screentinker/server/test/identity-platform-preserved.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

109 lines
6.3 KiB
JavaScript

'use strict';
/*
* A register that does not mention `platform` must not erase the one we have.
*
* liveness.captureIdentity() coerces a missing platform to the literal string 'unknown', and
* persistIdentity() wrote that straight over the stored value. So a single register from any
* client that doesn't send the field — an older build, a downgrade, anything pre-v4 — permanently
* turned a known Tizen panel into 'unknown'.
*
* That column is load-bearing: player-capabilities.platformFamily() reads it to pick a baseline.
* A cleared Tizen panel falls through to the WEB baseline, which hands it audio.volume (the .wgt
* has no set_volume handler — that is precisely why BASELINE.tizen omits it) and offline.cache
* (Tizen caches the playlist JSON, not the media). The same clobber costs a BrightSign its screen
* power and reboot and gives it screenshots it cannot take.
*
* The rule is the one applyCapabilities() already documents one screen up: an ABSENT declaration
* is not a statement about the device.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const liveness = require('../lib/liveness');
const caps = require('../lib/player-capabilities');
// What persistIdentity does with a register payload, minus the DB round trip.
const resolve = (stored, data) => liveness.preserveKnownIdentity(stored, liveness.captureIdentity(data));
test('a register with no platform leaves a known platform alone', () => {
const tizen = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
assert.equal(resolve(tizen, { device_id: 'x' }).platform, 'Tizen 6.0');
const bs = { client_type: 'player', client_version: '1.9.29', platform: 'brightsign', contract_version: 'v4' };
assert.equal(resolve(bs, { device_id: 'x' }).platform, 'brightsign');
});
test('the capability baseline survives that register — which is the whole point', () => {
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
const after = resolve(stored, { device_id: 'x' }); // an old client reconnects
const row = { platform: after.platform, client_type: after.client_type, android_version: null };
assert.equal(caps.platformFamily(row), 'tizen');
assert.equal(caps.supports(row, 'audio.volume'), false,
'a fielded .wgt has no set_volume handler — the web baseline would have offered the slider anyway');
assert.equal(caps.supports(row, 'offline.cache'), false,
'Tizen caches the playlist JSON, not the media, so content does NOT survive an outage');
const bsAfter = resolve({ client_type: 'player', client_version: '1.9.29', platform: 'brightsign', contract_version: 'v4' }, {});
const bsRow = { platform: bsAfter.platform, client_type: bsAfter.client_type, android_version: null };
assert.equal(caps.platformFamily(bsRow), 'brightsign');
assert.equal(caps.supports(bsRow, 'system.reboot'), true, 'a BrightSign really can reboot');
assert.equal(caps.supports(bsRow, 'remote.screenshot'), false, 'and really cannot screenshot');
});
test('a register that DOES declare a platform still updates it', () => {
// Preserving must not become "the first value wins forever": a genuine change (a panel
// re-flashed, a row reused for different hardware) has to land.
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
const i = resolve(stored, { platform: 'Tizen 7.0', client_type: 'wgt', client_version: '2.0', contract_version: 'v4' });
assert.equal(i.platform, 'Tizen 7.0');
assert.equal(liveness.identityChanged(stored, i), true, 'and the change is still detected, so it is written');
});
test('a device that never had a platform is not invented one', () => {
assert.equal(resolve(null, {}).platform, 'unknown', 'still honest about not knowing');
assert.equal(resolve({ platform: 'unknown' }, {}).platform, 'unknown');
});
test('an unchanged identity still short-circuits the write', () => {
// The A1 optimisation this function sits inside: a plain reconnect must stay a read with no
// UPDATE. Preserving the platform must not make every register look like a change.
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
assert.equal(liveness.identityChanged(stored, resolve(stored, {
platform: 'Tizen 6.0', client_type: 'wgt', client_version: '1.9.29', contract_version: 'v4',
})), false);
});
test('client_type wgt is a second, independent signal that this is a Tizen TV', () => {
// Belt and braces for a row whose platform was already cleared by the old behaviour: the .wgt
// player sends client_type 'wgt' (tizen/js/app.js), so one lost column is not the end of it.
const cleared = { platform: 'unknown', client_type: 'wgt', android_version: null };
assert.equal(caps.platformFamily(cleared), 'tizen');
assert.equal(caps.supports(cleared, 'audio.volume'), false);
});
test('client_type is preserved too — otherwise the second signal decays with the first', () => {
// captureIdentity coerces a missing client_type to 'legacy'. Preserving `platform` alone would
// still leave a panel that reconnects from an older build with NOTHING identifying it.
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
assert.equal(resolve(stored, {}).client_type, 'wgt');
assert.equal(resolve({ client_type: 'apk', platform: 'Android 14' }, {}).client_type, 'apk');
});
test('the version fields still decay — a stale build number is not an improvement', () => {
// The other half of the split: platform/client_type are physical facts, client_version and
// contract_version are properties of the build currently installed and change with every OTA.
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
const i = resolve(stored, {});
assert.equal(i.client_version, 'unknown');
assert.equal(i.contract_version, 'legacy');
});
test('the Android fleet is untouched by the wgt rule', () => {
assert.equal(caps.platformFamily({ client_type: 'apk', android_version: '14' }), 'android');
assert.equal(caps.platformFamily({ android_version: '14' }), 'android');
assert.equal(caps.platformFamily({ android_version: 'Web/Chrome 120' }), 'web');
assert.equal(caps.platformFamily({}), 'web');
});