T-HUB/apps/api/src/db/tenancy.integration.test.ts
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

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/);
});
});