screentinker/server/test/openapi-contract.test.js
Claude c483ef34dd docs(api): document a device's WAN/LAN addresses and SSID sentinel, and stop the spec version drifting
The published API reference (frontend/api-docs.html renders docs/openapi.yaml through Redoc) said
version 1.9.0 while 1.9.25 was shipping. bump-version.sh updates VERSION, server/package.json,
android versionName/versionCode and tizen/config.xml — the spec was simply never added to it, so it
had been frozen since the public API landed and integrators were reading a version identity that no
longer existed.

Spec changes:

- info.version -> 1.9.25.
- Device gains its two network addresses, which are easy to confuse and are now described so they
  cannot be: ip_address is the PUBLIC/WAN address the server observed on connect (X-Forwarded-For
  aware, normally shared by every device at a site), local_ip is the device's OWN LAN address as
  reported by the player, which is the one that reaches a panel on site. local_ip is new; both were
  returned by GET /devices and neither was documented.
- Device gains its flattened latest-telemetry block (wifi_ssid, wifi_rssi, battery, storage, ram,
  cpu_usage, uptime_seconds) — all returned already, none documented, all nullable because a web
  player does not report what Android does.
- wifi_ssid's "permission" value is called out as 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 renders "permission" to an end user as their Wi-Fi name.

Drift prevention, because a wrong version number is silent and nobody re-reads one they trust:

- bump-version.sh now writes the spec version too, anchored to info.version (operation- and
  schema-level version keys are indented deeper and untouched; openapi: 3.1.0 is unaffected).
- Three contract tests: the spec version tracks package.json, the two addresses stay documented
  and distinct, and the SSID sentinel stays explained.

No new endpoints — audited every public router's routes against the spec and all are documented.
830 server tests + the 5 contract tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:26:47 -05:00

101 lines
5.8 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']) {
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);
});
// "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');
});