dragonflight/services/mam-api/test/routes/assets-access.test.js
Zac ec026195eb feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.

- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
  assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
  view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
  "Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
  service-token-needs-admin/grants requirement

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:37:36 +00:00

79 lines
3.9 KiB
JavaScript

// Regression test: GET /api/v1/assets must be scoped to the caller's accessible
// projects. A pre-fix bug returned every asset across every project to any
// authenticated user, defeating RBAC v2.
//
// Like project-access.test.js, the assets router uses the singleton pool, so we
// point DATABASE_URL at TEST_DATABASE_URL and seed through the same pool.
// Skips 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';
async function appWithAssets(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
// Importing the assets router constructs BullMQ queues; they connect lazily,
// and the list route only touches Postgres, so no Redis is needed here.
const { default: assetsRouter } = await import('../../src/routes/assets.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/assets', assetsRouter);
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 asset = (pid, name) => pool.query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status)
VALUES ($1, $2, $2, 'video', 'ready')`, [pid, name]);
await asset(alpha, 'a1'); await asset(alpha, 'a2'); await asset(beta, 'b1');
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' };
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, admin, scoped };
}
test('GET /assets returns only assets in granted projects for scoped users', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { admin, scoped } = await seed(pool);
// Admin sees all three.
let a = await appWithAssets(admin);
let body = await (await fetch(a.baseUrl + '/api/v1/assets')).json();
assert.equal(body.assets.length, 3, 'admin should see every asset');
await a.close();
// Scoped viewer (granted only Alpha) sees the two Alpha assets, not Beta's.
let s = await appWithAssets(scoped);
body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
assert.equal(body.assets.length, 2, 'scoped user should see only granted-project assets');
assert.ok(body.assets.every(x => x.display_name !== 'b1'), 'must not leak ungranted-project asset');
await s.close();
} finally { await pool.end(); }
});
test('GET /assets returns nothing for a user with no grants', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await seed(pool);
const nobody = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('no','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
const s = await appWithAssets(nobody);
const body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
assert.deepEqual(body, { assets: [], total: 0 });
await s.close();
} finally { await pool.end(); }
});