test(api): token partition + threat-model + device WS coverage

A dedicated public-API suite (boots the real server as a subprocess) so CI green proves
the token layer, not just the pre-existing tests:

- Partition firewall, derived from the SAME config/api-surface.js server.js mounts from:
  every JWT-only router 401s a token; a public-surface snapshot fails if any router is
  added to the token door; known-privileged routers asserted JWT-only.
- Threat model: role-strip gates, workspace-binding both directions (token ignores
  X-Workspace-Id, JWT honors it), the scope ladder, the render bypass, token lifecycle,
  and JWT no-regression.
- Device WS round-trip via socket.io-client (added as a devDep): valid device_token
  registers + receives its playlist; wrong token rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-12 13:34:32 -05:00 committed by screentinker
parent c1b9c27f3a
commit 2ad9f54b8e
3 changed files with 363 additions and 0 deletions

114
server/package-lock.json generated
View file

@ -24,6 +24,9 @@
"stripe": "^20.4.1",
"unzipper": "^0.12.3",
"uuid": "^14.0.0"
},
"devDependencies": {
"socket.io-client": "^4.8.3"
}
},
"node_modules/@azure/msal-common": {
@ -1470,6 +1473,67 @@
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.20.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@ -3082,6 +3146,47 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-client/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
@ -3629,6 +3734,15 @@
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -25,5 +25,8 @@
"stripe": "^20.4.1",
"unzipper": "^0.12.3",
"uuid": "^14.0.0"
},
"devDependencies": {
"socket.io-client": "^4.8.3"
}
}

246
server/test/api.test.js Normal file
View file

@ -0,0 +1,246 @@
'use strict';
// Public-API integration suite. Boots the REAL server.js as a subprocess against an
// isolated DB and exercises the token front door end to end. Three tiers:
// 1. Partition firewall - every JWT-only router 401s a token; derived from the SAME
// config/api-surface.js that server.js mounts from, so the
// test and the mount list cannot drift.
// 2. Threat model - the 6 categories we verified by hand (gates, binding,
// scope ladder, render bypass, lifecycle, JWT no-regression).
// 3. Device WS round-trip - real socket.io-client: valid token registers, wrong rejected.
// Node built-ins + socket.io-client (devDep) only.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const ioClient = require('socket.io-client');
const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS } = require('../config/api-surface');
const PORT = 3978;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-api-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-api-test-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc;
const S = {}; // shared fixtures populated in before()
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null;
try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const auth = (tok, extra = {}) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json', ...extra } });
const post = (tok, obj, extra) => ({ method: 'POST', ...auth(tok, extra), body: JSON.stringify(obj) });
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
// wait for the server to answer /api/status
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
// user1 (first user -> platform_admin, workspace A); user2 (workspace B)
let r = await jfetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'u1@test.local', password: 'test12345', name: 'U1' }) });
S.jwt = r.body.token; S.user1 = r.body.user.id;
r = await jfetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'u2@test.local', password: 'test12345', name: 'U2' }) });
S.jwt2 = r.body.token;
// scoped tokens (read/write/full) for user1, all bound to workspace A
S.tok = {};
for (const scope of ['read', 'write', 'full']) {
const c = await jfetch('/api/tokens', post(S.jwt, { name: scope, scope }));
S.tok[scope] = c.body.token; S.wsA = c.body.workspace_id;
}
// workspace B id (from a user2 token)
S.wsB = (await jfetch('/api/tokens', post(S.jwt2, { name: 'b', scope: 'read' }))).body.workspace_id;
// marker playlists (one per workspace) + a group + a widget
S.playlistA = (await jfetch('/api/playlists', post(S.jwt, { name: 'PA-marker' }))).body.id;
await jfetch('/api/playlists', post(S.jwt2, { name: 'PB-marker' }));
S.groupId = (await jfetch('/api/groups', post(S.jwt, { name: 'G' }))).body.id;
S.widgetId = (await jfetch('/api/widgets', post(S.jwt, { name: 'W', widget_type: 'clock', config: {} }))).body.id;
// a paired device with a known token (for the WS round-trip) - inserted into the
// server's live DB (WAL: a second connection's commit is visible to the server).
const db = new (require('better-sqlite3'))(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 });
S.deviceId = crypto.randomUUID();
S.deviceToken = 'devtok_' + crypto.randomBytes(16).toString('hex');
db.prepare("INSERT INTO devices (id,name,user_id,workspace_id,device_token,status,created_at) VALUES (?,?,?,?,?,'offline',strftime('%s','now'))")
.run(S.deviceId, 'WS-dev', S.user1, S.wsA, S.deviceToken);
db.close();
});
after(() => {
if (proc) proc.kill('SIGKILL');
for (const f of [DATA_DIR, LOG]) { try { fs.rmSync(f, { recursive: true, force: true }); } catch { /* */ } }
});
// ───────────────────────── TIER 1: PARTITION FIREWALL ─────────────────────────
// Derived from config/api-surface.js (the same list server.js mounts from). The day
// someone gives a JWT-only router the token door (or moves a privileged router into the
// public set), one of these fails.
for (const r of JWT_ONLY_ROUTERS) {
test(`firewall: JWT-only ${r.path} rejects a Bearer st_ token (401)`, async () => {
const res = await jfetch(r.path, auth(S.tok.read));
assert.equal(res.status, 401, `${r.path} must 401 a token - a token reached a privileged router`);
});
}
for (const r of PUBLIC_ROUTERS) {
test(`partition: public ${r.path} accepts a token (not 401)`, async () => {
const res = await jfetch(r.path, auth(S.tok.read));
assert.notEqual(res.status, 401, `${r.path} is public but rejected a valid token`);
});
}
test('partition: known-privileged routers are JWT-only and never public', () => {
const MUST_BE_PRIVATE = ['/api/admin', '/api/workspaces', '/api/ai', '/api/provision', '/api/white-label', '/api/tokens'];
const jwtOnly = new Set(JWT_ONLY_ROUTERS.map(r => r.path));
const publicSet = new Set(PUBLIC_ROUTERS.map(r => r.path));
for (const p of MUST_BE_PRIVATE) {
assert.ok(jwtOnly.has(p), `${p} must be in JWT_ONLY_ROUTERS`);
assert.ok(!publicSet.has(p), `${p} must NOT be on the token door (PUBLIC_ROUTERS)`);
}
});
test('partition: public and JWT-only sets are disjoint', () => {
const publicSet = new Set(PUBLIC_ROUTERS.map(r => r.path));
for (const r of JWT_ONLY_ROUTERS) assert.ok(!publicSet.has(r.path), `${r.path} is in BOTH sets`);
});
test('partition: the public token surface is exactly the reviewed set (snapshot firewall)', () => {
// Putting a router on the token door must be a DELIBERATE, reviewed change: update this
// list and justify it in review. A NEW privileged route silently mounted on the token
// front door (the failure mode we care about) fails HERE.
const EXPECTED_PUBLIC = [
'/api/devices', '/api/content', '/api/folders', '/api/assignments', '/api/layouts',
'/api/widgets', '/api/schedules', '/api/walls', '/api/reports', '/api/groups',
'/api/playlists', '/api/activity', '/api/kiosk',
].sort();
assert.deepEqual(PUBLIC_ROUTERS.map(r => r.path).sort(), EXPECTED_PUBLIC);
});
// ───────────────────────── TIER 2: THREAT MODEL ─────────────────────────
// (a) in-handler privileged gates: the role-strip makes platform/elevated checks deny a
// token. /devices/unassigned is the canonical ELEVATED gate; the template-write gates on
// content/folders/layouts/widgets/kiosk share the identical !PLATFORM_ROLES(role='user').
test('gate: GET /api/devices/unassigned denies a token (403, ELEVATED gate via role-strip)', async () => {
const res = await jfetch('/api/devices/unassigned', auth(S.tok.full)); // full scope passes the scope gate; the in-handler gate fires
assert.equal(res.status, 403);
});
test('gate: a token cannot create a platform template (role-strip)', async () => {
// PLATFORM_ROLES gate on layout templates - either 403 or the flag is silently dropped.
const res = await jfetch('/api/layouts', post(S.tok.full, { name: 'T', is_template: true, zones: [] }));
const isTemplate = res.body && (res.body.is_template === 1 || res.body.is_template === true);
assert.ok(res.status === 403 || !isTemplate, 'token created a platform template');
});
// (b) workspace-binding strip - token IGNORES X-Workspace-Id, JWT HONORS it (both directions)
test('binding: a token IGNORES X-Workspace-Id (stays in its bound workspace)', async () => {
const res = await jfetch('/api/playlists', auth(S.tok.read, { 'X-Workspace-Id': S.wsB }));
const names = (Array.isArray(res.body) ? res.body : res.body.playlists || []).map(p => p.name);
assert.ok(names.includes('PA-marker'), 'token should still see workspace A');
assert.ok(!names.includes('PB-marker'), 'token leaked into workspace B via the header');
});
test('binding: a JWT HONORS X-Workspace-Id (multi-workspace switching intact)', async () => {
const withHdr = await jfetch('/api/playlists', auth(S.jwt, { 'X-Workspace-Id': S.wsB }));
const wNames = (Array.isArray(withHdr.body) ? withHdr.body : withHdr.body.playlists || []).map(p => p.name);
assert.ok(wNames.includes('PB-marker'), 'JWT + header must see workspace B');
const noHdr = await jfetch('/api/playlists', auth(S.jwt));
const nNames = (Array.isArray(noHdr.body) ? noHdr.body : noHdr.body.playlists || []).map(p => p.name);
assert.ok(nNames.includes('PA-marker') && !nNames.includes('PB-marker'), 'JWT default workspace must be A');
});
// (c) scope ladder: read<write<full
test('scope: read token can GET but not POST (403)', async () => {
assert.equal((await jfetch('/api/playlists', auth(S.tok.read))).status, 200);
assert.equal((await jfetch('/api/playlists', post(S.tok.read, { name: 'x' }))).status, 403);
});
test('scope: write token can POST but not command (403, command needs full)', async () => {
assert.equal((await jfetch('/api/playlists', post(S.tok.write, { name: 'w-made' }))).status, 201);
assert.equal((await jfetch(`/api/groups/${S.groupId}/command`, post(S.tok.write, { type: 'reboot' }))).status, 403);
});
test('scope: full token can command (not 403)', async () => {
const res = await jfetch(`/api/groups/${S.groupId}/command`, post(S.tok.full, { type: 'reboot' }));
assert.notEqual(res.status, 403, 'full scope should pass the operational gate');
});
// (d) dual-path render bypass: render public, CRUD locked, no secret leak
test('bypass: GET /api/widgets/:id/render is public (200, no auth) and leaks no secret', async () => {
const res = await fetch(`${BASE}/api/widgets/${S.widgetId}/render`);
assert.equal(res.status, 200);
const html = await res.text();
for (const leak of ['device_token', 'workspace_id', 'password', S.tok.read]) {
assert.ok(!html.includes(leak), `render leaked ${leak}`);
}
});
test('bypass: widget CRUD still requires auth (no-auth list/PUT -> 401)', async () => {
assert.equal((await fetch(`${BASE}/api/widgets`)).status, 401);
assert.equal((await fetch(`${BASE}/api/widgets/${S.widgetId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' })).status, 401);
});
// (e) token lifecycle
test('lifecycle: create returns the secret once; list never returns it; revoke -> 401', async () => {
const created = await jfetch('/api/tokens', post(S.jwt, { name: 'lifecycle', scope: 'read' }));
const secret = created.body.token;
assert.ok(secret && secret.startsWith('st_'), 'create must return the full secret once');
// works
assert.equal((await jfetch('/api/playlists', auth(secret))).status, 200);
// list never contains the secret
const list = await jfetch('/api/tokens', auth(S.jwt));
assert.ok(!JSON.stringify(list.body).includes(secret), 'list leaked the secret');
// revoke -> next call 401
await jfetch(`/api/tokens/${created.body.id}`, { method: 'DELETE', ...auth(S.jwt) });
assert.equal((await jfetch('/api/playlists', auth(secret))).status, 401, 'revoked token must 401');
});
// (f) bearerAuth byte-equivalence: a JWT caller is unaffected by the new middleware -
// it does every method on the public routers (tokenScopeGate is a no-op for JWT) and
// still reaches the JWT-only routers.
test('no-regression: JWT does full CRUD on a public router (scope gate is a no-op for JWT)', async () => {
const c = await jfetch('/api/playlists', post(S.jwt, { name: 'jwt-crud' }));
assert.equal(c.status, 201);
assert.equal((await jfetch(`/api/playlists/${c.body.id}`, { method: 'PUT', ...auth(S.jwt), body: JSON.stringify({ name: 'jwt-crud2' }) })).status, 200);
assert.equal((await jfetch(`/api/playlists/${c.body.id}`, { method: 'DELETE', ...auth(S.jwt) })).status, 200);
});
test('no-regression: JWT reaches a JWT-only router (requireAuth path unchanged)', async () => {
const res = await jfetch('/api/tokens', auth(S.jwt)); // token mgmt is JWT-only
assert.equal(res.status, 200, 'JWT must still reach JWT-only routers');
});
// ───────────────────────── TIER 3: DEVICE WS ROUND-TRIP ─────────────────────────
function deviceRegister(payload, timeoutMs = 4000) {
return new Promise((resolve) => {
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
const got = { connected: false, registered: false, playlist: false, authError: false };
const finish = () => { try { sock.close(); } catch { /* */ } resolve(got); };
sock.on('connect', () => { got.connected = true; sock.emit('device:register', payload); });
sock.on('device:registered', (d) => { got.registered = d.device_id === payload.device_id; sock.emit('device:heartbeat', { device_id: payload.device_id }); });
sock.on('device:playlist-update', () => { got.playlist = true; });
sock.on('device:auth-error', () => { got.authError = true; finish(); });
setTimeout(finish, timeoutMs);
});
}
test('device WS: valid device_token registers and receives its playlist', async () => {
const got = await deviceRegister({ device_id: S.deviceId, device_token: S.deviceToken, device_info: { app_version: 'test' } });
assert.ok(got.connected, 'device socket should connect');
assert.ok(got.registered, 'valid device_token should authenticate');
assert.ok(got.playlist, 'registered device should receive device:playlist-update');
});
test('device WS: wrong device_token is rejected (auth-error, never registered)', async () => {
const got = await deviceRegister({ device_id: S.deviceId, device_token: 'WRONG-TOKEN', device_info: {} });
assert.ok(got.authError, 'wrong token should emit device:auth-error');
assert.ok(!got.registered, 'wrong token must not register');
});