screentinker/server/test/device-command-gating.test.js
ScreenTinker c1270599c3 Make the parity matrix true, and stop three controls that do nothing
The parity doc and the capability model had drifted from the players in both
directions, and nothing failed when they did. Auditing all four players against
their shipped sources turned up three controls a customer can press today that
change nothing, and a set of baselines that were partly too generous and partly
too stingy.

The three dead controls:

  - The volume slider works on Android only. The dashboard sends set_volume as
    { level: 0..1 }; the web player reads payload.value and Tizen reads
    payload.value ?? payload.volume, so on both the number is undefined and the
    handler quietly declines. Three complete, working volume implementations
    that cannot be driven. The fix is one line in each player and belongs to
    those files; audio.volume is out of the web and brightsign baselines until
    it lands, held there by a biconditional test that fails the moment a player
    starts reading `level`.

  - Every #161 Tier-2 command was refused for the entire fleet. lock_now,
    power_menu, status_bar, block_uninstall and unblock_uninstall were gated on
    system.device_owner, which no player declares and no baseline grants, so
    supports() was false everywhere -- including on the device-owner panels the
    feature was built for. The dashboard still drew the buttons because it also
    gates on device.tier === 2. Fixed here: those five now accept
    system.device_owner OR system.kiosk, which PlayerCapabilities.kt declares
    under `if (isOwner)` and nothing else, and which no non-Android player
    declares. Android should declare system.device_owner and retire the
    stand-in.

  - enable_system_capture required the capability it creates. It raises the
    MediaProjection consent dialog -- the way a panel GAINS capture -- and was
    gated on remote.screenshot, so the only panel that needs it was the one
    panel that could not be sent it. Now ungated. The dashboard still hides the
    button behind the same check; that half is a frontend change.

The baselines describe what an un-updated fielded display can do, and since
v1.9.29 is the first build in which any player declares anything, that means
v1.9.28. Every entry is now justified against `git show v1.9.28:<source>`:

  - android loses display.power (v1.9.28 answers screen_on with a logged no-op,
    so the ON half is dead on every fielded panel and one capability renders
    both buttons) and system.reboot (owner-only; off-owner it paints an
    accessibility power dialog over the signage). Scheduled reboots now skip
    undeclared Android panels rather than logging a reboot that never happened,
    which is the reason that gate exists.
  - tizen gains display.power: v1.9.28 implements both halves with no signing
    and no panel API, so withholding it hid a working control.
  - brightsign loses audio.volume, display.power, system.reboot,
    system.restart_player and offline.cache. All need a host bridge the unit is
    not known to have, and restart_player without one is the page reload that
    darkened a panel on 2026-07-28.

Also found, not fixed here because the files belong to others:
st-bridge.js computeCapabilities() is dead code -- nothing calls BS.capabilities()
-- and its 199 lines of passing tests constrain nothing a BrightSign actually
declares; the two disagree on six capabilities and the bridge is right about
most of them. BrightSign's "Force update" button is dead. PlayerCapabilities.kt
under-declares display.brightness.

The new test reads the player sources rather than the table: a dead-button rule
(every gated command has a branch somewhere), an unreachable-capability rule
(which would have caught system.device_owner), and biconditionals so a fix in a
player fails the test until the baseline follows. Claims that need hardware --
CEC reaching a display, a widget being allowed a service worker, SyncManager
holding frame lock -- are marked unverifiable in the document instead of
asserted.

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

97 lines
5.7 KiB
JavaScript

'use strict';
// Hiding a button is not enforcement, and the dashboard is not the only way to send a command.
// The socket is reachable directly, a group send fans out to a mixed-platform fleet, and an
// operator with a tab open from before the panel declared anything still has the old controls on
// screen. In every one of those paths a command the player cannot honour used to be DELIVERED and
// silently dropped — the "reports success and changes nothing" shape again, one layer down.
//
// So the refusal lives on the server and names the capability, and the group route reports the
// skipped devices separately from the ones it actually reached. A group toast that counts an
// unreachable web player as "sent" is how an operator walks away believing the whole group
// rebooted.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const caps = require('../lib/player-capabilities');
test('a browser tab is refused reboot, and the refusal says which capability was missing', () => {
const web = { android_version: 'Web/Chrome' };
const verdict = caps.commandAllowed(web, 'reboot');
assert.equal(verdict.ok, false);
assert.equal(verdict.capability, 'system.reboot', 'the operator has to be told WHY, not just "no"');
});
test('the legacy fleet is not locked out of the commands it has always accepted', () => {
// The failure mode that would be worse than the bug: several hundred Android displays declare
// nothing, and a refusal keyed off "declared nothing => supports nothing" bricks every control
// in the product at once.
const legacy = { client_type: 'apk', android_version: '9' };
for (const cmd of ['launch', 'refresh', 'update', 'set_volume', 'set_brightness']) {
assert.equal(caps.commandAllowed(legacy, cmd).ok, true, `${cmd} must still reach a legacy Android panel`);
}
// reboot / screen_on / screen_off are NOT on that list any more, and that is the parity audit's
// finding rather than an oversight: v1.9.28 answers screen_on with a logged no-op on every
// panel, and STPolicy.reboot() needs device owner. Keeping them would have been the other half
// of the same bug — a control that appears to work and changes nothing.
for (const cmd of ['reboot', 'screen_on', 'screen_off']) {
assert.equal(caps.commandAllowed(legacy, cmd).ok, false, `${cmd} is privilege-gated on a fielded panel`);
}
});
test('a command with no capability requirement is never refused', () => {
// set_debug is diagnostics. Gating it would take away the tool you reach for precisely when a
// panel is misreporting what it can do.
assert.equal(caps.capabilityForCommand('set_debug'), null);
assert.equal(caps.commandAllowed({ android_version: 'Web/Chrome' }, 'set_debug').ok, true);
});
test('an unrecognised command type is passed through, not silently swallowed', () => {
// New player features ship before the server learns their names. Refusing by default would make
// every such command fail with a confusing "unsupported" instead of reaching the panel.
assert.equal(caps.capabilityForCommand('some_future_command'), null);
assert.equal(caps.commandAllowed({ client_type: 'apk' }, 'some_future_command').ok, true);
});
test('shutdown and reboot share one privilege, so a panel cannot be half-refused', () => {
// They are the same "device power lifecycle" authority. Splitting them produced a UI with
// Shutdown present and Reboot missing on the same display, which reads as a broken dashboard.
assert.equal(caps.capabilityForCommand('shutdown'), caps.capabilityForCommand('reboot'));
});
test('the per-window dim is NOT the backlight — conflating them hides a working slider', () => {
// set_brightness is the player's own overlay (Android Tier 0, no device owner);
// set_system_brightness writes the real backlight and needs settings-write. Mapping both to
// system.brightness — which is deliberately absent from every baseline because it is
// conditional — would have removed the overlay slider from the entire undeclared Android fleet.
const legacy = { client_type: 'apk', android_version: '11' };
assert.equal(caps.commandAllowed(legacy, 'set_brightness').ok, true, 'overlay dim has always worked here');
assert.equal(caps.commandAllowed(legacy, 'set_system_brightness').ok, false, 'backlight is conditional');
assert.notEqual(caps.capabilityForCommand('set_brightness'), caps.capabilityForCommand('set_system_brightness'));
});
test('every command in the map points at a capability that actually exists', () => {
// A typo here does not fail loudly: supports() returns false for an unknown name, so the command
// is refused for EVERY device on every platform, forever.
for (const cmd of Object.keys(caps.COMMAND_CAPABILITY)) {
// A command may name several capabilities, any of which is enough; all of them must be real.
for (const cap of caps.capabilitiesForCommand(cmd)) {
assert.ok(caps.CAP_SET.has(cap), `${cmd} maps to unknown capability ${cap}`);
}
}
});
test('a device row that failed to load refuses everything rather than guessing', () => {
// A missing row would otherwise fall through platformFamily() to the web baseline and cheerfully
// authorise commands against a device that does not exist.
assert.equal(caps.commandAllowed(null, 'reboot').ok, false);
assert.equal(caps.commandAllowed(undefined, 'set_volume').ok, false);
});
test('a player declaring nothing at all is refused every gated command', () => {
const mute = { client_type: 'apk', capabilities: '[]' };
assert.equal(caps.commandAllowed(mute, 'reboot').ok, false);
assert.equal(caps.commandAllowed(mute, 'set_volume').ok, false);
assert.equal(caps.commandAllowed(mute, 'set_debug').ok, true, 'ungated commands still pass');
});