mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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
188 lines
9.2 KiB
JavaScript
188 lines
9.2 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* The capability gate on the dashboard socket's REMOTE handlers.
|
|
*
|
|
* dashboard:device-command has always refused a command the panel cannot honour, and the comment
|
|
* over it is right about why: "Hiding the button is not enforcement. This socket is reachable
|
|
* directly, and an older dashboard tab left open still renders the old controls."
|
|
*
|
|
* Every word of that applied to the four handlers sitting immediately ABOVE it, which had no gate
|
|
* at all. Measured against a real server: a display declaring `[]` still received
|
|
* screenshot-request, remote-touch, remote-key and remote-start. The dashboard even popped a toast
|
|
* ("Screenshot requested — it appears on the panel's device page") for a BrightSign, which has no
|
|
* screenshot capability at all. That is the "reports success and changes nothing" shape the whole
|
|
* capability model was written to end.
|
|
*
|
|
* End-to-end on a real server, because the bug was in the wiring: a real device socket registers, a
|
|
* real dashboard socket sends, and the assertion is on what the DEVICE actually received.
|
|
*/
|
|
|
|
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 crypto = require('node:crypto');
|
|
const Database = require('better-sqlite3');
|
|
const { io } = require('socket.io-client');
|
|
const { freePort } = require('./helpers/free-port');
|
|
|
|
const DATA_DIR = path.join(os.tmpdir(), 'st-remotegate-' + crypto.randomBytes(4).toString('hex'));
|
|
const DBPATH = path.join(DATA_DIR, 'db', 'remote_display.db');
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
let proc, BASE, PORT, jwt, workspaceId;
|
|
|
|
// Every device below is an ANDROID panel. The only difference between them is the declaration, so
|
|
// a difference in outcome can only come from the declaration — not from a platform baseline.
|
|
const DEVICES = [
|
|
['d-declares-nothing', null], // the fielded fleet: NULL => android baseline
|
|
['d-declares-empty', '[]'], // a real statement: I can do nothing
|
|
['d-declares-shot-only', '["remote.screenshot"]'],
|
|
];
|
|
|
|
before(async () => {
|
|
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);
|
|
}
|
|
const reg = await fetch(BASE + '/api/auth/register', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'gate@test.local', password: 'GatePassw0rd!', name: 'Gate' }),
|
|
}).then((r) => r.json());
|
|
jwt = reg.token;
|
|
workspaceId = reg.current_workspace_id;
|
|
|
|
const seed = new Database(DBPATH);
|
|
const ins = seed.prepare(`INSERT INTO devices
|
|
(id, user_id, workspace_id, name, pairing_code, status, client_type, android_version, capabilities, device_token)
|
|
VALUES (?, ?, ?, ?, ?, 'online', 'apk', '14', ?, ?)`);
|
|
let n = 0;
|
|
for (const [id, caps] of DEVICES) ins.run(id, reg.user.id, workspaceId, id, String(900001 + n++), caps, 'tok-' + id);
|
|
seed.close();
|
|
});
|
|
|
|
after(async () => {
|
|
if (proc) proc.kill('SIGKILL');
|
|
await sleep(200);
|
|
});
|
|
|
|
function dashboard() {
|
|
return new Promise((resolve, reject) => {
|
|
const s = io(`${BASE}/dashboard`, { transports: ['websocket'], auth: { token: jwt }, reconnection: false });
|
|
s.on('connect', () => resolve(s));
|
|
s.on('connect_error', reject);
|
|
});
|
|
}
|
|
|
|
// Connect a real device socket and record every event the SERVER sends it.
|
|
function device(deviceId) {
|
|
return new Promise((resolve, reject) => {
|
|
const s = io(`${BASE}/device`, { transports: ['websocket'], reconnection: false });
|
|
const got = [];
|
|
for (const ev of ['device:screenshot-request', 'device:remote-touch', 'device:remote-key', 'device:remote-start', 'device:remote-stop', 'device:command']) {
|
|
s.on(ev, () => got.push(ev));
|
|
}
|
|
s.on('connect', () => s.emit('device:register', { device_id: deviceId, device_token: 'tok-' + deviceId }));
|
|
s.on('device:registered', () => resolve({ sock: s, got }));
|
|
s.on('connect_error', reject);
|
|
setTimeout(() => reject(new Error('register timed out for ' + deviceId)), 10000);
|
|
});
|
|
}
|
|
|
|
test('a display declaring [] receives NONE of the remote requests', async () => {
|
|
const dash = await dashboard();
|
|
const dev = await device('d-declares-empty');
|
|
await sleep(250);
|
|
dev.got.length = 0;
|
|
dash.emit('dashboard:request-screenshot', { device_id: 'd-declares-empty' });
|
|
dash.emit('dashboard:remote-touch', { device_id: 'd-declares-empty', x: 5, y: 5 });
|
|
dash.emit('dashboard:remote-key', { device_id: 'd-declares-empty', keycode: 4 });
|
|
dash.emit('dashboard:remote-start', { device_id: 'd-declares-empty' });
|
|
await sleep(700);
|
|
assert.deepEqual(dev.got, [], 'a panel that says it can do nothing is sent nothing');
|
|
dev.sock.disconnect(); dash.disconnect();
|
|
});
|
|
|
|
test('the refusal names the missing capability when the caller asks for an ack', async () => {
|
|
const dash = await dashboard();
|
|
const dev = await device('d-declares-empty');
|
|
await sleep(250);
|
|
const ack = (event) => new Promise((res) => {
|
|
let done = false;
|
|
dash.emit(event, { device_id: 'd-declares-empty', x: 1, y: 1, keycode: 4 }, (a) => { done = true; res(a); });
|
|
setTimeout(() => { if (!done) res(null); }, 800);
|
|
});
|
|
assert.deepEqual(await ack('dashboard:request-screenshot'), { delivered: false, reason: 'unsupported', capability: 'remote.screenshot' });
|
|
assert.deepEqual(await ack('dashboard:remote-touch'), { delivered: false, reason: 'unsupported', capability: 'remote.input' });
|
|
assert.deepEqual(await ack('dashboard:remote-key'), { delivered: false, reason: 'unsupported', capability: 'remote.input' });
|
|
assert.deepEqual(await ack('dashboard:remote-start'), { delivered: false, reason: 'unsupported', capability: 'remote.stream' });
|
|
dev.sock.disconnect(); dash.disconnect();
|
|
});
|
|
|
|
test('THE FLEET: a display that has never declared anything still gets everything it always had', async () => {
|
|
// The failure that would be worse than the bug. Several hundred Android panels declare nothing;
|
|
// if "declared nothing" resolved to "supports nothing", this gate would strip remote control
|
|
// from the entire installed base on the day it deployed.
|
|
const dash = await dashboard();
|
|
const dev = await device('d-declares-nothing');
|
|
await sleep(250);
|
|
dev.got.length = 0;
|
|
dash.emit('dashboard:request-screenshot', { device_id: 'd-declares-nothing' });
|
|
dash.emit('dashboard:remote-touch', { device_id: 'd-declares-nothing', x: 5, y: 5 });
|
|
dash.emit('dashboard:remote-key', { device_id: 'd-declares-nothing', keycode: 4 });
|
|
dash.emit('dashboard:remote-start', { device_id: 'd-declares-nothing' });
|
|
await sleep(700);
|
|
assert.deepEqual(dev.got.sort(), [
|
|
'device:remote-key', 'device:remote-start', 'device:remote-touch', 'device:screenshot-request',
|
|
], 'the undeclared Android baseline keeps the whole remote surface');
|
|
dev.sock.disconnect(); dash.disconnect();
|
|
});
|
|
|
|
test('the gate is per-capability, not all-or-nothing', async () => {
|
|
// A panel with accessibility on but no input injection declares screenshot alone. It must keep
|
|
// the screenshot and lose the pad, not lose both or keep both.
|
|
const dash = await dashboard();
|
|
const dev = await device('d-declares-shot-only');
|
|
await sleep(250);
|
|
dev.got.length = 0;
|
|
dash.emit('dashboard:request-screenshot', { device_id: 'd-declares-shot-only' });
|
|
dash.emit('dashboard:remote-touch', { device_id: 'd-declares-shot-only', x: 5, y: 5 });
|
|
dash.emit('dashboard:remote-start', { device_id: 'd-declares-shot-only' });
|
|
await sleep(700);
|
|
assert.deepEqual(dev.got, ['device:screenshot-request']);
|
|
dev.sock.disconnect(); dash.disconnect();
|
|
});
|
|
|
|
test('remote-stop is never refused — it is the way out of a stuck stream', async () => {
|
|
const dash = await dashboard();
|
|
const dev = await device('d-declares-empty');
|
|
await sleep(250);
|
|
dev.got.length = 0;
|
|
dash.emit('dashboard:remote-stop', { device_id: 'd-declares-empty' });
|
|
await sleep(600);
|
|
assert.deepEqual(dev.got, ['device:remote-stop'],
|
|
'a panel whose declaration changed mid-stream must still be stoppable');
|
|
dev.sock.disconnect(); dash.disconnect();
|
|
});
|
|
|
|
test('GET /api/devices ships the RESOLVED capability array, not the raw column', async () => {
|
|
// The list used to carry the raw TEXT column: '[]' or null. Array.isArray('[]') is false, so the
|
|
// dashboard's `can()` helper read a device that declared "I can do nothing" as "pre-capability
|
|
// server — show everything", which is the wrong answer in the one case that matters.
|
|
const rows = await fetch(BASE + '/api/devices', { headers: { Authorization: 'Bearer ' + jwt } }).then((r) => r.json());
|
|
const byId = Object.fromEntries(rows.map((d) => [d.id, d]));
|
|
assert.ok(Array.isArray(byId['d-declares-empty'].capabilities), 'an array, not a JSON string');
|
|
assert.deepEqual(byId['d-declares-empty'].capabilities, [], 'an empty declaration is honoured');
|
|
assert.ok(byId['d-declares-nothing'].capabilities.includes('remote.screenshot'),
|
|
'and an absent declaration still resolves to its platform baseline');
|
|
});
|