feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0c3a4b625f
commit
2615143c6d
8 changed files with 429 additions and 43 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
// TODO(authz): per-project scoping not yet enforced. Capture sessions carry a
|
// authz: intentionally any-logged-in (no per-project scoping). This is a thin
|
||||||
// project_id; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
|
// proxy to shared capture hardware with no project_id of its own; the resulting
|
||||||
|
// asset is scoped when it's registered via the /assets route. Gated by the
|
||||||
|
// global requireAuth in index.js, like the rest of /api/v1.
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
// Asset-scoped comments for the Asset Detail page.
|
// Asset-scoped comments for the Asset Detail page.
|
||||||
//
|
//
|
||||||
// TODO(authz): per-project scoping not yet enforced. Comments hang off an asset
|
|
||||||
// (resolve project via the asset); adopt assertProjectAccess (auth/authz.js).
|
|
||||||
//
|
|
||||||
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
|
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
|
||||||
// Express's :assetId param flows through from the parent mount.
|
// Express's :assetId param flows through from the parent mount.
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
|
import { assertProjectAccess } from '../auth/authz.js';
|
||||||
|
|
||||||
const router = express.Router({ mergeParams: true });
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
// Scope every comment route to the parent asset's project: resolve project_id
|
||||||
|
// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or
|
||||||
|
// unknown asset is a clean 404 before any access decision leaks its existence.
|
||||||
|
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
function rowToJson(r) {
|
function rowToJson(r) {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|
@ -52,8 +63,9 @@ router.post('/', async (req, res, next) => {
|
||||||
if (!body || !String(body).trim()) {
|
if (!body || !String(body).trim()) {
|
||||||
return res.status(400).json({ error: 'body is required' });
|
return res.status(400).json({ error: 'body is required' });
|
||||||
}
|
}
|
||||||
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
|
// Author is the authenticated user (requireAuth sets req.user for both
|
||||||
const userId = req.session?.userId || null;
|
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
|
||||||
|
const userId = req.user?.id || null;
|
||||||
|
|
||||||
const ins = await pool.query(
|
const ins = await pool.query(
|
||||||
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
|
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
// External media imports — currently YouTube only.
|
// External media imports — currently YouTube only.
|
||||||
//
|
//
|
||||||
// TODO(authz): per-project scoping not yet enforced. Imports target a project_id;
|
|
||||||
// adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
|
|
||||||
//
|
|
||||||
// The flow mirrors upload.js: create the asset row up front with a placeholder
|
// The flow mirrors upload.js: create the asset row up front with a placeholder
|
||||||
// filename (the worker fills in the real title once yt-dlp prints metadata),
|
// filename (the worker fills in the real title once yt-dlp prints metadata),
|
||||||
// then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the
|
// then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the
|
||||||
|
|
@ -13,6 +10,7 @@ import express from 'express';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
|
import { assertProjectAccess } from '../auth/authz.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -63,6 +61,8 @@ router.post('/youtube', async (req, res, next) => {
|
||||||
if (projCheck.rows.length === 0) {
|
if (projCheck.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Project not found' });
|
return res.status(404).json({ error: 'Project not found' });
|
||||||
}
|
}
|
||||||
|
// Importing writes an asset into the project — require edit access.
|
||||||
|
await assertProjectAccess(req.user, projectId, 'edit');
|
||||||
|
|
||||||
const assetId = uuidv4();
|
const assetId = uuidv4();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// TODO(authz): per-project scoping not yet enforced. Recorders carry project_id;
|
|
||||||
// adopt assertProjectAccess/accessibleProjectIds (auth/authz.js) — see bins.js.
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
@ -8,10 +6,34 @@ import dgram from 'dgram';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { getS3Bucket } from '../s3/client.js';
|
import { getS3Bucket } from '../s3/client.js';
|
||||||
import { validateUuid } from '../middleware/errors.js';
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
|
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
|
||||||
|
// Every /:id recorder route is scoped to the recorder's project. The param
|
||||||
|
// handler validates the UUID, resolves the owning project_id, and asserts the
|
||||||
|
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
|
||||||
|
// A recorder with a NULL project_id resolves to admin-only (assertProjectAccess
|
||||||
|
// throws 403 for non-admins on a null project).
|
||||||
|
router.param('id', async (req, res, next) => {
|
||||||
|
validateUuid('id')(req, res, () => {});
|
||||||
|
if (res.headersSent) return;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT project_id FROM recorders WHERE id = $1', [req.params.id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||||
|
req.recorderProjectId = rows[0].project_id;
|
||||||
|
await assertProjectAccess(req.user, req.recorderProjectId, 'view');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireRecorderEdit(req, res, next) {
|
||||||
|
try {
|
||||||
|
await assertProjectAccess(req.user, req.recorderProjectId, 'edit');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
// Base port for on-demand SDI sidecar containers on remote worker nodes.
|
// Base port for on-demand SDI sidecar containers on remote worker nodes.
|
||||||
// Device index 0 → 7438, index 1 → 7439, etc.
|
// Device index 0 → 7438, index 1 → 7439, etc.
|
||||||
|
|
@ -151,6 +173,17 @@ function pickRecorderFields(body) {
|
||||||
// parallel with a per-call timeout from `dockerApi`.
|
// parallel with a per-call timeout from `dockerApi`.
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
// Scope to recorders in projects the caller can access (admins unfiltered).
|
||||||
|
// Recorders with a NULL project are admin-only and never appear for scoped
|
||||||
|
// users (accessibleProjectIds never yields a null id).
|
||||||
|
const access = await accessibleProjectIds(req.user);
|
||||||
|
let scopeClause = '';
|
||||||
|
const params = [];
|
||||||
|
if (!access.all) {
|
||||||
|
if (access.ids.size === 0) return res.json([]);
|
||||||
|
scopeClause = 'WHERE r.project_id = ANY($1::uuid[])';
|
||||||
|
params.push([...access.ids]);
|
||||||
|
}
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT r.*, la.live_asset_id
|
SELECT r.*, la.live_asset_id
|
||||||
FROM recorders r
|
FROM recorders r
|
||||||
|
|
@ -164,8 +197,9 @@ router.get('/', async (req, res, next) => {
|
||||||
ORDER BY a.created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) la ON TRUE
|
) la ON TRUE
|
||||||
|
${scopeClause}
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
`);
|
`, params);
|
||||||
const rows = result.rows;
|
const rows = result.rows;
|
||||||
|
|
||||||
// Only inspect containers for recorders that actually claim to be recording.
|
// Only inspect containers for recorders that actually claim to be recording.
|
||||||
|
|
@ -196,6 +230,11 @@ router.post('/', async (req, res, next) => {
|
||||||
.json({ error: 'Name and source_type are required' });
|
.json({ error: 'Name and source_type are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creating a recorder writes into a project — require edit there. A recorder
|
||||||
|
// with no project_id is admin-only (assertProjectAccess denies non-admins on
|
||||||
|
// a null project).
|
||||||
|
await assertProjectAccess(req.user, fields.project_id ?? null, 'edit');
|
||||||
|
|
||||||
// Defaults — written on insert so the DB row is always self-contained.
|
// Defaults — written on insert so the DB row is always self-contained.
|
||||||
const defaults = {
|
const defaults = {
|
||||||
source_config: {},
|
source_config: {},
|
||||||
|
|
@ -258,7 +297,7 @@ router.get('/:id', async (req, res, next) => {
|
||||||
|
|
||||||
// PATCH /:id - Edit recorder settings
|
// PATCH /:id - Edit recorder settings
|
||||||
// Blocked while recorder is actively recording to prevent config drift.
|
// Blocked while recorder is actively recording to prevent config drift.
|
||||||
router.patch('/:id', async (req, res, next) => {
|
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -297,7 +336,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/start - Start recording
|
// POST /:id/start - Start recording
|
||||||
router.post('/:id/start', async (req, res, next) => {
|
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -347,6 +386,14 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
? req.body.projectId
|
? req.body.projectId
|
||||||
: recorder.project_id;
|
: recorder.project_id;
|
||||||
|
|
||||||
|
// requireRecorderEdit only covered the recorder's own project. If this take
|
||||||
|
// is being routed into a DIFFERENT project, the caller must have edit there
|
||||||
|
// too — otherwise edit on recorder A's project would let them write live
|
||||||
|
// assets into any project B.
|
||||||
|
if (takeProjectId !== recorder.project_id) {
|
||||||
|
await assertProjectAccess(req.user, takeProjectId, 'edit');
|
||||||
|
}
|
||||||
|
|
||||||
// live-asset: create the asset row right now (status='live') so the
|
// live-asset: create the asset row right now (status='live') so the
|
||||||
// library shows the recording while it is happening.
|
// library shows the recording while it is happening.
|
||||||
const assetIdLive = uuidv4();
|
const assetIdLive = uuidv4();
|
||||||
|
|
@ -553,7 +600,7 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/stop - Stop recording
|
// POST /:id/stop - Stop recording
|
||||||
router.post('/:id/stop', async (req, res, next) => {
|
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -724,7 +771,7 @@ router.get('/:id/status', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id - Delete recorder
|
// DELETE /:id - Delete recorder
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
// services/mam-api/src/routes/sequences.js
|
// services/mam-api/src/routes/sequences.js
|
||||||
// TODO(authz): per-project scoping not yet enforced. Sequences belong to a
|
|
||||||
// project; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { getSignedUrlForObject } from '../s3/client.js';
|
import { getSignedUrlForObject } from '../s3/client.js';
|
||||||
import { validateUuid } from '../middleware/errors.js';
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
|
import { assertProjectAccess } from '../auth/authz.js';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
const parseRedisUrl = (url) => {
|
const parseRedisUrl = (url) => {
|
||||||
|
|
@ -21,7 +20,27 @@ const conformQueue = new Queue('conform', {
|
||||||
});
|
});
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
|
||||||
|
// Scope every /:id sequence route to its project: validate the UUID, resolve
|
||||||
|
// project_id, assert the 'view' baseline; mutators escalate via requireSequenceEdit.
|
||||||
|
router.param('id', async (req, res, next) => {
|
||||||
|
validateUuid('id')(req, res, () => {});
|
||||||
|
if (res.headersSent) return;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT project_id FROM sequences WHERE id = $1', [req.params.id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Sequence not found' });
|
||||||
|
req.sequenceProjectId = rows[0].project_id;
|
||||||
|
await assertProjectAccess(req.user, req.sequenceProjectId, 'view');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireSequenceEdit(req, res, next) {
|
||||||
|
try {
|
||||||
|
await assertProjectAccess(req.user, req.sequenceProjectId, 'edit');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Row mapper ────────────────────────────────────────────────────────────────
|
// ── Row mapper ────────────────────────────────────────────────────────────────
|
||||||
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
|
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
|
||||||
|
|
@ -126,6 +145,7 @@ router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { project_id } = req.query;
|
const { project_id } = req.query;
|
||||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||||
|
await assertProjectAccess(req.user, project_id, 'view');
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
|
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
|
||||||
[project_id]
|
[project_id]
|
||||||
|
|
@ -145,6 +165,7 @@ router.post('/', async (req, res, next) => {
|
||||||
height = 1080,
|
height = 1080,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||||
|
await assertProjectAccess(req.user, project_id, 'edit');
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
|
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||||
|
|
@ -190,7 +211,7 @@ router.get('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
||||||
router.put('/:id', async (req, res, next) => {
|
router.put('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, frame_rate, width, height } = req.body;
|
const { name, frame_rate, width, height } = req.body;
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
@ -213,7 +234,7 @@ router.put('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
|
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
|
||||||
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
|
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
|
||||||
|
|
@ -222,12 +243,14 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
|
// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
|
||||||
router.put('/:id/clips', async (req, res, next) => {
|
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
|
||||||
// Verify sequence exists first (before acquiring transaction client)
|
const clips = Array.isArray(req.body) ? req.body : [];
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
// Verify sequence exists first (before acquiring transaction client).
|
||||||
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
||||||
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||||
|
|
||||||
const clips = Array.isArray(req.body) ? req.body : [];
|
|
||||||
for (const c of clips) {
|
for (const c of clips) {
|
||||||
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
||||||
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
||||||
|
|
@ -239,8 +262,22 @@ router.put('/:id/clips', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await pool.connect();
|
// Every referenced asset must belong to THIS sequence's project. Without this,
|
||||||
try {
|
// a user with edit on the sequence could splice in assets from a project they
|
||||||
|
// can't access — and GET /:id would then hand back those assets' names and
|
||||||
|
// signed proxy URLs (cross-project leak).
|
||||||
|
const assetIds = [...new Set(clips.map(c => c.asset_id))];
|
||||||
|
if (assetIds.length) {
|
||||||
|
const owning = await pool.query(
|
||||||
|
`SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
|
||||||
|
[assetIds, req.sequenceProjectId]
|
||||||
|
);
|
||||||
|
if (owning.rows.length !== assetIds.length) {
|
||||||
|
return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client = await pool.connect();
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
await client.query(
|
await client.query(
|
||||||
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
|
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
|
||||||
|
|
@ -267,10 +304,12 @@ router.put('/:id/clips', async (req, res, next) => {
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
res.json({ ok: true, count: clips.length });
|
res.json({ ok: true, count: clips.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await client.query('ROLLBACK');
|
// client is only set once we've connected; a failure in the pre-transaction
|
||||||
|
// queries (existence/validation/ownership) has no transaction to roll back.
|
||||||
|
if (client) await client.query('ROLLBACK').catch(() => {});
|
||||||
next(e);
|
next(e);
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
if (client) client.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -302,7 +341,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
|
||||||
// ── POST /:id/conform – conform sequence via FCP XML ─────────────────────────
|
// ── POST /:id/conform – conform sequence via FCP XML ─────────────────────────
|
||||||
// Accepts FCP XML content and encode settings from the Premiere plugin,
|
// Accepts FCP XML content and encode settings from the Premiere plugin,
|
||||||
// queues a conform job in BullMQ, and returns the job ID for polling.
|
// queues a conform job in BullMQ, and returns the job ID for polling.
|
||||||
router.post('/:id/conform', async (req, res, next) => {
|
router.post('/:id/conform', requireSequenceEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
|
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
|
||||||
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||||
|
|
|
||||||
76
services/mam-api/test/routes/comments-access.test.js
Normal file
76
services/mam-api/test/routes/comments-access.test.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// RBAC coverage for asset comments: the guard resolves the project via the
|
||||||
|
// asset, requiring view to read and edit to write. Also verifies the author id
|
||||||
|
// is recorded from req.user (regression for the old req.session.userId bug).
|
||||||
|
// 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 appWithComments(user) {
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
|
process.env.AUTH_ENABLED = 'true';
|
||||||
|
const { default: commentsRouter } = await import('../../src/routes/comments.js?v=' + Date.now());
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => { req.user = user; next(); });
|
||||||
|
// Mirror index.js mount so :assetId flows through (mergeParams in the router).
|
||||||
|
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
|
||||||
|
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 = async (pid, name) => (await pool.query(
|
||||||
|
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`, [pid, name])).rows[0].id;
|
||||||
|
const assetA = await asset(alpha, 'a1');
|
||||||
|
const assetB = await asset(beta, 'b1');
|
||||||
|
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 { assetA, assetB, viewer, editor };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('comments require view to read and block ungranted assets', { skip: SKIP }, async () => {
|
||||||
|
const pool = await setupTestDb();
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
|
try {
|
||||||
|
const { assetA, assetB, viewer } = await seed(pool);
|
||||||
|
const s = await appWithComments(viewer);
|
||||||
|
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments')).status, 200);
|
||||||
|
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetB + '/comments')).status, 403);
|
||||||
|
await s.close();
|
||||||
|
} finally { await pool.end(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posting a comment requires edit and records the author id from req.user', { skip: SKIP }, async () => {
|
||||||
|
const pool = await setupTestDb();
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
|
try {
|
||||||
|
const { assetA, viewer, editor } = await seed(pool);
|
||||||
|
|
||||||
|
// viewer (view-only) cannot post.
|
||||||
|
let s = await appWithComments(viewer);
|
||||||
|
let r = await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'hi' }) });
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
await s.close();
|
||||||
|
|
||||||
|
// editor can post, and the author id is the editor (not null).
|
||||||
|
let e = await appWithComments(editor);
|
||||||
|
r = await fetch(e.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'looks good' }) });
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
const created = await r.json();
|
||||||
|
assert.equal(created.user_id, editor.id, 'comment author must be req.user.id');
|
||||||
|
await e.close();
|
||||||
|
} finally { await pool.end(); }
|
||||||
|
});
|
||||||
107
services/mam-api/test/routes/recorders-access.test.js
Normal file
107
services/mam-api/test/routes/recorders-access.test.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// 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(); }
|
||||||
|
});
|
||||||
103
services/mam-api/test/routes/sequences-access.test.js
Normal file
103
services/mam-api/test/routes/sequences-access.test.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// 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(); }
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue