// RBAC coverage for recorders: list is scoped to accessible projects, /:id // asserts view, mutators assert edit, null-project recorders are admin-only. // Same harness as assets-access.test.js — singleton pool on TEST_DATABASE_URL, // req.user injected, router dynamic-imported. Skips without TEST_DATABASE_URL. import { test } from 'node:test'; import assert from 'node:assert/strict'; import express from 'express'; import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set'; async function appWithRecorders(user) { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; process.env.AUTH_ENABLED = 'true'; const { default: recordersRouter } = await import('../../src/routes/recorders.js?v=' + Date.now()); const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.user = user; next(); }); app.use('/api/v1/recorders', recordersRouter); return new Promise(r => { const srv = app.listen(0, '127.0.0.1', () => r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) })); }); } async function seed(pool) { const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id; const alpha = await proj('Alpha'); const beta = await proj('Beta'); const rec = async (pid, name) => (await pool.query( `INSERT INTO recorders (name, source_type, project_id) VALUES ($1, 'srt', $2) RETURNING id`, [name, pid])).rows[0].id; const recA = await rec(alpha, 'Cam A'); const recB = await rec(beta, 'Cam B'); const recNull = (await pool.query( `INSERT INTO recorders (name, source_type, project_id) VALUES ('Unassigned','srt',NULL) RETURNING id`)).rows[0].id; const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' }; const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' }; await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, scoped.id]); return { alpha, beta, recA, recB, recNull, admin, scoped }; } test('recorders list is scoped; admin sees all incl. null-project, scoped sees only granted', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { recA, admin, scoped } = await seed(pool); let a = await appWithRecorders(admin); let list = await (await fetch(a.baseUrl + '/api/v1/recorders')).json(); assert.equal(list.length, 3, 'admin sees all three recorders'); await a.close(); let s = await appWithRecorders(scoped); list = await (await fetch(s.baseUrl + '/api/v1/recorders')).json(); assert.deepEqual(list.map(r => r.id), [recA], 'scoped editor sees only the granted-project recorder'); await s.close(); } finally { await pool.end(); } }); test('recorder /:id enforces view; mutators enforce edit', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { recA, recB, recNull, scoped } = await seed(pool); const s = await appWithRecorders(scoped); // view-granted on Alpha: can GET recA, cannot GET recB (other project) or recNull (admin-only). assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recA)).status, 200); assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recB)).status, 403); assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recNull)).status, 403); // view-only grant cannot PATCH (edit) or start. const patch = await fetch(s.baseUrl + '/api/v1/recorders/' + recA, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x' }) }); assert.equal(patch.status, 403, 'view-level grant must not allow edit'); await s.close(); } finally { await pool.end(); } }); test('creating a recorder requires edit; null-project create is admin-only', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { alpha, admin, scoped } = await seed(pool); // scoped editor has only 'view' on Alpha → create denied. let s = await appWithRecorders(scoped); let r = await fetch(s.baseUrl + '/api/v1/recorders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'New', source_type: 'srt', project_id: alpha }) }); assert.equal(r.status, 403); // null project → admin-only, still denied for the editor. r = await fetch(s.baseUrl + '/api/v1/recorders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'New2', source_type: 'srt' }) }); assert.equal(r.status, 403); await s.close(); // admin can create in any project and with no project. let a = await appWithRecorders(admin); r = await fetch(a.baseUrl + '/api/v1/recorders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'AdminRec', source_type: 'srt' }) }); assert.equal(r.status, 201); await a.close(); } finally { await pool.end(); } });