screentinker/server/test/openapi-contract.test.js
ScreenTinker 5f83fc20d3 docs(api): document /api/pip and the assignments muted field (#109/#129)
The PiP endpoints and the per-item mute field shipped without OpenAPI coverage.

- openapi.yaml: add POST /pip (show), DELETE /pip + POST /pip/clear (clear), all
  x-required-scope: full; add the `muted` boolean to PUT /assignments/{id}; add a `pip` tag.
- openapi-contract.test.js: the scope heuristic only treated `command` paths as full-scope,
  so a full-scope non-command route (/pip) would fail it — extend it to recognize /pip.

Docs-only as far as the running build goes (no route/behavior change). Lands on main; not
in the frozen v1.9.1-beta4 tag — ships in the next tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:36:12 -05:00

56 lines
3.1 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'));
});