// Integration test for per-project RBAC: the grant-management API on the // projects router + scoped enforcement on GET /projects and GET /projects/:id. // // NOTE: the routers use the singleton pool (src/db/pool.js), which reads // DATABASE_URL. We point DATABASE_URL at the throwaway TEST_DATABASE_URL and // seed through that same singleton pool so the router and the test share one // database. Skips cleanly when TEST_DATABASE_URL is unset. 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'; // Build an app that injects a chosen user as req.user (simulating requireAuth), // then mounts the real projects router with the same admin gate index.js uses. async function appWithProjects(user) { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; process.env.AUTH_ENABLED = 'true'; const { default: projectsRouter } = await import('../../src/routes/projects.js?v=' + Date.now()); const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.user = user; next(); }); app.use('/api/v1/projects', projectsRouter); 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 seedBaseline(pool) { const alpha = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Alpha','alpha') RETURNING id`)).rows[0].id; const beta = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Beta','beta') 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 ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' }; return { alpha, beta, admin, scoped }; } test('admin grants a project, scoped user then sees only it; revoke removes access', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { alpha, beta, admin, scoped } = await seedBaseline(pool); // Scoped viewer initially sees nothing. let s = await appWithProjects(scoped); let list = await (await fetch(s.baseUrl + '/api/v1/projects')).json(); assert.equal(list.length, 0, 'scoped user should see no projects before any grant'); // And cannot read Alpha directly. let direct = await fetch(s.baseUrl + '/api/v1/projects/' + alpha); assert.equal(direct.status, 403); await s.close(); // Admin grants the scoped user 'view' on Alpha. const a = await appWithProjects(admin); const grant = await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'view' }), }); assert.equal(grant.status, 201); const grantList = await (await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access')).json(); assert.equal(grantList.length, 1); assert.equal(grantList[0].subject_id, scoped.id); await a.close(); // Scoped viewer now sees exactly Alpha (not Beta), can GET it, cannot PATCH (view-only). s = await appWithProjects(scoped); list = await (await fetch(s.baseUrl + '/api/v1/projects')).json(); assert.deepEqual(list.map(p => p.id), [alpha]); assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + alpha)).status, 200); assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + beta)).status, 403); const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'hacked' }), }); assert.equal(patch.status, 403, 'view-level grant must not allow edit'); await s.close(); // Admin revokes; scoped viewer goes back to seeing nothing. const a2 = await appWithProjects(admin); const del = await fetch(a2.baseUrl + '/api/v1/projects/' + alpha + '/access/user/' + scoped.id, { method: 'DELETE' }); assert.equal(del.status, 204); await a2.close(); s = await appWithProjects(scoped); list = await (await fetch(s.baseUrl + '/api/v1/projects')).json(); assert.equal(list.length, 0); await s.close(); } finally { await pool.end(); } }); test('non-admin cannot reach the grant-management API', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { alpha, scoped } = await seedBaseline(pool); const s = await appWithProjects(scoped); // requireAdmin sits on the access sub-routes; a viewer is 403. const r = await fetch(s.baseUrl + '/api/v1/projects/' + alpha + '/access'); assert.equal(r.status, 403); await s.close(); } finally { await pool.end(); } }); test('edit-level grant allows PATCH', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const { alpha, admin, scoped } = await seedBaseline(pool); const a = await appWithProjects(admin); await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'edit' }), }); await a.close(); const s = await appWithProjects(scoped); const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'updated by editor' }), }); assert.equal(patch.status, 200); await s.close(); } finally { await pool.end(); } });