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/.
176 lines
8.9 KiB
TypeScript
176 lines
8.9 KiB
TypeScript
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/);
|
|
});
|
|
});
|