mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Two field-reported gaps, unrelated except that both are about being able to read something off a screen. A PANEL'S IPv6 WAS NEVER COLLECTED, LET ALONE SHOWN. DeviceInfo.getLocalIp() filters to Inet4Address, so a v6-only panel reported no address at all and the dashboard rendered a dash for a screen that was perfectly reachable. It now reports both stacks in their own fields: a dual-stack panel genuinely has two addresses and either may be the one you need, so collapsing them into one column would make it mean "whichever interface enumerated first". Link-local (fe80::/10) is deliberately excluded. Every interface has one, they tend to enumerate first, and none can be dialled without also knowing the zone index — so admitting them would fill the field with a string nobody can paste anywhere and hide the address that works. Any %iface suffix is trimmed for the same reason. The 45-char cap the writer already applied is exactly the longest legitimate IPv6 text form, so it needed no change. The dashboard card renders only when a panel actually has a v6 address, rather than showing an empty row to the overwhelmingly v4 fleet. THE PAIRING CODE DID NOT SCALE, WHICH IS WORST WHERE IT MATTERS MOST. Every size on the pre-playback screens was a hard-coded pixel value. A CSS pixel covers a quarter of the screen area on a 4K panel that it does on 1080p, and a sixteenth on 8K — so the 72px code that fills a 1080p screen is a smudge on the 4K wall it was installed on, which is where signage actually goes. What has to stay constant is ANGULAR size, so the root font size is now viewport-proportional and everything on those screens is a rem against it. The code holds 6.67% of screen height at every resolution: 72px at 1080p — bit for bit what it renders today, so nothing changes for the existing fleet — 144px at 4K, 288px at 8K. Verified in a browser rather than by arithmetic: at a 1409px viewport the root computes to 13.0473px, which is 0.926vmin to four decimals. vmin, not vw, because portrait-mounted panels are common here and vw would render a 1080x1920 screen at half size. Clamped at both ends so the dashboard's preview iframe stays legible instead of microscopic and an ultrawide does not get silly. Applied to the web player (which BrightSign also runs) and to Tizen, where a 1920x1080 logical viewport makes it arithmetically identical to the values it replaces — the point being the panels where it is not. A test asserts the scaling cannot reach playback content: the whole safety argument is that only the chrome uses rem, and a stage or zone rule adopting it would start resizing CONTENT, which is a worse bug than the one being fixed. Android is untouched — its pairing code already autosizes within a dp-scaled layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
106 lines
6.2 KiB
JavaScript
106 lines
6.2 KiB
JavaScript
'use strict';
|
|
|
|
// Contract tests for the published OpenAPI spec. The spec is the integrator-facing
|
|
// contract, so it must not drift from what the server actually enforces. These parse
|
|
// docs/openapi.yaml directly (no server needed) and are derived from the same
|
|
// config/api-surface.js the server mounts from.
|
|
//
|
|
// Born from a real self-review finding: POST /widgets/preview was documented as scope
|
|
// 'read' while the method-based tokenScopeGate enforces 'write' for any POST, so a
|
|
// read-token integrator following the docs would hit a surprise 403. This makes that
|
|
// class of drift fail CI forever after.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const yaml = require('js-yaml');
|
|
const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS } = require('../config/api-surface');
|
|
|
|
const spec = yaml.load(fs.readFileSync(path.join(__dirname, '..', '..', 'docs', 'openapi.yaml'), 'utf8'));
|
|
const METHODS = ['get', 'post', 'put', 'delete', 'patch', 'head'];
|
|
// Spec paths are written without the /api prefix (servers: [{ url: /api }]).
|
|
const PUBLIC_PREFIXES = PUBLIC_ROUTERS.map(r => r.path.replace(/^\/api/, ''));
|
|
const JWT_ONLY_PREFIXES = JWT_ONLY_ROUTERS.map(r => r.path.replace(/^\/api/, ''));
|
|
const underPrefix = (p, prefixes) => prefixes.some(pre => p === pre || p.startsWith(pre + '/'));
|
|
|
|
test('openapi: every operation x-required-scope matches the method-based enforcement', () => {
|
|
// Mirrors tokenScopeGate (GET/HEAD -> read, mutations -> write) + requireScope('full')
|
|
// on the operational command route. Public render endpoints (security: []) carry no scope.
|
|
const mismatches = [];
|
|
for (const [p, ops] of Object.entries(spec.paths || {})) {
|
|
for (const [m, op] of Object.entries(ops)) {
|
|
if (!METHODS.includes(m) || !op || typeof op !== 'object') continue;
|
|
if (Array.isArray(op.security) && op.security.length === 0) continue; // unauthenticated render
|
|
// Operational/fleet-affecting routes require 'full' even though they aren't GETs:
|
|
// the group command route, and #109 PiP (push an arbitrary web overlay to devices).
|
|
const isFullScope = p.includes('command') || p === '/pip' || p.startsWith('/pip/');
|
|
const expected = (m === 'get' || m === 'head') ? 'read' : (isFullScope ? 'full' : 'write');
|
|
if (op['x-required-scope'] !== expected) {
|
|
mismatches.push(`${m.toUpperCase()} ${p}: spec='${op['x-required-scope']}' enforcement='${expected}'`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(mismatches, [], 'spec x-required-scope drifted from enforcement:\n' + mismatches.join('\n'));
|
|
});
|
|
|
|
test('openapi: every documented path is a token-reachable (public) router, never JWT-only', () => {
|
|
// The spec must never advertise a JWT-only / privileged route as part of the token
|
|
// surface (it would invite an integrator to call something their token can't reach).
|
|
const offenders = [];
|
|
for (const p of Object.keys(spec.paths || {})) {
|
|
if (underPrefix(p, JWT_ONLY_PREFIXES) || !underPrefix(p, PUBLIC_PREFIXES)) offenders.push(p);
|
|
}
|
|
assert.deepEqual(offenders, [], 'spec documents non-public paths:\n' + offenders.join('\n'));
|
|
});
|
|
|
|
// The published spec version is what Redoc prints at the top of the API reference, so a stale
|
|
// value tells integrators they are reading docs for a release that no longer exists. It HAD gone
|
|
// stale — the spec said 1.9.0 while 1.9.25 was shipping — because bump-version.sh updated every
|
|
// other version source and not this one. That step now exists; this test is what keeps it honest,
|
|
// since the failure mode is silent and nobody reads a version number they already trust.
|
|
test('openapi: the spec version tracks the shipped release', () => {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
// Pre-release labels (1.9.26-beta.1) live on the build, not on the published API identity,
|
|
// so compare the numeric core the way bump-version.sh writes it.
|
|
const numeric = (v) => String(v).split('-')[0];
|
|
assert.equal(
|
|
numeric(spec.info.version),
|
|
numeric(pkg.version),
|
|
'docs/openapi.yaml info.version drifted from server/package.json — bump-version.sh should ' +
|
|
'have moved both; if you edited a version by hand, move this one too',
|
|
);
|
|
});
|
|
|
|
// Two addresses that are easy to mix up: ip_address is the public/WAN address the SERVER observed
|
|
// on connect, local_ip is the LAN address the PLAYER reported about itself. An integrator reaching
|
|
// a panel on site needs local_ip; one correlating sites needs ip_address. Both are returned by
|
|
// GET /devices, so both must be documented and must not be described interchangeably.
|
|
test('openapi: a device documents its WAN and LAN addresses distinctly', () => {
|
|
const props = spec.components.schemas.Device.properties;
|
|
for (const field of ['ip_address', 'local_ip', 'local_ip6']) {
|
|
assert.ok(props[field], `Device.${field} is returned by GET /devices but is not documented`);
|
|
assert.ok(
|
|
props[field].type.includes('null'),
|
|
`Device.${field} must be nullable — it is absent until a device reports/connects`,
|
|
);
|
|
assert.ok(props[field].description, `Device.${field} needs a description to be told apart`);
|
|
}
|
|
assert.match(props.ip_address.description, /WAN|public/i);
|
|
assert.match(props.local_ip.description, /local network|LAN/i);
|
|
assert.match(props.local_ip6.description, /local network|LAN/i);
|
|
// The two LAN fields are a pair, not alternatives — a dual-stack panel reports both, so the
|
|
// spec must not let an integrator read one as a fallback for the other.
|
|
assert.match(props.local_ip.description, /IPv4/i);
|
|
assert.match(props.local_ip6.description, /IPv6/i);
|
|
});
|
|
|
|
// "permission" is a sentinel, not a network name: Android 10+ withholds the SSID without a
|
|
// location permission ScreenTinker only requests if an operator opts in. An integrator who does
|
|
// not know that will render it as the Wi-Fi name to an end user.
|
|
test('openapi: the wifi_ssid permission sentinel is documented', () => {
|
|
const ssid = spec.components.schemas.Device.properties.wifi_ssid;
|
|
assert.ok(ssid, 'wifi_ssid is returned by GET /devices but is not documented');
|
|
assert.match(ssid.description, /permission/, 'the sentinel value must be explained');
|
|
});
|