// RBAC coverage for sequences: list/create assert on the query/body project, // /:id asserts view, mutators assert edit. 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 appWithSequences(user) { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; process.env.AUTH_ENABLED = 'true'; const { default: sequencesRouter } = await import('../../src/routes/sequences.js?v=' + Date.now()); const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.user = user; next(); }); app.use('/api/v1/sequences', sequencesRouter); 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 seqB = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'B seq') RETURNING id`, [beta])).rows[0].id; const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' }; const editor = { 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, viewer.id]); await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]); return { alpha, beta, seqB, viewer, editor }; } const asset = (pool, pid, name) => pool.query( `INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`, [pid, name]).then(r => r.rows[0].id); test('GET /sequences?project_id 403s on an ungranted project', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { alpha, beta, viewer } = await seed(pool); const s = await appWithSequences(viewer); assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + alpha)).status, 200); assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + beta)).status, 403); await s.close(); } finally { await pool.end(); } }); test('POST /sequences requires edit; viewer denied, editor allowed; /:id view enforced', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { alpha, seqB, viewer, editor } = await seed(pool); // viewer (view-only on Alpha) cannot create. let s = await appWithSequences(viewer); let r = await fetch(s.baseUrl + '/api/v1/sequences', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'X' }) }); assert.equal(r.status, 403); // viewer cannot read a sequence in an ungranted project (Beta). assert.equal((await fetch(s.baseUrl + '/api/v1/sequences/' + seqB)).status, 403); await s.close(); // editor can create in Alpha and then PUT it. let e = await appWithSequences(editor); r = await fetch(e.baseUrl + '/api/v1/sequences', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'Mine' }) }); assert.equal(r.status, 201); const seqId = (await r.json()).id; const put = await fetch(e.baseUrl + '/api/v1/sequences/' + seqId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Renamed' }) }); assert.equal(put.status, 200); await e.close(); } finally { await pool.end(); } }); test('PUT /:id/clips rejects assets from outside the sequence project (cross-project leak guard)', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { alpha, beta, editor } = await seed(pool); const seqA = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'A seq') RETURNING id`, [alpha])).rows[0].id; const assetA = await asset(pool, alpha, 'a-clip'); const assetB = await asset(pool, beta, 'b-clip'); // editor has NO access to project Beta const e = await appWithSequences(editor); const clip = (aid) => ({ asset_id: aid, track: 0, timeline_in_frames: 0, timeline_out_frames: 10, source_in_frames: 0, source_out_frames: 10 }); // Same-project asset is accepted. let r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA)]) }); assert.equal(r.status, 200); // Splicing in a Beta asset must be rejected — it would leak B's media via GET /:id. r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA), clip(assetB)]) }); assert.equal(r.status, 400, 'foreign-project asset must be rejected'); await e.close(); } finally { await pool.end(); } });