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/.
710 lines
41 KiB
TypeScript
710 lines
41 KiB
TypeScript
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<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
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<void>): Promise<void> {
|
|
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<string> =>
|
|
(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<string> =>
|
|
(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<string> =>
|
|
(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<string, string[]>();
|
|
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<string> => (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<unknown> => 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<unknown> => 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 });
|
|
});
|
|
});
|
|
});
|