import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { join } from "node:path"; import { Pool, type PoolClient } from "pg"; /** * chatgov P0 spine against a live PostgreSQL (chatgov-design.md ยง7 DoD): * - migration idempotency (re-run applies nothing) * - RLS denial for EVERY new table (workspace B sees/updates/deletes zero A * rows, and B cannot write into A's workspace โ€” WITH CHECK) * - rule CRUD + one-live-rule-per-slot * - policy compile -> posting_policy_snapshots with a deterministic version * - RBAC permission gating (chatgov.manage; Editor is denied, Admin allowed) * - audit event landing (policy.revised / policy.gate in audit_log) * * Owns its own database (CHATGOV_IT_DATABASE_URL + CHATGOV_IT_SUPER_DATABASE_URL, * local-db-sol.env pattern) because the shared tenancy DB is wiped by other * suites and vitest runs files in parallel. Skips cleanly when unset. */ const appUrl = process.env.CHATGOV_IT_DATABASE_URL; const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL; const dbAvailable = appUrl !== undefined && ownerUrl !== undefined; const RLS_VIOLATION = "42501"; const UNIQUE_VIOLATION = "23505"; const FK_VIOLATION = "23503"; const TABLES = [ "polls", "poll_votes", "posting_rules", "posting_policy_snapshots", "topic_bindings", "chat_command_audit", "chat_identities", "gate_decisions", ] as const; type Modules = { policy: typeof import("./policy.js"); policyService: typeof import("./policy.service.js"); gate: typeof import("./gate.service.js"); db: typeof import("../db/client.js"); guard: typeof import("../rbac/rbac.guard.js"); session: typeof import("../auth/session.js"); }; describe.skipIf(!dbAvailable)("chatgov P0 spine (live DB)", () => { let m: Modules; let ownerPool: Pool; const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`; let wsA: string; let wsB: string; let userA: string; let connA1: string; let connA2: string; let connB: string; let topicA: string; let draftA: string; async function asOwner(fn: (client: PoolClient) => Promise): Promise { const client = await ownerPool.connect(); try { return await fn(client); } finally { client.release(); } } /** Seed tenant rows honestly: FORCE RLS applies to the owner too. */ async function inTenant(client: PoolClient, wsId: string, fn: () => Promise): Promise { await client.query("BEGIN"); await client.query("SELECT set_config('app.workspace_id', $1, true)", [wsId]); await fn(); await client.query("COMMIT"); } beforeAll(async () => { // The shared pool reads DATABASE_URL at import time: point it at this // isolated DB first, then import the modules under test. process.env.DATABASE_URL = appUrl; process.env.SUPER_DATABASE_URL = ownerUrl; const { runMigrations } = await import("../db/migrate.js"); // Idempotency: the CLI already applied every migration; re-running must // apply nothing (schema_migrations guards). const applied = await runMigrations(join(process.cwd(), "migrations")); expect(applied).toEqual([]); m = { policy: await import("./policy.js"), policyService: await import("./policy.service.js"), gate: await import("./gate.service.js"), db: await import("../db/client.js"), guard: await import("../rbac/rbac.guard.js"), session: await import("../auth/session.js"), }; ownerPool = new Pool({ connectionString: ownerUrl }); await asOwner(async (c) => { const ws = async (name: string): Promise => (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", [name, `${name}-${suffix}`])).rows[0].id as string; wsA = await ws("cg-a"); wsB = await ws("cg-b"); const user = async (name: string): Promise => (await c.query( "INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id", [`cg:${name}:${suffix}`, `cg-${name}-${suffix}`, `${name}-${suffix}@example.com`, name], )).rows[0].id as string; userA = await user("owner"); const editorA = await user("editor"); const conn = async (wsId: string, name: string): Promise => (await c.query( "INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id", [wsId, `ch-${name}`], )).rows[0].id as string; connA1 = await conn(wsA, "a1"); connA2 = await conn(wsA, "a2"); connB = await conn(wsB, "b1"); await inTenant(c, wsA, async () => { await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsA, userA]); await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Editor')", [wsA, editorA]); draftA = (await c.query("INSERT INTO drafts (workspace_id, author_user_id, post) VALUES ($1, $2, $3::jsonb) RETURNING id", [wsA, userA, JSON.stringify({ body: "x" })])).rows[0].id as string; topicA = (await c.query( `INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name, account_ids, icon_color) VALUES ($1, $2, '-100200', '7', 'announce', 'Ops', $3, 7322096) RETURNING id`, [wsA, connA1, [connA1]], )).rows[0].id as string; // Seed rows for the RLS denial suite (A-side data B must not see). const poll = (await c.query( `INSERT INTO polls (workspace_id, channel_id, topic_binding_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by) VALUES ($1, NULL, $2, 'draft', $3, 'ship it?', $4::jsonb, 'pv-seed', now() + interval '1 hour', $5) RETURNING id`, [wsA, topicA, draftA, JSON.stringify({ version: 1, mode: "quorum", arms: [{ kind: "admins", x: 1 }], timebox: { duration: "PT1H", onExpire: "reject" } }), userA], )).rows[0].id as string; await c.query( `INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot) VALUES ($1, $2, 'chat_identity', $3, '424242', 'opal', 'yes')`, [poll, wsA, userA], ); await c.query( `INSERT INTO chat_identities (workspace_id, provider, remote_user_id, user_id, display_name, status, approved_by) VALUES ($1, 'telegram', '111222333', $2, 'Opal', 'approved', $2)`, [wsA, userA], ); await c.query( `INSERT INTO chat_command_audit (workspace_id, update_id, binding_id, provider, remote_user_id, user_id, command, args, permission, decision) VALUES ($1, 9001, $2, 'telegram', '111222333', $3, '/tally', '{}'::jsonb, 'director.view', 'accepted')`, [wsA, topicA, userA], ); // one row of every remaining table so the RLS sweep counts real data await c.query( `INSERT INTO posting_rules (workspace_id, scope, policy, policy_version, created_by) VALUES ($1, 'workspace', $2::jsonb, 'pv-seed', $3)`, [wsA, JSON.stringify({ version: 1, mode: "immediate" }), userA], ); await c.query( `INSERT INTO posting_policy_snapshots (workspace_id, destination_kind, account_id, topic_binding_id, content_type, effective, policy_version, resolved_from) VALUES ($1, 'account', $2, NULL, '', $3::jsonb, 'pv-seed', '{"scopes":["workspace"]}'::jsonb)`, [wsA, connA1, JSON.stringify({ version: 1, mode: "immediate" })], ); await c.query( `INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor) VALUES ($1, $2, $3::jsonb, 'E1', 'pv-seed', 'fired', '{}'::jsonb, '{}', 'system:gate-e1')`, [wsA, draftA, JSON.stringify({ platform: "telegram", accountId: connA1 })], ); }); await inTenant(c, wsB, async () => { const ownerB = await user("owner-b"); await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsB, ownerB]); }); }); }, 60_000); afterAll(async () => { if (ownerPool !== undefined) { await asOwner(async (c) => { await c.query("DELETE FROM workspaces WHERE slug LIKE $1", [`%-${suffix}`]); await c.query("DELETE FROM users WHERE external_id LIKE $1", [`cg:%:${suffix}`]); }); await ownerPool.end(); } await m?.db.pool.end(); }); // ------------------------------------------------------------------------- describe("migration + RBAC seed", () => { it("idempotent: a second run applies nothing", async () => { const { runMigrations } = await import("../db/migrate.js"); const previous = process.env.SUPER_DATABASE_URL; process.env.SUPER_DATABASE_URL = ownerUrl; try { expect(await runMigrations(join(process.cwd(), "migrations"))).toEqual([]); } finally { process.env.SUPER_DATABASE_URL = previous; } }); it("chatgov permissions are seeded per the 002 pattern", async () => { const rows = await asOwner((c) => c.query<{ role: string; permission: string }>( "SELECT role, permission FROM role_permissions WHERE permission LIKE 'chatgov%' ORDER BY role, permission"), ); const map = new Map(); for (const row of rows.rows) { map.set(row.role, [...(map.get(row.role) ?? []), row.permission]); } expect(map.get("Owner")).toEqual(["chatgov.manage", "chatgov.vote"]); expect(map.get("Admin")).toEqual(["chatgov.manage", "chatgov.vote"]); for (const role of ["Publisher", "Editor", "Approver", "Moderator", "BroadcastOperator"]) { expect(map.get(role)).toEqual(["chatgov.vote"]); } for (const role of ["Analyst", "VideoPublisher", "StreamOperator"]) { expect(map.has(role)).toBe(false); } }); it("every new table is RLS-enabled AND forced with a tenant_isolation policy", async () => { for (const table of TABLES) { const flags = await asOwner((c) => c.query<{ relrowsecurity: boolean; relforcerowsecurity: boolean }>( "SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = $1::regclass", [table])); expect(flags.rows[0], table).toEqual({ relrowsecurity: true, relforcerowsecurity: true }); const policies = await asOwner((c) => c.query<{ policyname: string }>( "SELECT policyname FROM pg_policies WHERE tablename = $1", [table])); expect(policies.rows.map((r) => r.policyname), table).toContain("tenant_isolation"); } }); it("sp_app holds table grants (and is neither superuser nor BYPASSRLS)", async () => { for (const table of TABLES) { const grants = await asOwner((c) => c.query<{ privilege_type: string }>( "SELECT privilege_type FROM information_schema.role_table_grants WHERE grantee = 'sp_app' AND table_name = $1", [table])); expect(new Set(grants.rows.map((r) => r.privilege_type)), table) .toEqual(new Set(["SELECT", "INSERT", "UPDATE", "DELETE"])); } const role = await asOwner((c) => c.query<{ rolsuper: boolean; rolbypassrls: boolean }>( "SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'sp_app'")); expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false }); }); }); /** Fresh workspace with its own connections/topic (isolated rule slots). */ async function seedWorkspace(name: string): Promise<{ ws: string; conn1: string; conn2: string; topic: string }> { return asOwner(async (c) => { const ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", [`cg-${name}`, `cg-${name}-${suffix}`])).rows[0].id as string; let conn1 = ""; let conn2 = ""; let topic = ""; await inTenant(c, ws, async () => { await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [ws, userA]); conn1 = (await c.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id", [ws, `ch-${name}-1`])).rows[0].id as string; conn2 = (await c.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id", [ws, `ch-${name}-2`])).rows[0].id as string; topic = (await c.query( `INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name, account_ids) VALUES ($1, $2, '-100200', '7', 'announce', 'Ops', $3) RETURNING id`, [ws, conn1, [conn1]])).rows[0].id as string; }); return { ws, conn1, conn2, topic }; }); } // ------------------------------------------------------------------------- describe("RLS denial suite (FORCE + NULLIF; B must see zero A rows)", () => { it.each(TABLES)("%s: B sees, updates and deletes none of A's rows", async (table) => { const seen = await m.db.withTenant(wsB, (c) => c.query(`SELECT count(*)::int AS n FROM ${table} WHERE workspace_id = $1`, [wsA])); expect(seen.rows[0]?.n).toBe(0); const noFilter = await m.db.withTenant(wsB, (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`)); expect(noFilter.rows[0]?.n).toBe(0); // A harmless mutable column per table (several have no updated_at). const touch: Record<(typeof TABLES)[number], string> = { polls: "outbox_state = outbox_state", poll_votes: "ballot = ballot", posting_rules: "dry_run = dry_run", posting_policy_snapshots: "policy_version = policy_version", topic_bindings: "name = name", chat_command_audit: "reason = reason", chat_identities: "display_name = display_name", gate_decisions: "actor = actor", }; const updated = await m.db.withTenant(wsB, (c) => c.query(`UPDATE ${table} SET ${touch[table]} WHERE workspace_id = $1`, [wsA])); expect(updated.rowCount).toBe(0); const deleted = await m.db.withTenant(wsB, (c) => c.query(`DELETE FROM ${table} WHERE workspace_id = $1`, [wsA])); expect(deleted.rowCount).toBe(0); const ownStillThere = await m.db.withTenant(wsA, (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`)); expect(ownStillThere.rows[0]?.n).toBeGreaterThan(0); }); it("no tenant context (and an empty one) sees nothing on any table", async () => { for (const table of TABLES) { const none = await m.db.withSystem((c) => c.query(`SELECT count(*)::int AS n FROM ${table}`)); expect(none.rows[0]?.n, table).toBe(0); } for (const table of TABLES) { const leaked = await m.db.withTenant(wsB, async (c) => { // empty-string GUC (004 pooled-session hazard) must fail closed too await c.query("SELECT set_config('app.workspace_id', '', true)"); return c.query(`SELECT count(*)::int AS n FROM ${table}`); }); expect(leaked.rows[0]?.n, table).toBe(0); } }); it("B cannot write rows into A's workspace (WITH CHECK) on representative tables", async () => { const attempts: Array<[string, unknown[]]> = [ ["INSERT INTO posting_rules (workspace_id, scope, policy, policy_version, created_by) VALUES ($1, 'workspace', '{}'::jsonb, 'pv', $2)", [wsA, userA]], ["INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor) VALUES ($1, $2, '{}'::jsonb, 'E1', 'pv', 'pending', '{}'::jsonb, '{}', 'system:gate')", [wsA, draftA]], ["INSERT INTO chat_identities (workspace_id, provider, remote_user_id) VALUES ($1, 'telegram', '999')", [wsA]], ["INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name) VALUES ($1, $2, '-100999', 'announce', 'Sneaky')", [wsA, connA1]], ["INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision) VALUES ($1, 42, 'telegram', '999', '/x', 'rejected')", [wsA]], ["INSERT INTO polls (workspace_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by) VALUES ($1, 'draft', $2, 'q?', '{}'::jsonb, 'pv', now() + interval '1 hour', $3)", [wsA, draftA, userA]], ]; for (const [sql, params] of attempts) { await expect(m.db.withTenant(wsB, (c) => c.query(sql, params)), sql).rejects.toMatchObject({ code: RLS_VIOLATION }); } }); it("cross-tenant composite FKs: B's rows cannot point at A's parents", async () => { await expect(m.db.withTenant(wsB, async (c) => c.query( "INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name) VALUES ($1, $2, '-100888', 'announce', 'X')", [wsB, connA1], ))).rejects.toMatchObject({ code: FK_VIOLATION }); await expect(m.db.withTenant(wsB, async (c) => c.query( "INSERT INTO posting_rules (workspace_id, scope, account_id, policy, policy_version, created_by) VALUES ($1, 'account', $2, '{}'::jsonb, 'pv', $3)", [wsB, connA1, userA], ))).rejects.toMatchObject({ code: FK_VIOLATION }); }); }); // ------------------------------------------------------------------------- describe("rule CRUD + compile-to-snapshot", () => { it("create rejects a second live rule in the same slot (unique index)", async () => { const service = new m.policyService.PolicyService(); const actor = { userId: userA, role: "Owner" }; const body = { scope: "account", accountId: connA1, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }; await expect(service.createRule(wsA, body, actor)).resolves.toBeTruthy(); await expect(service.createRule(wsA, body, actor)).rejects.toMatchObject({ status: 409 }); // a different slot is fine await expect(service.createRule(wsA, { ...body, accountId: connA2 }, actor)).resolves.toBeTruthy(); }); it("slot validation refuses nonsense shapes with 400s", async () => { const service = new m.policyService.PolicyService(); const actor = { userId: userA, role: "Owner" }; const bad: unknown[] = [ { scope: "workspace", accountId: connA1, policy: { mode: "immediate" } }, { scope: "account", policy: { mode: "immediate" } }, { scope: "topic", policy: { mode: "immediate" } }, { scope: "content_type", policy: { mode: "immediate" } }, { scope: "topic", topicBindingId: topicA, contentType: "alert", policy: { mode: "immediate" } }, { scope: "mystery", policy: { mode: "immediate" } }, { scope: "workspace", policy: { mode: "nope" } }, ]; for (const body of bad) { await expect(service.createRule(wsA, body, actor), JSON.stringify(body)).rejects.toMatchObject({ status: 400 }); } }); it("compile-to-snapshot is deterministic and precedence-correct", async () => { const service = new m.policyService.PolicyService(); const actor = { userId: userA, role: "Owner" }; const { ws, conn1, conn2, topic } = await seedWorkspace("compile"); // workspace default await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor); // account override for conn1 const accountRule = await service.createRule(ws, { scope: "account", accountId: conn1, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } }, }, actor); // topic override (highest precedence) for the topic bound to conn1 await service.createRule(ws, { scope: "topic", topicBindingId: topic, policy: { mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } }, }, actor); // content_type override await service.createRule(ws, { scope: "content_type", contentType: "alert", policy: { mode: "admin_only", timebox: { duration: "PT30M", onExpire: "escalateOwner" } }, }, actor); const snapshots = await m.db.withTenant(ws, (c) => c.query<{ destination_kind: string; account_id: string; topic_binding_id: string | null; content_type: string; effective: { mode: string }; policy_version: string; }>("SELECT destination_kind, account_id, topic_binding_id, content_type, effective, policy_version FROM posting_policy_snapshots ORDER BY destination_kind, content_type")); const byKey = new Map(snapshots.rows.map((r) => [`${r.destination_kind}|${r.account_id}|${r.topic_binding_id ?? ""}|${r.content_type}`, r])); const vImmediate = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" })); const vAdmin1h = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } })); const vQuorum = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } })); const vAlert = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT30M", onExpire: "escalateOwner" } })); // account root of connA1: account rule beats the workspace default expect(byKey.get(`account|${conn1}||`)?.policy_version).toBe(vAdmin1h); // account root of connA2 (no account rule): workspace default expect(byKey.get(`account|${conn2}||`)?.policy_version).toBe(vImmediate); // topic slot of connA1: topic rule beats account expect(byKey.get(`topic|${conn1}|${topic}|`)?.policy_version).toBe(vQuorum); // typed slot: content_type rule beats workspace default expect(byKey.get(`account|${conn1}||alert`)?.policy_version).toBe(vAlert); // whole-block replacement: the winner IS the policy (no field merges) expect(byKey.get(`topic|${conn1}|${topic}|`)?.effective.mode).toBe("quorum"); expect(byKey.get(`account|${conn1}||`)?.effective).not.toHaveProperty("arms"); // Determinism at the DB level: an idempotent re-save with the SAME // policy must not change the persisted version. const before = byKey.get(`account|${conn1}||`)?.policy_version; await service.updateRule(ws, accountRule.id, { policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor); const after = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>( "SELECT policy_version FROM posting_policy_snapshots WHERE destination_kind = 'account' AND account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn1])); expect(after.rows[0]!.policy_version).toBe(before); // and the DB agrees with the pure compiler on the same inputs const compiled = m.policy.compilePolicy( [ { scope: "workspace", rule: m.policy.parsePolicyRule({ mode: "immediate" }) }, { scope: "account", rule: m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } }) }, ], { adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 }, ); expect(after.rows[0]!.policy_version).toBe(compiled.policy_version); // the snapshot rows live in ws only: invisible from wsA (RLS) const inA = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>( "SELECT count(*)::int AS n FROM posting_policy_snapshots WHERE account_id = $1", [conn1])); expect(inA.rows[0]!.n).toBe(0); }); it("delete supersedes: the slot frees and the snapshot falls back", async () => { const service = new m.policyService.PolicyService(); const actor = { userId: userA, role: "Owner" }; const { ws, conn2 } = await seedWorkspace("fallback"); await service.createRule(ws, { scope: "account", accountId: conn2, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor); const during = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>( "SELECT policy_version FROM posting_policy_snapshots WHERE account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn2])); expect(during.rows[0]!.policy_version).not.toBe(m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" }))); const live = await service.listRules(ws, { accountId: conn2 }); await expect(service.deleteRule(ws, live[0]!.id, actor)).resolves.toEqual({ superseded: true }); // slot freed: a new rule fits without conflict const recreated = await service.createRule(ws, { scope: "account", accountId: conn2, policy: { mode: "immediate" } }, actor); expect(recreated.supersededBy).toBeNull(); const after = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>( "SELECT policy_version FROM posting_policy_snapshots WHERE account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn2])); expect(after.rows[0]!.policy_version).toBe(m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" }))); // superseded rows survive (lineage) but never win const visible = await service.listRules(ws, { accountId: conn2 }); expect(visible).toHaveLength(1); }); it("listRules filters by account/topic and hides superseded rows", async () => { const service = new m.policyService.PolicyService(); const actor = { userId: userA, role: "Owner" }; const { ws, conn1, topic } = await seedWorkspace("listing"); await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor); await service.createRule(ws, { scope: "account", accountId: conn1, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor); await service.createRule(ws, { scope: "topic", topicBindingId: topic, policy: { mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } } }, actor); const all = await service.listRules(ws); expect(all.every((r) => r.supersededBy === null)).toBe(true); expect(all).toHaveLength(3); const forTopic = await service.listRules(ws, { topicBindingId: topic }); expect(forTopic.map((r) => r.scope)).toEqual(["topic"]); const forAccount = await service.listRules(ws, { accountId: conn1 }); expect(forAccount.map((r) => r.scope)).toEqual(["account"]); }); }); // ------------------------------------------------------------------------- describe("gate_decisions writer", () => { it("persists outcomes with codes + quorum state and audits them", async () => { const service = new m.gate.GateService(); const decision = await service.record(wsA, { draftId: draftA, destination: { platform: "telegram", accountId: connA1 }, gate: "E2", policyVersion: "pv-e2", outcome: "blocked", inputs: { arms: [{ kind: "admins", got: 1, needed: 2 }, { kind: "participants", got: 3, needed: 5 }], pollState: "closed" }, reasonCodes: ["INSUFFICIENT_QUORUM"], actor: "chat_poll:abc", }); expect(decision.outcome).toBe("blocked"); expect(decision.reasonCodes).toEqual(["INSUFFICIENT_QUORUM"]); expect(decision.inputs.arms).toEqual([ { kind: "admins", got: 1, needed: 2 }, { kind: "participants", got: 3, needed: 5 }, ]); // the seeded beforeAll row (fired) plus this one โ€” and nothing from B const rows = await service.listForDraft(wsA, draftA); expect(rows.map((r) => r.id)).toContain(decision.id); expect(rows.every((r) => r.workspaceId === wsA)).toBe(true); // audit landed: policy.gate on the tenant chain const audited = await m.db.withTenant(wsA, (c) => c.query<{ action: string; target_type: string; target_id: string; diff: { outcome?: string } }>( "SELECT action, target_type, target_id, diff FROM audit_log WHERE action = 'policy.gate' AND target_id = $1", [draftA])); expect(audited.rows).toHaveLength(1); expect(audited.rows[0]!.target_type).toBe("draft"); expect(audited.rows[0]!.diff.outcome).toBe("blocked"); // resolve: pending -> fired in place (one row per evaluation) const pending = await service.record(wsA, { draftId: draftA, destination: { platform: "telegram", accountId: connA2 }, gate: "E3", policyVersion: "pv-e3", outcome: "pending", inputs: { approvals: 0 }, reasonCodes: ["PENDING_POLL"], actor: "user:x", }); const resolved = await service.resolve(wsA, pending.id, "fired", [], "system:gate-e3"); expect(resolved.outcome).toBe("fired"); expect(resolved.id).toBe(pending.id); const audited2 = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>( "SELECT count(*)::int AS n FROM audit_log WHERE action = 'policy.gate.resolved'")); expect(audited2.rows[0]!.n).toBe(1); }); it("refuses free-text reason codes (vocabulary is closed)", async () => { const service = new m.gate.GateService(); await expect(service.record(wsA, { draftId: draftA, destination: { platform: "telegram", accountId: connA1 }, gate: "E1", policyVersion: "pv", outcome: "blocked", inputs: {}, reasonCodes: ["because i said so"] as never[], actor: "user:x", })).rejects.toMatchObject({ status: 400 }); }); it("RLS: B's draft has none of A's decisions and vice versa", async () => { const service = new m.gate.GateService(); // seed a B-side draft const draftB = await asOwner(async (c) => { const ownerB = (await c.query<{ id: string }>("SELECT u.id FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.workspace_id = $1 AND m.role = 'Owner' LIMIT 1", [wsB])).rows[0]!.id; let id = ""; await inTenant(c, wsB, async () => { id = (await c.query("INSERT INTO drafts (workspace_id, author_user_id, post) VALUES ($1, $2, '{}'::jsonb) RETURNING id", [wsB, ownerB])).rows[0].id as string; }); return id; }); await service.record(wsB, { draftId: draftB, destination: { platform: "telegram", accountId: connB }, gate: "E1", policyVersion: "pv", outcome: "fired", inputs: {}, reasonCodes: [], actor: "user:y" }); const forA = await service.listForDraft(wsA, draftA); expect(forA.every((d) => d.workspaceId === wsA)).toBe(true); const forB = await service.listForDraft(wsB, draftB); expect(forB.every((d) => d.workspaceId === wsB)).toBe(true); expect(forB.map((d) => d.destination.accountId)).toEqual([connB]); }); }); // ------------------------------------------------------------------------- describe("audit chain", () => { it("policy.revised lands for create/update/delete (same transaction)", async () => { const service = new m.policyService.PolicyService(); const actor = { userId: userA, role: "Owner" }; const { ws } = await seedWorkspace("audit"); const created = await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor); await service.updateRule(ws, created.id, { policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor); await service.deleteRule(ws, created.id, actor); const events = await m.db.withTenant(ws, (c) => c.query<{ action: string; diff: { action?: string } }>( "SELECT action, diff FROM audit_log WHERE action = 'policy.revised' ORDER BY seq")); expect(events.rows.map((r) => r.diff.action)).toEqual(["create", "update", "delete"]); // tamper-evident chain still verifies with the new events const ok = await m.db.withTenant(ws, (c) => c.query<{ ok: boolean }>("SELECT audit_verify_chain($1) AS ok", [ws])); expect(ok.rows[0]?.ok).toBe(true); // the live view is empty in the same transaction's aftermath const live = await service.listRules(ws); expect(live).toHaveLength(0); }); }); // ------------------------------------------------------------------------- describe("RBAC permission gating", () => { it("service refuses non-privileged callers without chatgov.manage", async () => { const service = new m.policyService.PolicyService(); // Editor holds chatgov.vote but NOT chatgov.manage const editor = { userId: userA, role: "Editor", permissions: ["chatgov.vote", "workspace.view"] }; await expect(service.createRule(wsA, { scope: "workspace", policy: { mode: "immediate" } }, editor)) .rejects.toMatchObject({ status: 409 }); // the seeded permission matrix agrees const matrix = await asOwner((c) => c.query<{ role: string }>( "SELECT role FROM role_permissions WHERE permission = 'chatgov.manage'")); expect(matrix.rows.map((r) => r.role).sort()).toEqual(["Admin", "Owner"]); }); it("RbacGuard enforces chatgov.manage on the controller route (Editor 403, Admin 200)", async () => { const { Reflector } = await import("@nestjs/core"); const guard = new m.guard.RbacGuard(new Reflector()); const editor = (await asOwner((c) => c.query<{ id: string }>( "SELECT u.id FROM users u WHERE u.external_id = $1", [`cg:editor:${suffix}`]))).rows[0]!.id; const epoch = await asOwner(async (c) => Number((await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [wsA])).rows[0]!.authz_epoch)); const token = (userId: string, role: string): string => m.session.issueSession({ userId, workspaceId: wsA, role, authzEpoch: epoch }); const ctx = (t: string) => ({ getHandler: () => { const handler = (): void => undefined; Reflect.defineMetadata(m.guard.PERMISSION_KEY, "chatgov.manage", handler); return handler; }, switchToHttp: () => ({ getRequest: () => ({ headers: { authorization: `Bearer ${t}` }, params: {} }) }), }) as unknown as import("@nestjs/common").ExecutionContext; await expect(guard.canActivate(ctx(token(editor, "Editor")))).rejects.toMatchObject({ status: 403 }); await expect(guard.canActivate(ctx(token(userA, "Admin")))).resolves.toBe(true); }); }); // ------------------------------------------------------------------------- describe("invariants: one open poll per draft, vote idempotency, replay dedupe", () => { it("a second OPEN poll for the same draft is refused; a closed one frees the gate", async () => { const openPoll = async (): Promise => (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>( `INSERT INTO polls (workspace_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by) VALUES ($1, 'draft', $2, 'again?', '{}'::jsonb, 'pv2', now() + interval '30 minutes', $3) RETURNING id`, [wsA, draftA, userA]))).rows[0]!.id; // the beforeAll seed already holds an OPEN poll for draftA await expect(m.db.withTenant(wsA, (c) => c.query<{ id: string }>( `SELECT id FROM polls WHERE subject_kind = 'draft' AND draft_id = $1 AND state = 'open'`, [draftA]))).resolves.toBeTruthy(); await expect(openPoll()).rejects.toMatchObject({ code: UNIQUE_VIOLATION }); // close it (the poll service P2 owns the real transition; here: direct) const existing = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>( "SELECT id FROM polls WHERE draft_id = $1 AND state = 'open'", [draftA]))).rows[0]!.id; await m.db.withTenant(wsA, (c) => c.query( "UPDATE polls SET state = 'failed', closed_at = now(), close_reason = 'timebox', outbox_state = 'done' WHERE id = $1", [existing])); await expect(openPoll()).resolves.toBeTruthy(); }); it("revote lineage: parent_poll_id must stay inside the workspace (composite FK)", async () => { const parent = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>( "SELECT id FROM polls WHERE workspace_id = $1 ORDER BY created_at LIMIT 1", [wsA]))).rows[0]!.id; const ownerB = (await asOwner((c) => c.query<{ id: string }>( "SELECT u.id FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.workspace_id = $1 AND m.role = 'Owner' LIMIT 1", [wsB]))).rows[0]!.id; await expect(m.db.withTenant(wsB, (c) => c.query( `INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by, parent_poll_id) VALUES ($1, 'none', 'q', '{}'::jsonb, 'pv', now() + interval '1 hour', $2, $3)`, [wsB, ownerB, parent]))).rejects.toMatchObject({ code: FK_VIOLATION }); }); it("poll_votes: one ballot per (poll, voter) โ€” recast replaces, never duplicates", async () => { const poll = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>( `INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by) VALUES ($1, 'none', 'ballot test?', '{}'::jsonb, 'pv-b', now() + interval '1 hour', $2) RETURNING id`, [wsA, userA]))).rows[0]!.id; const cast = (ballot: string): Promise => m.db.withTenant(wsA, (c) => c.query( `INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot) VALUES ($1, $2, 'chat_identity', '777', 'someone', $3)`, [poll, wsA, ballot])); await cast("no"); await expect(cast("yes")).rejects.toMatchObject({ code: UNIQUE_VIOLATION }); await m.db.withTenant(wsA, (c) => c.query( `INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot) VALUES ($1, $2, 'chat_identity', '777', 'someone', 'yes') ON CONFLICT (poll_id, voter_remote_id) DO UPDATE SET ballot = EXCLUDED.ballot, cast_at = now()`, [poll, wsA])); const rows = await m.db.withTenant(wsA, (c) => c.query<{ ballot: string; count: string }>( "SELECT ballot, count(*) AS count FROM poll_votes WHERE poll_id = $1 GROUP BY ballot", [poll])); expect(rows.rows).toEqual([{ ballot: "yes", count: "1" }]); }); it("ineligible ballots are stored but not counted", async () => { const poll = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>( `INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by) VALUES ($1, 'none', 'eligibility?', '{}'::jsonb, 'pv-c', now() + interval '1 hour', $2) RETURNING id`, [wsA, userA]))).rows[0]!.id; await m.db.withTenant(wsA, (c) => c.query( `INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot, eligible, counted, ineligible_reason) VALUES ($1, $2, 'chat_identity', '888', 'rando', 'yes', false, false, 'INELIGIBLE_ACTOR')`, [poll, wsA])); const counted = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>( "SELECT count(*)::int AS n FROM poll_votes WHERE poll_id = $1 AND counted", [poll])); expect(counted.rows[0]!.n).toBe(0); const stored = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>( "SELECT count(*)::int AS n FROM poll_votes WHERE poll_id = $1", [poll])); expect(stored.rows[0]!.n).toBe(1); }); it("chat_command_audit: the same update_id cannot land twice (replay killer)", async () => { const insert = (): Promise => m.db.withTenant(wsA, (c) => c.query( `INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision) VALUES ($1, 777001, 'telegram', '111222333', '/tally', 'accepted')`, [wsA])); await insert(); await expect(insert()).rejects.toMatchObject({ code: UNIQUE_VIOLATION }); // a different workspace may reuse the provider's update id await expect(m.db.withTenant(wsB, (c) => c.query( `INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision) VALUES ($1, 777001, 'telegram', '1', '/tally', 'accepted')`, [wsB]))).resolves.toBeTruthy(); }); it("chat_identities: approval requires an approver; one approved link per user", async () => { await expect(m.db.withTenant(wsA, (c) => c.query( `INSERT INTO chat_identities (workspace_id, provider, remote_user_id, user_id, status) VALUES ($1, 'telegram', '555000111', $2, 'approved')`, [wsA, userA]))).rejects.toThrow(/chat_identities_check/); const second = await m.db.withTenant(wsA, (c) => c.query( `INSERT INTO chat_identities (workspace_id, provider, remote_user_id, display_name) VALUES ($1, 'telegram', '555000112', 'pending one') RETURNING id`, [wsA])); expect(second.rows).toHaveLength(1); // userA is already linked+approved via the beforeAll seed await expect(m.db.withTenant(wsA, (c) => c.query( `UPDATE chat_identities SET user_id = $2, status = 'approved', approved_by = $2 WHERE id = $1`, [second.rows[0]!.id, userA]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION }); }); it("topic_bindings: one control binding per virtual channel; slot uniqueness holds", async () => { await expect(m.db.withTenant(wsA, (c) => c.query( `INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name) VALUES ($1, $2, '-100200', '7', 'control', 'Dup slot')`, [wsA, connA1]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION }); const vc = await asOwner(async (c) => { let id = ""; await inTenant(c, wsA, async () => { id = (await c.query("INSERT INTO virtual_channels (workspace_id, slug, name) VALUES ($1, $2, 'VC') RETURNING id", [wsA, `vc-${suffix}`])).rows[0].id as string; }); return id; }); await m.db.withTenant(wsA, (c) => c.query( `INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name, linked_channel_id) VALUES ($1, $2, '-100300', 'control', 'Cmd', $3)`, [wsA, connA1, vc])); await expect(m.db.withTenant(wsA, (c) => c.query( `INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name, linked_channel_id) VALUES ($1, $2, '-100301', 'control', 'Cmd2', $3)`, [wsA, connA1, vc]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION }); }); }); });