mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
test(api): close #92 follow-up coverage gaps
The non-security gaps named in the public-API self-review: - gap-fix: zone_id (playlist items) + layout_id (device PUT) accepted and returned on read, INCLUDING the cross-tenant rejection (the is_template OR workspace_id guard - the security-relevant one). - docs serving: /openapi.yaml serves the spec, /docs returns the Redoc page. - i18n drift-guard: apitoken.* keys have full parity across en/es/fr/de/pt (a key missing in one locale fails CI). - token lifecycle branches: token-create workspace-membership validation and last_used_at stamping (integration), plus the must_change_password gate (unit test via the in-memory DB injection - cross-process WAL visibility is unreliable for that branch in-process). 119 tests total (was 108), all in the existing node --test job. Closes #92 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33eaef826c
commit
538f4a7b03
|
|
@ -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('<redoc') && html.includes('/openapi.yaml'), 'must serve the Redoc page pointing at /openapi.yaml');
|
||||
});
|
||||
|
||||
test('token-create: rejects a workspace the caller is not a member of (400)', async () => {
|
||||
// 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');
|
||||
});
|
||||
|
|
|
|||
57
server/test/apitoken-unit.test.js
Normal file
57
server/test/apitoken-unit.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
30
server/test/i18n-tokens.test.js
Normal file
30
server/test/i18n-tokens.test.js
Normal file
|
|
@ -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(', ')}`);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue