screentinker/server/test/uploads-missing-file-404.test.js
ScreenTinker 3e37d33b80 QA: close four ways a control or an asset lied about itself
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.

1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
   miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
   15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
   sets before it knows the file exists. Every player downloader treats 200 as success,
   so a panel stores the HTML page AS the video and caches it for a month, rendering a
   black frame with nothing in any log. Reachable exactly when it hurts: a content
   replace writes a new random filename and unlinks the old one. The mount now
   terminates a miss with a 404 and drops the cache header.

2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
   has always refused a command the panel cannot honour, and the comment above it is right
   about why ("hiding the button is not enforcement — this socket is reachable directly").
   Every word applied to the four handlers immediately above it, which had none: a display
   declaring [] still received screenshot-request, remote-touch, remote-key and
   remote-start. Measured, not inferred. They now refuse on remote.screenshot /
   remote.input / remote.stream and name the capability in the ack; remote-stop stays
   ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
   absent declaration still resolves to its platform baseline and keeps everything.

   The wall panel list (#235) made this visible: it offered a Screenshot button for every
   panel, including a BrightSign, which has no screenshot capability at all, and popped a
   toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
   capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
   as "pre-capability server, show everything" — wrong in the one case that matters), so
   the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
   Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
   commands on display.power; both now agree.

3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
   field to the literal 'unknown' and persistIdentity wrote it straight over. That column
   is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
   a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
   for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
   power and reboot and gained screenshots it cannot take. platform and client_type are
   now preserved (physical facts); client_version and contract_version still decay, because
   there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
   second signal for a Tizen TV.

4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
   a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
   brand-new "default an item to the clip's own length" then handed out the wrong number
   for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
   image measured it with raw sharp metadata and thumbnailed without .rotate(),
   re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
   share lib/content-ingest.deriveMediaMetadata.

Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.

Full suite green (1319).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:12:29 -05:00

82 lines
3.7 KiB
JavaScript

'use strict';
/*
* A missing upload must 404, not hand the player the dashboard.
*
* express.static calls next() on a miss, and the only thing downstream of /uploads/content was the
* SPA catch-all. So GET /uploads/content/<gone>.mp4 answered 200 OK, Content-Type: text/html, with
* 15KB of index.html — under the `public, max-age=2592000, immutable` header the mount had already
* set on the way in, before it knew whether the file existed.
*
* For a player that is the worst possible answer. Every downloader treats 200 as success, so the
* panel stores the HTML page AS the video, caches it for a month, and renders a black frame with
* nothing in any log to explain it. And it is reachable exactly when it hurts: a content REPLACE
* writes a new randomly-named file and unlinks the old one, so every snapshot still pointing at the
* old name asks for a file that is gone.
*
* Whole-server test on purpose — the bug was the ORDER of two mounts, which no unit test can see.
*/
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const { freePort } = require('./helpers/free-port');
const DATA_DIR = path.join(os.tmpdir(), 'st-uploads404-' + crypto.randomBytes(4).toString('hex'));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let proc, BASE;
before(async () => {
const PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: 'ignore',
});
for (let i = 0; i < 100; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) break; } catch { /* booting */ }
await sleep(150);
}
});
after(async () => { if (proc) proc.kill('SIGKILL'); await sleep(150); });
test('a missing media file is a 404, not 200 with an HTML page', async () => {
const res = await fetch(BASE + '/uploads/content/00000000-0000-0000-0000-000000000000.mp4');
assert.equal(res.status, 404);
const ct = res.headers.get('content-type') || '';
assert.ok(!ct.includes('text/html'), `a player must never be handed HTML as a video (got ${ct})`);
});
test('and it is not cached for a month — immutable is a promise about a file that exists', async () => {
const res = await fetch(BASE + '/uploads/content/also-not-here.png');
assert.equal(res.status, 404);
const cc = res.headers.get('cache-control') || '';
assert.ok(!cc.includes('immutable'), `a 404 must not be cached as the asset (got "${cc}")`);
});
test('a file that IS there still serves, with its own type and the long cache', async () => {
// The guard must terminate ONLY the miss. A 404 on a present file would black out every screen.
const contentDir = path.join(DATA_DIR, 'uploads', 'content');
fs.mkdirSync(contentDir, { recursive: true });
const name = 'present-' + crypto.randomBytes(4).toString('hex') + '.png';
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
fs.writeFileSync(path.join(contentDir, name), png);
const res = await fetch(`${BASE}/uploads/content/${name}`);
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/png');
assert.ok((res.headers.get('cache-control') || '').includes('immutable'), 'real assets keep the 30-day cache');
assert.equal(Buffer.from(await res.arrayBuffer()).length, png.length);
});
test('path traversal out of the content dir is still not reachable', async () => {
const res = await fetch(BASE + '/uploads/content/..%2f..%2f..%2fetc%2fpasswd');
assert.notEqual(res.status, 200);
});