Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
59
apps/api/src/db/client.ts
Normal file
59
apps/api/src/db/client.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Pool, type PoolClient } from "pg";
|
||||
import pino from "pino";
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: Number(process.env.PG_POOL_MAX ?? 10),
|
||||
});
|
||||
|
||||
/**
|
||||
* Tenant-scoped transaction: sets app.workspace_id for the transaction so
|
||||
* RLS policies and SECURITY DEFINER assertions see the tenant context.
|
||||
* The setting is transaction-local (SET LOCAL) — it cannot leak.
|
||||
*/
|
||||
export async function withTenant<T>(
|
||||
workspaceId: string,
|
||||
fn: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** System-level transaction (no tenant context): RLS sees NULL context and returns nothing tenant-scoped. */
|
||||
export async function withSystem<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDatabase(): Promise<boolean> {
|
||||
try {
|
||||
await pool.query("SELECT 1");
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn({ err: String(err) }, "db: unreachable");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
6
apps/api/src/db/migrate.cli.ts
Normal file
6
apps/api/src/db/migrate.cli.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { resolve } from "node:path";
|
||||
import { runMigrations } from "./migrate.js";
|
||||
const index = process.argv.indexOf("--dir");
|
||||
void runMigrations(resolve(index < 0 ? "migrations" : process.argv[index + 1] ?? "migrations"))
|
||||
.then((files) => console.log({ applied: files }))
|
||||
.catch(() => { console.error("Migration failed; schema remains unready. Inspect the phase and database records before retrying."); process.exitCode = 1; });
|
||||
12
apps/api/src/db/migrate.test.ts
Normal file
12
apps/api/src/db/migrate.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { migrationPhases } from "./migrate.js";
|
||||
describe("migration stage grammar", () => {
|
||||
it("leaves 001–018 transactional and parses explicit stages", () => {
|
||||
expect(migrationPhases("SELECT 1;")).toEqual([{ name: "", mode: "transaction", sql: "SELECT 1;" }]);
|
||||
expect(migrationPhases("-- comment\n-- wp3:phase expand transaction\nSELECT 1;\n-- wp3:phase fill batch\nUPDATE t SET x=1;\n-- wp3:phase idx concurrent\nCREATE INDEX CONCURRENTLY t_idx ON t (x);").map((p) => [p.name, p.mode])).toEqual([["expand", "transaction"], ["fill", "batch"], ["idx", "concurrent"]]);
|
||||
});
|
||||
it("refuses duplicate phases and SQL before the first stage", () => {
|
||||
expect(() => migrationPhases("-- wp3:phase expand transaction\nSELECT 1;\n-- wp3:phase expand transaction\nSELECT 2;")).toThrow(/duplicate/);
|
||||
expect(() => migrationPhases("SELECT 0;\n-- wp3:phase expand transaction\nSELECT 1;")).toThrow(/before/);
|
||||
});
|
||||
});
|
||||
77
apps/api/src/db/migrate.ts
Normal file
77
apps/api/src/db/migrate.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
|
||||
export interface MigrationPhase { name: string; mode: "transaction" | "batch" | "concurrent"; sql: string }
|
||||
/** Only explicit whole-line WP3 markers are accepted; old migrations stay transactional. */
|
||||
export function migrationPhases(sql: string): MigrationPhase[] {
|
||||
const marker = /^-- wp3:phase ([a-z][a-z0-9_]*) (transaction|batch|concurrent)\s*$/gm;
|
||||
const matches = [...sql.matchAll(marker)];
|
||||
if (matches.length === 0) return [{ name: "", mode: "transaction", sql }];
|
||||
if (sql.slice(0, matches[0]?.index).split("\n").some((line) => line.trim() && !line.trim().startsWith("--"))) throw new Error("SQL before migration phase");
|
||||
const phases = matches.map((match, index): MigrationPhase => ({ name: match[1]!, mode: match[2] as MigrationPhase["mode"], sql: sql.slice(match.index! + match[0].length, matches[index + 1]?.index).trim() }));
|
||||
if (new Set(phases.map((p) => p.name)).size !== phases.length) throw new Error("duplicate migration phase");
|
||||
return phases;
|
||||
}
|
||||
|
||||
async function concurrentIndex(client: PoolClient, sql: string): Promise<void> {
|
||||
const parsed = /^CREATE (UNIQUE )?INDEX CONCURRENTLY ([a-z][a-z0-9_]*) ON ([a-z][a-z0-9_]*) ([\s\S]+);$/.exec(sql);
|
||||
if (!parsed) throw new Error("concurrent phase must contain exactly one CREATE INDEX");
|
||||
const [, unique, name, table, tail] = parsed;
|
||||
const found = await client.query<{ indisvalid: boolean; definition: string }>("SELECT i.indisvalid, pg_get_indexdef(i.indexrelid) AS definition FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relname = $1", [name]);
|
||||
const existing = found.rows[0];
|
||||
if (existing) {
|
||||
const expected = `CREATE ${unique ?? ""}INDEX ${name} ON public.${table} USING btree ${tail}`;
|
||||
const normalize = (text: string): string => text.replace(/;$/, "").replace(/\s+/g, " ").trim();
|
||||
if (normalize(existing.definition) !== normalize(expected)) throw new Error(`index ${name} definition differs`);
|
||||
if (existing.indisvalid) return;
|
||||
await client.query(`DROP INDEX CONCURRENTLY public.${name}`);
|
||||
}
|
||||
await client.query(sql);
|
||||
}
|
||||
|
||||
/** Owner-only offline runner; runtime applications never call this. */
|
||||
export async function runMigrations(dir: string): Promise<string[]> {
|
||||
if (!process.env.SUPER_DATABASE_URL) throw new Error("SUPER_DATABASE_URL is required for migrations");
|
||||
const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
|
||||
const applied: string[] = [];
|
||||
const pool = new Pool({ connectionString: process.env.SUPER_DATABASE_URL });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock(193674839, 3)");
|
||||
await client.query("SET lock_timeout = '5s'");
|
||||
await client.query("CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())");
|
||||
for (const file of files) {
|
||||
if ((await client.query("SELECT 1 FROM schema_migrations WHERE name = $1", [file])).rowCount !== 0) continue;
|
||||
for (const phase of migrationPhases(await readFile(join(dir, file), "utf8"))) {
|
||||
const key = phase.name ? `${file}:${phase.name}` : file;
|
||||
if ((await client.query("SELECT 1 FROM schema_migrations WHERE name = $1", [key])).rowCount !== 0) continue;
|
||||
try {
|
||||
if (phase.mode === "concurrent") {
|
||||
await concurrentIndex(client, phase.sql);
|
||||
await client.query("INSERT INTO schema_migrations(name) VALUES ($1)", [key]);
|
||||
} else {
|
||||
for (;;) {
|
||||
await client.query("BEGIN");
|
||||
const result = await client.query(phase.sql);
|
||||
if (phase.mode === "batch" && (result.rowCount ?? 0) > 0) { await client.query("COMMIT"); continue; }
|
||||
await client.query("INSERT INTO schema_migrations(name) VALUES ($1)", [key]);
|
||||
await client.query("COMMIT");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw new Error(`migration ${key} failed`, { cause: error });
|
||||
}
|
||||
}
|
||||
await client.query("INSERT INTO schema_migrations(name) VALUES ($1) ON CONFLICT DO NOTHING", [file]);
|
||||
applied.push(file);
|
||||
}
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock(193674839, 3)").catch(() => undefined);
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
326
apps/api/src/db/production-schema.integration.test.ts
Normal file
326
apps/api/src/db/production-schema.integration.test.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { runMigrations } from "./migrate.js";
|
||||
|
||||
/**
|
||||
* Migration 013 (streaming S1) against a live PostgreSQL: the same-channel
|
||||
* invariant fails at the DB constraint, the production CHECKs hold, and FORCE
|
||||
* RLS denies every new table across tenants. Assertions run as sp_app
|
||||
* (DATABASE_URL); setup/cleanup as owner. Skipped without DATABASE_URL.
|
||||
*/
|
||||
const dbAvailable = process.env.DATABASE_URL !== undefined;
|
||||
const ownerPool = new Pool({ connectionString: process.env.SUPER_DATABASE_URL ?? process.env.DATABASE_URL });
|
||||
const appPool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
const FK_VIOLATION = "23503";
|
||||
const UNIQUE_VIOLATION = "23505";
|
||||
const CHECK_VIOLATION = "23514";
|
||||
const RLS_VIOLATION = "42501";
|
||||
const ASSET = "a".repeat(64);
|
||||
|
||||
async function asTenant<T>(workspaceId: string | null, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await appPool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
if (workspaceId !== null) await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function one(client: PoolClient, sql: string, params: unknown[]): Promise<string> {
|
||||
const result = await client.query(sql, params);
|
||||
return String(result.rows[0]?.id);
|
||||
}
|
||||
|
||||
interface Channel { id: string; hls: string; playlist: string; scene: string; spare: string; slot: string; overlay: string; revision: string }
|
||||
|
||||
async function seedChannel(client: PoolClient, ws: string, assetId: string): Promise<Channel> {
|
||||
const id = await one(client, "INSERT INTO virtual_channels (workspace_id, slug, name) VALUES ($1, $2, 'Channel') RETURNING id", [ws, `ch-${randomUUID().slice(0, 8)}`]);
|
||||
const hls = await one(client,
|
||||
"INSERT INTO virtual_channel_sources (workspace_id, virtual_channel_id, kind, label, remote_ref, hls_url) VALUES ($1, $2, 'hls', 'Cam 1', $3, 'https://cdn.example.com/a.m3u8') RETURNING id",
|
||||
[ws, id, `ref-${randomUUID()}`]);
|
||||
const playlist = await one(client,
|
||||
"INSERT INTO virtual_channel_sources (workspace_id, virtual_channel_id, kind, label, playlist_loop) VALUES ($1, $2, 'playlist', 'Fallback reel', true) RETURNING id",
|
||||
[ws, id]);
|
||||
await client.query("INSERT INTO playlist_items (workspace_id, virtual_channel_id, source_id, position, video_asset_id) VALUES ($1, $2, $3, 0, $4)", [ws, id, playlist, assetId]);
|
||||
const scene = await one(client, "INSERT INTO production_scenes (workspace_id, virtual_channel_id, label, layout) VALUES ($1, $2, 'Main', '2x2') RETURNING id", [ws, id]);
|
||||
const spare = await one(client, "INSERT INTO production_scenes (workspace_id, virtual_channel_id, label, layout) VALUES ($1, $2, 'Spare', 'single') RETURNING id", [ws, id]);
|
||||
const slot = await one(client,
|
||||
"INSERT INTO scene_slots (workspace_id, virtual_channel_id, scene_id, slot_index, source_id, audio_mode, gain_db) VALUES ($1, $2, $3, 0, $4, 'primary', -6) RETURNING id",
|
||||
[ws, id, scene, hls]);
|
||||
await client.query("INSERT INTO production_buses (workspace_id, virtual_channel_id, bus, scene_id) VALUES ($1, $2, 'program', $3), ($1, $2, 'preview', $3)", [ws, id, scene]);
|
||||
const overlay = await one(client, "INSERT INTO overlay_slots (workspace_id, virtual_channel_id, scene_id, kind) VALUES ($1, $2, $3, 'lower_third') RETURNING id", [ws, id, scene]);
|
||||
const revision = await one(client,
|
||||
"INSERT INTO overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, template_id, render_state, asset_id) VALUES ($1, $2, $3, 'lower-third-v1', 'rendered', $4) RETURNING id",
|
||||
[ws, id, overlay, ASSET]);
|
||||
await client.query("UPDATE overlay_slots SET current_revision_id = $2 WHERE id = $1", [overlay, revision]);
|
||||
return { id, hls, playlist, scene, spare, slot, overlay, revision };
|
||||
}
|
||||
|
||||
describe.skipIf(!dbAvailable)("production schema 013 (live DB)", () => {
|
||||
let wsA: string;
|
||||
let wsB: string;
|
||||
let assetA: string;
|
||||
let assetB: string;
|
||||
let a1: Channel;
|
||||
let a2: Channel;
|
||||
let b1: Channel;
|
||||
|
||||
beforeAll(async () => {
|
||||
await runMigrations(process.env.MIGRATIONS_DIR ?? join(process.cwd(), "migrations"));
|
||||
const insert = "INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id";
|
||||
wsA = String((await ownerPool.query(insert, ["Prod A", `prod-a-${randomUUID()}`])).rows[0]?.id);
|
||||
wsB = String((await ownerPool.query(insert, ["Prod B", `prod-b-${randomUUID()}`])).rows[0]?.id);
|
||||
const asset = "INSERT INTO video_assets (workspace_id, storage_key, mime_type, size_bytes) VALUES ($1, 'sha256/x.mp4', 'video/mp4', 1) RETURNING id";
|
||||
assetA = await asTenant(wsA, (c) => one(c, asset, [wsA]));
|
||||
assetB = await asTenant(wsB, (c) => one(c, asset, [wsB]));
|
||||
a1 = await asTenant(wsA, (c) => seedChannel(c, wsA, assetA));
|
||||
a2 = await asTenant(wsA, (c) => seedChannel(c, wsA, assetA));
|
||||
b1 = await asTenant(wsB, (c) => seedChannel(c, wsB, assetB));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (wsA !== undefined && wsB !== undefined) await ownerPool.query("DELETE FROM workspaces WHERE id IN ($1, $2)", [wsA, wsB]);
|
||||
await appPool.end();
|
||||
await ownerPool.end();
|
||||
});
|
||||
|
||||
describe("same-channel invariant fails at the DB constraint", () => {
|
||||
it("a slot cannot reference another channel's source (same workspace)", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO scene_slots (workspace_id, virtual_channel_id, scene_id, slot_index, source_id) VALUES ($1, $2, $3, 1, $4)",
|
||||
[wsA, a1.id, a1.scene, a2.hls]))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
});
|
||||
|
||||
it("a slot cannot claim another channel to smuggle a foreign scene or source", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO scene_slots (workspace_id, virtual_channel_id, scene_id, slot_index, source_id) VALUES ($1, $2, $3, 1, $4)",
|
||||
[wsA, a2.id, a1.scene, a2.hls]))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
});
|
||||
|
||||
it("a slot cannot be re-pointed to any source in place, nor moved to another scene", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE scene_slots SET source_id = $2 WHERE id = $1", [a1.slot, a2.hls])))
|
||||
.rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE scene_slots SET source_id = $2 WHERE id = $1", [a1.slot, a1.playlist])))
|
||||
.rejects.toThrow(/source_id is immutable/);
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE scene_slots SET scene_id = $2 WHERE id = $1", [a1.slot, a1.spare])))
|
||||
.rejects.toThrow(/scene_id is immutable/);
|
||||
});
|
||||
|
||||
it("a bus cannot hold another channel's scene, by insert or by TAKE-style update", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE production_buses SET scene_id = $2 WHERE virtual_channel_id = $1 AND bus = 'program'", [a1.id, a2.scene])))
|
||||
.rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
const moved = await asTenant(wsA, (c) => c.query("UPDATE production_buses SET scene_id = $2 WHERE virtual_channel_id = $1 AND bus = 'preview'", [a1.id, a1.spare]));
|
||||
expect(moved.rowCount).toBe(1);
|
||||
});
|
||||
|
||||
it("overlay slots and revisions stay inside their channel", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO overlay_slots (workspace_id, virtual_channel_id, scene_id, kind) VALUES ($1, $2, $3, 'logo')",
|
||||
[wsA, a1.id, a2.scene]))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, template_id) VALUES ($1, $2, $3, 'logo')",
|
||||
[wsA, a1.id, a2.overlay]))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE overlay_slots SET current_revision_id = $2 WHERE id = $1", [a1.overlay, a2.revision])))
|
||||
.rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
});
|
||||
|
||||
it("a playlist item cannot name another channel's source or another tenant's asset", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO playlist_items (workspace_id, virtual_channel_id, source_id, position, video_asset_id) VALUES ($1, $2, $3, 1, $4)",
|
||||
[wsA, a1.id, a2.playlist, assetA]))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO playlist_items (workspace_id, virtual_channel_id, source_id, position, video_asset_id) VALUES ($1, $2, $3, 1, $4)",
|
||||
[wsA, a1.id, a1.playlist, assetB]))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
});
|
||||
});
|
||||
|
||||
describe("production constraints", () => {
|
||||
it("allows at most one primary audio slot per scene", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO scene_slots (workspace_id, virtual_channel_id, scene_id, slot_index, source_id, audio_mode) VALUES ($1, $2, $3, 1, $4, 'primary')",
|
||||
[wsA, a1.id, a1.scene, a1.playlist]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
|
||||
await asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO scene_slots (workspace_id, virtual_channel_id, scene_id, slot_index, source_id, audio_mode) VALUES ($1, $2, $3, 0, $4, 'primary'), ($1, $2, $5, 1, $4, 'on')",
|
||||
[wsA, a1.id, a1.spare, a1.playlist, a1.scene]));
|
||||
});
|
||||
|
||||
it("gain_db is -24..0 in 3 dB steps", async () => {
|
||||
for (const bad of [-4, 3, -27, -1]) {
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE scene_slots SET gain_db = $2 WHERE id = $1", [a1.slot, bad])))
|
||||
.rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
}
|
||||
for (const good of [-24, -3, 0]) await asTenant(wsA, (c) => c.query("UPDATE scene_slots SET gain_db = $2 WHERE id = $1", [a1.slot, good]));
|
||||
});
|
||||
|
||||
it("geometry is all-or-nothing, positive, and inside the frame", async () => {
|
||||
const set = (sql: string) => asTenant(wsA, (c) => c.query(`UPDATE scene_slots SET ${sql} WHERE id = $1`, [a1.slot]));
|
||||
await expect(set("x = 0, y = 0, w = 100")).rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(set("x = 0, y = 0, w = 0, h = 100")).rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(set("x = -1, y = 0, w = 100, h = 100")).rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(set("x = 9000, y = 0, w = 2000, h = 100")).rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await set("x = 7000, y = 7000, w = 3000, h = 3000");
|
||||
});
|
||||
|
||||
it("layouts, buses and source kinds are closed enums; playlist is a source kind", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE production_scenes SET layout = '5x5' WHERE id = $1", [a1.spare])))
|
||||
.rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO production_buses (workspace_id, virtual_channel_id, bus) VALUES ($1, $2, 'aux')", [wsA, a1.id])))
|
||||
.rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO production_buses (workspace_id, virtual_channel_id, bus) VALUES ($1, $2, 'program')", [wsA, a1.id])))
|
||||
.rejects.toMatchObject({ code: UNIQUE_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO virtual_channel_sources (workspace_id, virtual_channel_id, kind, label, hls_url) VALUES ($1, $2, 'rtmp', 'X', 'https://a.example/x')",
|
||||
[wsA, a1.id]))).rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
const kinds = await asTenant(wsA, (c) => c.query("SELECT kind FROM virtual_channel_sources WHERE virtual_channel_id = $1 ORDER BY kind", [a1.id]));
|
||||
expect(kinds.rows.map((r: { kind: string }) => r.kind)).toEqual(["hls", "playlist"]);
|
||||
});
|
||||
|
||||
it("labels with filtergraph metacharacters are rejected by the schema too", async () => {
|
||||
for (const label of ["a;b", "x[0]", "a,b", "it's", "movie=/etc/passwd"]) {
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE production_scenes SET label = $2 WHERE id = $1", [a1.spare, label])))
|
||||
.rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
}
|
||||
});
|
||||
|
||||
it("overlay kinds carry exactly their own parameters; rendered revisions carry a sha256 asset", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO overlay_slots (workspace_id, virtual_channel_id, scene_id, kind) VALUES ($1, $2, $3, 'chyron')", [wsA, a1.id, a1.scene])))
|
||||
.rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO overlay_slots (workspace_id, virtual_channel_id, scene_id, kind, chyron_speed_px_s, chyron_strip_width_px) VALUES ($1, $2, $3, 'chyron', 401, 100)",
|
||||
[wsA, a1.id, a1.scene]))).rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO overlay_slots (workspace_id, virtual_channel_id, scene_id, kind, chyron_speed_px_s, chyron_strip_width_px) VALUES ($1, $2, $3, 'chyron', 120, 16384)",
|
||||
[wsA, a1.id, a1.scene]));
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO overlay_slots (workspace_id, virtual_channel_id, scene_id, kind) VALUES ($1, $2, $3, 'clock')", [wsA, a1.id, a1.scene])))
|
||||
.rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, template_id, render_state, asset_id) VALUES ($1, $2, $3, 'lt', 'rendered', 'movie=x')",
|
||||
[wsA, a1.id, a1.overlay]))).rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
await expect(asTenant(wsA, (c) => c.query(
|
||||
"INSERT INTO overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, template_id, render_state) VALUES ($1, $2, $3, 'lt', 'rendered')",
|
||||
[wsA, a1.id, a1.overlay]))).rejects.toMatchObject({ code: CHECK_VIOLATION });
|
||||
});
|
||||
});
|
||||
|
||||
describe("lifecycle", () => {
|
||||
it("deleting a source empties its slots (SET NULL keeps tenant + channel); a scene on a bus cannot be deleted", async () => {
|
||||
const channel = await asTenant(wsA, (c) => seedChannel(c, wsA, assetA));
|
||||
await asTenant(wsA, (c) => c.query("DELETE FROM virtual_channel_sources WHERE id = $1", [channel.hls]));
|
||||
const slot = await asTenant(wsA, (c) => c.query("SELECT workspace_id, virtual_channel_id, source_id FROM scene_slots WHERE id = $1", [channel.slot]));
|
||||
expect(slot.rows[0]).toEqual({ workspace_id: wsA, virtual_channel_id: channel.id, source_id: null });
|
||||
await expect(asTenant(wsA, (c) => c.query("DELETE FROM production_scenes WHERE id = $1", [channel.scene])))
|
||||
.rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
});
|
||||
|
||||
it("deleting a rendered revision clears the slot's current revision; deleting a channel cascades everything", async () => {
|
||||
const channel = await asTenant(wsA, (c) => seedChannel(c, wsA, assetA));
|
||||
await asTenant(wsA, (c) => c.query("DELETE FROM overlay_revisions WHERE id = $1", [channel.revision]));
|
||||
const overlay = await asTenant(wsA, (c) => c.query("SELECT current_revision_id FROM overlay_slots WHERE id = $1", [channel.overlay]));
|
||||
expect(overlay.rows[0]?.current_revision_id).toBeNull();
|
||||
await asTenant(wsA, (c) => c.query("DELETE FROM virtual_channels WHERE id = $1", [channel.id]));
|
||||
const left = await asTenant(wsA, (c) => c.query(
|
||||
`SELECT (SELECT count(*) FROM virtual_channel_sources WHERE virtual_channel_id = $1)
|
||||
+ (SELECT count(*) FROM production_scenes WHERE virtual_channel_id = $1)
|
||||
+ (SELECT count(*) FROM scene_slots WHERE virtual_channel_id = $1)
|
||||
+ (SELECT count(*) FROM production_buses WHERE virtual_channel_id = $1)
|
||||
+ (SELECT count(*) FROM overlay_slots WHERE virtual_channel_id = $1)
|
||||
+ (SELECT count(*) FROM overlay_revisions WHERE virtual_channel_id = $1)
|
||||
+ (SELECT count(*) FROM playlist_items WHERE virtual_channel_id = $1) AS n`, [channel.id]));
|
||||
expect(Number(left.rows[0]?.n)).toBe(0);
|
||||
});
|
||||
|
||||
it("a playlisted video asset cannot be deleted directly, but a workspace purge removes everything", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query("DELETE FROM video_assets WHERE id = $1", [assetA])))
|
||||
.rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
const ws = String((await ownerPool.query("INSERT INTO workspaces (name, slug) VALUES ('Purge', $1) RETURNING id", [`purge-${randomUUID()}`])).rows[0]?.id);
|
||||
const asset = await asTenant(ws, (c) => one(c,
|
||||
"INSERT INTO video_assets (workspace_id, storage_key, mime_type, size_bytes) VALUES ($1, 'sha256/p.mp4', 'video/mp4', 1) RETURNING id", [ws]));
|
||||
await asTenant(ws, (c) => seedChannel(c, ws, asset));
|
||||
await ownerPool.query("DELETE FROM workspaces WHERE id = $1", [ws]);
|
||||
const left = await ownerPool.query(
|
||||
`SELECT (SELECT count(*) FROM virtual_channels WHERE workspace_id = $1)
|
||||
+ (SELECT count(*) FROM playlist_items WHERE workspace_id = $1)
|
||||
+ (SELECT count(*) FROM production_buses WHERE workspace_id = $1)
|
||||
+ (SELECT count(*) FROM overlay_revisions WHERE workspace_id = $1) AS n`, [ws]);
|
||||
expect(Number(left.rows[0]?.n)).toBe(0);
|
||||
});
|
||||
|
||||
it("workspace and channel ownership columns are immutable", async () => {
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE production_scenes SET virtual_channel_id = $2 WHERE id = $1", [a1.spare, a2.id])))
|
||||
.rejects.toThrow(/immutable/);
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE production_buses SET bus = 'preview' WHERE virtual_channel_id = $1 AND bus = 'program'", [a1.id])))
|
||||
.rejects.toThrow(/bus is immutable/);
|
||||
await expect(asTenant(wsA, (c) => c.query("UPDATE virtual_channels SET workspace_id = $2 WHERE id = $1", [a1.id, wsB])))
|
||||
.rejects.toThrow(/immutable|row-level security/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RLS denial suite (FORCE, NULLIF tenant isolation)", () => {
|
||||
const tables = [
|
||||
"virtual_channels", "virtual_channel_sources", "playlist_items", "production_scenes",
|
||||
"scene_slots", "production_buses", "overlay_slots", "overlay_revisions",
|
||||
] as const;
|
||||
|
||||
it.each(tables)("%s has RLS enabled and forced", async (table) => {
|
||||
const flags = await ownerPool.query("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = $1::regclass", [table]);
|
||||
expect(flags.rows[0]).toEqual({ relrowsecurity: true, relforcerowsecurity: true });
|
||||
});
|
||||
|
||||
it.each(tables)("%s: tenant B sees, updates and deletes none of A's rows", async (table) => {
|
||||
const seen = await asTenant(wsB, (c) => c.query(`SELECT count(*)::int AS n FROM ${table} WHERE workspace_id = $1`, [wsA]));
|
||||
expect(seen.rows[0]?.n).toBe(0);
|
||||
const updated = await asTenant(wsB, (c) => c.query(`UPDATE ${table} SET updated_at = now() WHERE workspace_id = $1`, [wsA]));
|
||||
expect(updated.rowCount).toBe(0);
|
||||
const deleted = await asTenant(wsB, (c) => c.query(`DELETE FROM ${table} WHERE workspace_id = $1`, [wsA]));
|
||||
expect(deleted.rowCount).toBe(0);
|
||||
const ownStillThere = await asTenant(wsA, (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
|
||||
expect(ownStillThere.rows[0]?.n).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(tables)("%s: no tenant context (and an empty one) sees nothing", async (table) => {
|
||||
const none = await asTenant(null, (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
|
||||
expect(none.rows[0]?.n).toBe(0);
|
||||
const empty = await asTenant("", (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
|
||||
expect(empty.rows[0]?.n).toBe(0);
|
||||
});
|
||||
|
||||
it("tenant B cannot write rows into A's workspace (WITH CHECK) on any table", async () => {
|
||||
const attempts: Array<[string, unknown[]]> = [
|
||||
["INSERT INTO virtual_channels (workspace_id, slug, name) VALUES ($1, 'x-rls', 'X')", [wsA]],
|
||||
["INSERT INTO virtual_channel_sources (workspace_id, virtual_channel_id, kind, label, playlist_loop) VALUES ($1, $2, 'playlist', 'X', false)", [wsA, a1.id]],
|
||||
["INSERT INTO playlist_items (workspace_id, virtual_channel_id, source_id, position, video_asset_id) VALUES ($1, $2, $3, 9, $4)", [wsA, a1.id, a1.playlist, assetA]],
|
||||
["INSERT INTO production_scenes (workspace_id, virtual_channel_id, label, layout) VALUES ($1, $2, 'X', 'single')", [wsA, a1.id]],
|
||||
["INSERT INTO scene_slots (workspace_id, virtual_channel_id, scene_id, slot_index) VALUES ($1, $2, $3, 3)", [wsA, a1.id, a1.scene]],
|
||||
["INSERT INTO production_buses (workspace_id, virtual_channel_id, bus) VALUES ($1, $2, 'program')", [wsA, a1.id]],
|
||||
["INSERT INTO overlay_slots (workspace_id, virtual_channel_id, scene_id, kind) VALUES ($1, $2, $3, 'logo')", [wsA, a1.id, a1.scene]],
|
||||
["INSERT INTO overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, template_id) VALUES ($1, $2, $3, 'lt')", [wsA, a1.id, a1.overlay]],
|
||||
];
|
||||
for (const [sql, params] of attempts) {
|
||||
await expect(asTenant(wsB, (c) => c.query(sql, params)), sql).rejects.toMatchObject({ code: RLS_VIOLATION });
|
||||
}
|
||||
});
|
||||
|
||||
it("tenant B cannot point its own rows at A's channel, scene or source", async () => {
|
||||
await expect(asTenant(wsB, (c) => c.query(
|
||||
"INSERT INTO scene_slots (workspace_id, virtual_channel_id, scene_id, slot_index, source_id) VALUES ($1, $2, $3, 2, $4)",
|
||||
[wsB, b1.id, b1.scene, a1.hls]))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
await expect(asTenant(wsB, (c) => c.query(
|
||||
"UPDATE production_buses SET scene_id = $2 WHERE virtual_channel_id = $1 AND bus = 'program'", [b1.id, a1.scene])))
|
||||
.rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
});
|
||||
});
|
||||
});
|
||||
68
apps/api/src/db/publishing-migration.integration.test.ts
Normal file
68
apps/api/src/db/publishing-migration.integration.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Pool } from "pg";
|
||||
import { runMigrations } from "./migrate.js";
|
||||
import { assertPublishingSchema } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
const ownerUrl = process.env.PUBLISHING_IT_SUPER_DATABASE_URL;
|
||||
const appUrl = process.env.PUBLISHING_IT_DATABASE_URL;
|
||||
describe.skipIf(!ownerUrl || !appUrl)("publishing migration upgrade and resumability (local DB)", () => {
|
||||
let admin: Pool; let owner: Pool; let app: Pool; let dir: string;
|
||||
let database: string; let previousOwner: string | undefined;
|
||||
beforeAll(async () => {
|
||||
const url = new URL(ownerUrl!); url.pathname = "/postgres";
|
||||
admin = new Pool({ connectionString: url.toString() });
|
||||
database = `wp3_migration_sol_${Date.now()}`;
|
||||
await admin.query(`CREATE DATABASE ${database}`);
|
||||
url.pathname = `/${database}`; previousOwner = process.env.SUPER_DATABASE_URL;
|
||||
process.env.SUPER_DATABASE_URL = url.toString(); owner = new Pool({ connectionString: url.toString() });
|
||||
const runtimeUrl = new URL(appUrl!); runtimeUrl.pathname = `/${database}`; app = new Pool({ connectionString: runtimeUrl.toString() });
|
||||
dir = await mkdtemp(join(tmpdir(), "wp3-migrations-sol-"));
|
||||
for (const file of (await readdir("migrations")).filter((name) => name.endsWith(".sql") && name < "018")) await writeFile(join(dir, file), await readFile(join("migrations", file)));
|
||||
await runMigrations(dir);
|
||||
}, 30_000);
|
||||
afterAll(async () => {
|
||||
if (previousOwner === undefined) delete process.env.SUPER_DATABASE_URL; else process.env.SUPER_DATABASE_URL = previousOwner;
|
||||
await owner?.end(); await app?.end();
|
||||
if (admin && database) await admin.query(`DROP DATABASE ${database}`);
|
||||
await admin?.end();
|
||||
});
|
||||
it("upgrades legacy schedules without inventing authorization and resumes 019 after failed cutover", async () => {
|
||||
const user = (await owner.query("INSERT INTO users(external_id, username, display_name) VALUES('migration-sol', 'migration-sol', 'Migration') RETURNING id")).rows[0].id as string;
|
||||
const workspace = (await owner.query("INSERT INTO workspaces(name,slug) VALUES('Migration','migration-sol') RETURNING id")).rows[0].id as string;
|
||||
const connection = (await owner.query("INSERT INTO connections(workspace_id,provider,channel_id) VALUES($1,'mastodon','channel') RETURNING id", [workspace])).rows[0].id as string;
|
||||
const keys = ["legacy-pending", "legacy-flight", "legacy-done"];
|
||||
for (const [index, key] of keys.entries()) {
|
||||
const draft = (await owner.query("INSERT INTO drafts(workspace_id,author_user_id,post,state) VALUES($1,$2,'{}','scheduled') RETURNING id", [workspace, user])).rows[0].id as string;
|
||||
await owner.query("INSERT INTO scheduled_publications(workspace_id,draft_id,connection_id,publish_at,idempotency_key,state) VALUES($1,$2,$3,now(),$4,$5)", [workspace, draft, connection, key, index === 1 ? "in_flight" : "pending"]);
|
||||
if (index === 2) await owner.query("INSERT INTO publications(workspace_id,draft_id,connection_id,idempotency_key,remote_id,published_at) VALUES($1,$2,$3,$4,'remote',now())", [workspace, draft, connection, key]);
|
||||
}
|
||||
await writeFile(join(dir, "018_publishing_execution.sql"), await readFile("migrations/018_publishing_execution.sql"));
|
||||
await runMigrations(dir);
|
||||
const upgraded = await owner.query("SELECT state, scheduled_by_user_id, execution_version FROM scheduled_publications ORDER BY idempotency_key");
|
||||
expect(upgraded.rows.map((row) => row.state)).toEqual(["done", "failed", "needs_reauthorization"]);
|
||||
expect(upgraded.rows.every((row) => row.scheduled_by_user_id === null && row.execution_version === 0)).toBe(true);
|
||||
const original = await readFile("migrations/019_publishing_integration.sql", "utf8");
|
||||
await writeFile(join(dir, "019_publishing_integration.sql"), original.replace("DROP FUNCTION publishing_due_schedules(integer);", "SELECT nonexistent_cutover_function();"));
|
||||
await expect(runMigrations(dir)).rejects.toThrow("cutover failed");
|
||||
expect((await owner.query("SELECT name FROM schema_migrations WHERE name = '019_publishing_integration.sql'")).rowCount).toBe(0);
|
||||
await expect(assertPublishingSchema(app)).rejects.toThrow();
|
||||
expect((await owner.query("SELECT name FROM schema_migrations WHERE name LIKE '019%:%'")).rowCount).toBe(3);
|
||||
await writeFile(join(dir, "019_publishing_integration.sql"), original);
|
||||
await expect(runMigrations(dir)).resolves.toEqual(["019_publishing_integration.sql"]);
|
||||
await expect(assertPublishingSchema(app)).resolves.toBeUndefined();
|
||||
await expect(runMigrations(dir)).resolves.toEqual([]);
|
||||
const role = (await app.query("SELECT current_user, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user")).rows[0];
|
||||
expect(role).toEqual({ current_user: "sp_app", rolsuper: false, rolbypassrls: false });
|
||||
}, 30_000);
|
||||
it("repairs an invalid concurrent index only when its definition matches", async () => {
|
||||
const file = "020_test_concurrent.sql";
|
||||
await writeFile(join(dir, file), "-- wp3:phase expand transaction\nCREATE TABLE migration_index_test(v integer); INSERT INTO migration_index_test VALUES(1),(1);\n-- wp3:phase idx concurrent\nCREATE UNIQUE INDEX CONCURRENTLY migration_index_test_idx ON migration_index_test (v);");
|
||||
await expect(runMigrations(dir)).rejects.toThrow("idx failed");
|
||||
expect((await owner.query("SELECT indisvalid FROM pg_index WHERE indexrelid = 'migration_index_test_idx'::regclass")).rows[0]?.indisvalid).toBe(false);
|
||||
await owner.query("DELETE FROM migration_index_test WHERE ctid NOT IN (SELECT min(ctid) FROM migration_index_test GROUP BY v)");
|
||||
await expect(runMigrations(dir)).resolves.toEqual([file]);
|
||||
expect((await owner.query("SELECT indisvalid FROM pg_index WHERE indexrelid = 'migration_index_test_idx'::regclass")).rows[0]?.indisvalid).toBe(true);
|
||||
}, 30_000);
|
||||
});
|
||||
176
apps/api/src/db/tenancy.integration.test.ts
Normal file
176
apps/api/src/db/tenancy.integration.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { Pool } from "pg";
|
||||
import { pool, withTenant, withSystem } from "./client.js";
|
||||
import { runMigrations } from "./migrate.js";
|
||||
import { PublishingExecutionRepository } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
|
||||
/**
|
||||
* Audit-finding acceptance tests against a live PostgreSQL.
|
||||
* Migrations + cleanup run via the owner pool (SUPER_DATABASE_URL);
|
||||
* the assertions run as sp_app so FORCE RLS binds honestly.
|
||||
* Skipped without DATABASE_URL.
|
||||
*/
|
||||
const dbAvailable = process.env.DATABASE_URL !== undefined;
|
||||
const ownerPool = new Pool({ connectionString: process.env.SUPER_DATABASE_URL ?? process.env.DATABASE_URL });
|
||||
|
||||
async function asOwner<T>(fn: (client: import("pg").PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await ownerPool.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!dbAvailable)("tenancy + audit chain (live DB)", () => {
|
||||
let wsA: string;
|
||||
let wsB: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await runMigrations(process.env.MIGRATIONS_DIR ?? join(process.cwd(), "migrations"));
|
||||
await asOwner(async (client) => {
|
||||
await client.query("DELETE FROM audit_log");
|
||||
await client.query("DELETE FROM memberships");
|
||||
await client.query("DELETE FROM connections");
|
||||
await client.query("DELETE FROM credentials");
|
||||
await client.query("DELETE FROM workspaces");
|
||||
const a = await client.query("INSERT INTO workspaces (name, slug) VALUES ('A', $1) RETURNING id", [`a-${Date.now()}`]);
|
||||
const b = await client.query("INSERT INTO workspaces (name, slug) VALUES ('B', $1) RETURNING id", [`b-${Date.now()}`]);
|
||||
wsA = a.rows[0].id as string;
|
||||
wsB = b.rows[0].id as string;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await asOwner(async (client) => {
|
||||
await client.query("DELETE FROM workspaces WHERE id IN ($1, $2)", [wsA, wsB]);
|
||||
});
|
||||
await pool.end();
|
||||
await ownerPool.end();
|
||||
});
|
||||
|
||||
it("RLS isolates tenants: A sees only A's connections (FORCE applies to owner)", async () => {
|
||||
await withTenant(wsA, async (client) => {
|
||||
await client.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', 'ch-a')", [wsA]);
|
||||
});
|
||||
await withTenant(wsB, async (client) => {
|
||||
await client.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', 'ch-b')", [wsB]);
|
||||
});
|
||||
const seenFromA = await withTenant(wsA, async (client) => {
|
||||
return (await client.query("SELECT channel_id FROM connections")).rows.map((r) => r.channel_id);
|
||||
});
|
||||
expect(seenFromA).toEqual(["ch-a"]);
|
||||
const seenFromSystem = await withSystem(async (client) => {
|
||||
return (await client.query("SELECT count(*)::int AS n FROM connections")).rows[0]?.n;
|
||||
});
|
||||
expect(seenFromSystem).toBe(0);
|
||||
});
|
||||
|
||||
it("SECURITY DEFINER tenant assertion: cross-tenant call rejected (finding #3)", async () => {
|
||||
await expect(
|
||||
withTenant(wsB, async (client) => {
|
||||
await client.query("SELECT audit_append($1, NULL, 'system', 'x', 'y', 'z', NULL)", [wsA]);
|
||||
}),
|
||||
).rejects.toThrow(/tenant context mismatch/);
|
||||
});
|
||||
|
||||
it("audit branch: tenant context => tenant chain even for service callers (findings #1/#6)", async () => {
|
||||
await withTenant(wsA, async (client) => {
|
||||
const r = await client.query("SELECT audit_append($1, NULL, 'sp_scheduler', 'job.ran', 'job', 'j1', NULL) AS id", [wsA]);
|
||||
expect(r.rows[0]?.id).toBeTruthy();
|
||||
const row = await client.query("SELECT workspace_id, actor_role FROM audit_log WHERE id = $1", [r.rows[0].id]);
|
||||
expect(row.rows[0]?.workspace_id).toBe(wsA);
|
||||
expect(row.rows[0]?.actor_role).toBe("sp_scheduler");
|
||||
// system append inside tenant context must be refused
|
||||
await expect(client.query("SELECT audit_append_system(NULL, 'x', 'y', 'z', NULL)")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("membership change bumps authz_epoch => sessions fail closed (finding #2)", async () => {
|
||||
const before = Number(await withSystem(async (c) => (await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [wsA])).rows[0]?.authz_epoch));
|
||||
await asOwner(async (c) => {
|
||||
const u = await c.query("INSERT INTO users (external_id, email, display_name) VALUES ($1, 'e@x', 'E') RETURNING id", [`ext-${Date.now()}`]);
|
||||
await withTenant(wsA, async (tenantClient) => {
|
||||
await tenantClient.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Publisher')", [wsA, u.rows[0].id]);
|
||||
});
|
||||
});
|
||||
const after = Number(await withSystem(async (c) => (await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [wsA])).rows[0]?.authz_epoch));
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it("hash chain detects tampering", async () => {
|
||||
await withTenant(wsA, async (client) => {
|
||||
await client.query("SELECT audit_append($1, NULL, 'system', 't.1', 't', '1', NULL)", [wsA]);
|
||||
await client.query("SELECT audit_append($1, NULL, 'system', 't.2', 't', '2', NULL)", [wsA]);
|
||||
});
|
||||
const ok = await withTenant(wsA, async (c) => (await c.query("SELECT audit_verify_chain($1) AS ok", [wsA])).rows[0]?.ok);
|
||||
expect(ok).toBe(true);
|
||||
// direct tamper (as owner, inside the tenant context, bypassing the append fn)
|
||||
await withTenant(wsA, async (c) => {
|
||||
await c.query("UPDATE audit_log SET action = 'forged' WHERE action = 't.1'");
|
||||
});
|
||||
const broken = await withTenant(wsA, async (c) => (await c.query("SELECT audit_verify_chain($1) AS ok", [wsA])).rows[0]?.ok);
|
||||
expect(broken).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies a legacy v1 row followed by v2 and detects a changed v2 diff", async () => {
|
||||
await withTenant(wsB, async (client) => {
|
||||
await client.query(
|
||||
`WITH stamp AS MATERIALIZED (SELECT clock_timestamp() AS at)
|
||||
INSERT INTO audit_log (workspace_id, action, target_type, target_id, diff, at, prev_hash, hash, hash_version)
|
||||
SELECT $1, 'legacy', 'test', 'legacy', '{"value":1}'::jsonb, stamp.at, NULL,
|
||||
audit_hash(NULL, NULL, 'legacy', 'legacy', stamp.at), 1 FROM stamp`,
|
||||
[wsB],
|
||||
);
|
||||
await client.query("SELECT audit_append($1, NULL, 'system', 'modern', 'test', 'modern', $2)", [wsB, { value: 2 }]);
|
||||
const verified = await client.query("SELECT audit_verify_chain($1) AS ok", [wsB]);
|
||||
expect(verified.rows[0]?.ok).toBe(true);
|
||||
await client.query("UPDATE audit_log SET diff = $2 WHERE workspace_id = $1 AND action = 'modern'", [wsB, { value: 3 }]);
|
||||
const broken = await client.query("SELECT audit_verify_chain($1) AS ok", [wsB]);
|
||||
expect(broken.rows[0]?.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("scheduler claim refuses a pending schedule whose draft is not approved", async () => {
|
||||
const repository = new PublishingExecutionRepository(pool);
|
||||
try {
|
||||
const user = await asOwner(async (client) => {
|
||||
const inserted = await client.query(
|
||||
"INSERT INTO users (external_id, email, display_name) VALUES ($1, 'claim@example.com', 'Claim') RETURNING id",
|
||||
[`claim-${Date.now()}`],
|
||||
);
|
||||
return inserted.rows[0]?.id as string;
|
||||
});
|
||||
const key = `claim-${Date.now()}`;
|
||||
await withTenant(wsA, async (client) => {
|
||||
const connection = await client.query(
|
||||
"INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', 'claim') RETURNING id",
|
||||
[wsA],
|
||||
);
|
||||
const draft = await client.query(
|
||||
"INSERT INTO drafts (workspace_id, author_user_id, post, state) VALUES ($1, $2, $3, 'in_review') RETURNING id",
|
||||
[wsA, user, { text: "unapproved", mediaAssetIds: [], visibility: "public", providerMetadata: {} }],
|
||||
);
|
||||
await client.query(
|
||||
"INSERT INTO scheduled_publications (workspace_id, draft_id, connection_id, publish_at, idempotency_key) VALUES ($1, $2, $3, now(), $4)",
|
||||
[wsA, draft.rows[0]?.id, connection.rows[0]?.id, key],
|
||||
);
|
||||
});
|
||||
const schedule = await withTenant(wsA, (client) => client.query("SELECT id, generation FROM scheduled_publications WHERE idempotency_key = $1", [key]));
|
||||
await expect(repository.claim({ workspaceId: wsA, scheduleId: String(schedule.rows[0]?.id), generation: String(schedule.rows[0]?.generation), idempotencyKey: key })).resolves.toMatchObject({ kind: "failed", code: "EXECUTION_FENCE_MISSING" });
|
||||
const state = await withTenant(wsA, (client) => client.query("SELECT state FROM scheduled_publications WHERE idempotency_key = $1", [key]));
|
||||
expect(state.rows[0]?.state).toBe("pending");
|
||||
} finally {
|
||||
// The shared repository uses the caller-owned pool, closed by suite cleanup.
|
||||
}
|
||||
});
|
||||
|
||||
it("workspace_id is immutable on tenant rows (finding #8)", async () => {
|
||||
await expect(
|
||||
withTenant(wsA, async (c) => {
|
||||
await c.query("UPDATE connections SET workspace_id = $1 WHERE channel_id = 'ch-a'", [wsB]);
|
||||
}),
|
||||
).rejects.toThrow(/workspace_id is immutable/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue