diff --git a/server/test/api.test.js b/server/test/api.test.js index 6308638..6ed3221 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -73,6 +73,14 @@ before(async () => { 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; + // layouts + zones in workspace A (user1) and workspace B (user2) - for the gap-fix + // assertions and the cross-tenant rejection (the is_template OR workspace_id guard). + const zone = (n) => ({ name: n, x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }); + const layA = await jfetch('/api/layouts', post(S.jwt, { name: 'LA', zones: [zone('ZA')] })); + S.layoutA = layA.body.id; S.zoneA = layA.body.zones[0].id; + const layB = await jfetch('/api/layouts', post(S.jwt2, { name: 'LB', zones: [zone('ZB')] })); + S.layoutB = layB.body.id; S.zoneB = layB.body.zones[0].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 }); @@ -244,3 +252,63 @@ test('device WS: wrong device_token is rejected (auth-error, never registered)', assert.ok(got.authError, 'wrong token should emit device:auth-error'); assert.ok(!got.registered, 'wrong token must not register'); }); + +// ───────────────────────── TIER 4: #92 FOLLOW-UP COVERAGE ───────────────────────── +// The non-security gaps named in the self-review (issue #92): the gap-fix fields + the +// cross-tenant guard (the security-relevant one), docs serving, and the token lifecycle +// branches the suite didn't exercise. + +test('gap: playlist item accepts zone_id and returns it on read', async () => { + const created = await jfetch(`/api/playlists/${S.playlistA}/items`, post(S.jwt, { widget_id: S.widgetId, zone_id: S.zoneA })); + assert.equal(created.status, 201); + assert.equal(created.body.zone_id, S.zoneA); + const items = await jfetch(`/api/playlists/${S.playlistA}/items`, auth(S.jwt)); + assert.ok(items.body.some(i => i.zone_id === S.zoneA), 'GET items must return zone_id'); +}); +test('gap: playlist item REJECTS a cross-tenant zone_id (400, is_template OR workspace_id guard)', async () => { + const res = await jfetch(`/api/playlists/${S.playlistA}/items`, post(S.jwt, { widget_id: S.widgetId, zone_id: S.zoneB })); + assert.equal(res.status, 400, 'a zone from another workspace must be rejected'); +}); +test('gap: device PUT accepts layout_id and returns it on read', async () => { + const put = await jfetch(`/api/devices/${S.deviceId}`, { method: 'PUT', ...auth(S.jwt), body: JSON.stringify({ layout_id: S.layoutA }) }); + assert.equal(put.status, 200); + assert.equal(put.body.layout_id, S.layoutA); + const dev = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt)); + assert.equal(dev.body.layout_id, S.layoutA, 'GET device must return layout_id'); +}); +test('gap: device PUT REJECTS a cross-tenant layout_id (400)', async () => { + const res = await jfetch(`/api/devices/${S.deviceId}`, { method: 'PUT', ...auth(S.jwt), body: JSON.stringify({ layout_id: S.layoutB }) }); + assert.equal(res.status, 400, 'a layout from another workspace must be rejected'); +}); + +test('docs: /openapi.yaml serves the spec document', async () => { + const res = await fetch(BASE + '/openapi.yaml'); + assert.equal(res.status, 200); + assert.ok((await res.text()).includes('openapi: 3.1'), 'must serve the OpenAPI document'); +}); +test('docs: /docs serves the Redoc viewer wired to the spec', async () => { + const res = await fetch(BASE + '/docs'); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok(html.includes(' { + // user1 is platform_admin (resolveTenancy lets them into wsB via the header) but is NOT + // a member of wsB; the create endpoint checks accessContext with the platform role + // stripped to 'user', so it must refuse to bind a token there. + const res = await jfetch('/api/tokens', post(S.jwt, { name: 'x', scope: 'read' }, { 'X-Workspace-Id': S.wsB })); + assert.equal(res.status, 400); + assert.equal((await jfetch('/api/tokens', post(S.jwt, { name: 'x2', scope: 'read' }))).status, 201, 'own workspace still works'); +}); +// The must_change_password gate is middleware logic and is unit-tested with an injected +// in-memory DB in test/apitoken-unit.test.js (cross-process DB visibility against the +// subprocess server is unreliable for asserting that specific branch). +test('token-auth: last_used_at is stamped on first use', async () => { + const created = await jfetch('/api/tokens', post(S.jwt, { name: 'lu', scope: 'read' })); + const before = (await jfetch('/api/tokens', auth(S.jwt))).body.find(t => t.id === created.body.id); + assert.equal(before.last_used_at, null, 'a fresh token has no last_used_at'); + await jfetch('/api/playlists', auth(created.body.token)); // use it once + const after = (await jfetch('/api/tokens', auth(S.jwt))).body.find(t => t.id === created.body.id); + assert.ok(after.last_used_at, 'last_used_at is set after first use'); +}); diff --git a/server/test/apitoken-unit.test.js b/server/test/apitoken-unit.test.js new file mode 100644 index 0000000..59aefd6 --- /dev/null +++ b/server/test/apitoken-unit.test.js @@ -0,0 +1,57 @@ +'use strict'; + +// Unit tests for apiTokenAuth branches that are awkward to assert against the subprocess +// integration server (cross-process SQLite/WAL visibility is unreliable mid-run): the +// must_change_password gate, plus a sanity check that a normal token passes with the +// platform role stripped. Uses the project's in-memory-DB injection pattern (inject +// ../db/database into the require cache BEFORE requiring the middleware). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const Database = require('better-sqlite3'); + +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-apitoken-unit'; + +const db = new Database(':memory:'); +db.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, email TEXT, name TEXT, role TEXT DEFAULT 'user', + auth_provider TEXT, avatar_url TEXT, plan_id TEXT, email_alerts INTEGER, + must_change_password INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, token_hash TEXT, prefix TEXT, name TEXT, user_id TEXT, + workspace_id TEXT, scope TEXT, created_at INTEGER, last_used_at INTEGER, revoked_at INTEGER + ); +`); +require.cache[require.resolve('../db/database')] = { id: require.resolve('../db/database'), loaded: true, exports: { db } }; +const { apiTokenAuth, hashToken } = require('../middleware/apiToken'); + +function seedToken({ mustChange }) { + const uid = crypto.randomUUID(); + db.prepare('INSERT INTO users (id, email, must_change_password) VALUES (?, ?, ?)').run(uid, uid + '@t.local', mustChange ? 1 : 0); + const secret = 'st_' + crypto.randomBytes(16).toString('hex'); + db.prepare('INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope) VALUES (?,?,?,?,?,?,?)') + .run(crypto.randomUUID(), hashToken(secret), secret.slice(0, 11), 'n', uid, 'ws-x', 'read'); + return secret; +} +function runAuth(secret) { + return new Promise((resolve) => { + const req = { headers: { authorization: 'Bearer ' + secret }, query: {} }; + const res = { statusCode: 200, status(c) { this.statusCode = c; return this; }, json() { resolve({ outcome: 'response', status: this.statusCode }); } }; + apiTokenAuth(req, res, () => resolve({ outcome: 'next', viaToken: req.viaToken, role: req.user && req.user.role })); + }); +} + +test('apiTokenAuth: a must_change_password owner is blocked with 403', async () => { + const r = await runAuth(seedToken({ mustChange: true })); + assert.equal(r.outcome, 'response'); + assert.equal(r.status, 403); +}); +test('apiTokenAuth: a normal owner passes (next; viaToken set; platform role stripped to user)', async () => { + const r = await runAuth(seedToken({ mustChange: false })); + assert.equal(r.outcome, 'next'); + assert.equal(r.viaToken, true); + assert.equal(r.role, 'user'); +}); diff --git a/server/test/i18n-tokens.test.js b/server/test/i18n-tokens.test.js new file mode 100644 index 0000000..75d978d --- /dev/null +++ b/server/test/i18n-tokens.test.js @@ -0,0 +1,30 @@ +'use strict'; + +// i18n drift-guard for the public-API token UI: the apitoken.* keys must have full parity +// across all five locales. A key added to en (or any locale) without the others fails CI, +// so the Settings "API Tokens" UI can't ship a missing translation. No server needed. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const LOCALES = ['en', 'es', 'fr', 'de', 'pt']; +const I18N_DIR = path.join(__dirname, '..', '..', 'frontend', 'js', 'i18n'); + +function apitokenKeys(locale) { + const text = fs.readFileSync(path.join(I18N_DIR, locale + '.js'), 'utf8'); + return new Set((text.match(/['"]apitoken\.[a-z_]+['"]/g) || []).map(s => s.replace(/['"]/g, ''))); +} + +test('i18n: apitoken.* keys have full parity across all 5 locales (drift fails CI)', () => { + const base = apitokenKeys('en'); + assert.ok(base.size >= 20, `en should define the apitoken keys (found ${base.size})`); + for (const loc of LOCALES) { + const keys = apitokenKeys(loc); + const missing = [...base].filter(k => !keys.has(k)); + const extra = [...keys].filter(k => !base.has(k)); + assert.deepEqual(missing, [], `${loc}.js is missing apitoken keys present in en: ${missing.join(', ')}`); + assert.deepEqual(extra, [], `${loc}.js has apitoken keys not in en: ${extra.join(', ')}`); + } +});