T-HUB/apps/api/src/chatgov/policy.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

173 lines
8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
canonicalJson,
compilePolicy,
parsePolicyRule,
policyHash,
resolvePrecedence,
type QuorumStateInput,
type RuleCandidate,
} from "./policy.js";
/**
* P0 acceptance (design §7): "policy compile is pure + snapshot hash
* deterministic". Pure-function tests — no DB, no clock.
*/
const quorum: QuorumStateInput = { adminCount: 3, observedParticipants: 12, eligibleParticipants: 8 };
const immediate = { version: 1, mode: "immediate" as const };
const adminOnly = { version: 1, mode: "admin_only" as const, timebox: { duration: "PT1H", onExpire: "reject" as const } };
const quorumRule = {
version: 1,
mode: "quorum" as const,
arms: [
{ kind: "admins" as const, x: 2 },
{ kind: "participants" as const, x: 3, of: "observed" as const },
],
join: "all" as const,
allowVeto: true,
timebox: { duration: "PT2H", onExpire: "reject" as const },
};
describe("canonicalJson", () => {
it("sorts keys and drops undefined, so key order never changes the hash input", () => {
expect(canonicalJson({ b: 1, a: 2 })).toBe(canonicalJson({ a: 2, b: 1 }));
expect(canonicalJson({ a: undefined, b: 1 })).toBe(canonicalJson({ b: 1 }));
expect(canonicalJson({ a: [1, { y: 2, x: 1 }] })).toBe('{"a":[1,{"x":1,"y":2}]}');
});
it("keeps array order (order is data)", () => {
expect(canonicalJson({ a: [2, 1] })).not.toBe(canonicalJson({ a: [1, 2] }));
});
});
describe("policyHash determinism", () => {
it("same rule (any key order / explicit defaults) => same hash", () => {
const a = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } });
const b = parsePolicyRule({
timebox: { onExpire: "reject", duration: "PT2H" },
arms: [{ x: 2, kind: "admins" }],
mode: "quorum",
join: "all",
allowVeto: false,
anonymity: "named",
allowSelfApproval: false,
resetOnChange: true,
dryRun: false,
minDwellSeconds: 0,
});
expect(policyHash(a)).toBe(policyHash(b));
});
it("different policy => different hash", () => {
expect(policyHash(parsePolicyRule({ mode: "immediate" })))
.not.toBe(policyHash(parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } })));
const withVeto = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], allowVeto: true, timebox: { duration: "PT1H", onExpire: "reject" } });
const noVeto = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], allowVeto: false, timebox: { duration: "PT1H", onExpire: "reject" } });
expect(policyHash(withVeto)).not.toBe(policyHash(noVeto));
});
it("quorum denominators never enter the hash", () => {
const rule = parsePolicyRule(quorumRule);
const withSmall: QuorumStateInput = { adminCount: 1, observedParticipants: 1, eligibleParticipants: 1 };
const withBig: QuorumStateInput = { adminCount: 99, observedParticipants: 9999, eligibleParticipants: 500 };
expect(policyHash(rule)).toBe(compilePolicy([{ scope: "workspace", rule }], withSmall).policy_version);
expect(compilePolicy([{ scope: "workspace", rule }], withSmall).policy_version)
.toBe(compilePolicy([{ scope: "workspace", rule }], withBig).policy_version);
});
});
describe("compilePolicy", () => {
it("candidate order does not change the compiled snapshot", () => {
const candidates: RuleCandidate[] = [
{ scope: "workspace", rule: parsePolicyRule(immediate) },
{ scope: "account", rule: parsePolicyRule(adminOnly) },
{ scope: "topic", rule: parsePolicyRule(quorumRule) },
];
const forward = compilePolicy(candidates, quorum);
const shuffled = compilePolicy([...candidates].reverse(), quorum);
expect(forward.policy_version).toBe(shuffled.policy_version);
expect(canonicalJson(forward.rule)).toBe(canonicalJson(shuffled.rule));
});
it("repeated compiles are byte-identical (no hidden state)", () => {
const candidates: RuleCandidate[] = [{ scope: "content_type", rule: parsePolicyRule(adminOnly) }];
const first = compilePolicy(candidates, quorum);
for (let i = 0; i < 5; i += 1) {
expect(compilePolicy(candidates, quorum)).toEqual(first);
}
});
it("no candidates => fail-closed immediate baseline with a stable version", () => {
const empty = compilePolicy([], quorum);
expect(empty.rule.mode).toBe("immediate");
expect(empty.policy_version).toBe(compilePolicy([], { adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 }).policy_version);
expect(empty.policy_version).toBe(policyHash(parsePolicyRule({ mode: "immediate", allowVeto: false })));
});
});
describe("resolvePrecedence (D4: topic > content_type > account > workspace)", () => {
it("picks the most specific scope", () => {
const all: RuleCandidate[] = [
{ scope: "workspace", rule: parsePolicyRule(immediate) },
{ scope: "account", rule: parsePolicyRule(adminOnly) },
{ scope: "content_type", rule: parsePolicyRule(adminOnly) },
{ scope: "topic", rule: parsePolicyRule(quorumRule) },
];
expect(resolvePrecedence(all).scope).toBe("topic");
expect(resolvePrecedence(all.filter((c) => c.scope !== "topic")).scope).toBe("content_type");
expect(resolvePrecedence(all.filter((c) => c.scope === "account" || c.scope === "workspace")).scope).toBe("account");
expect(resolvePrecedence(all.filter((c) => c.scope === "workspace")).scope).toBe("workspace");
});
it("whole-block replacement: the winner's rule IS the effective policy", () => {
const winner = resolvePrecedence([
{ scope: "workspace", rule: parsePolicyRule(quorumRule) },
{ scope: "topic", rule: parsePolicyRule(adminOnly) },
]);
expect(winner.rule.mode).toBe("admin_only");
expect(winner.rule).not.toHaveProperty("arms");
});
});
describe("parsePolicyRule validation (400s, never 500s)", () => {
it("rejects malformed shapes", () => {
const bad: unknown[] = [
null,
"immediate",
{ mode: "nope" },
{ mode: "quorum" }, // no arms
{ mode: "quorum", arms: [] },
{ mode: "quorum", arms: [{ kind: "admins", x: 0 }] },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "2 hours", onExpire: "reject" } },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }] }, // no timebox
{ mode: "admin_only", arms: [{ kind: "participants", x: 2 }], timebox: { duration: "PT1H", onExpire: "reject" } },
{ mode: "immediate", arms: [{ kind: "admins", x: 1 }] },
{ mode: "quorum", arms: [{ kind: "admins", x: 2, of: "observed" }], timebox: { duration: "PT1H", onExpire: "reject" } },
{ mode: "quorum", arms: [{ kind: "participants", x: 2 }], timebox: { duration: "PT1H", onExpire: "maybe" } },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], join: "xor", timebox: { duration: "PT1H", onExpire: "reject" } },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], anonymity: "secret", timebox: { duration: "PT1H", onExpire: "reject" } },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], minDwellSeconds: -1, timebox: { duration: "PT1H", onExpire: "reject" } },
{ version: 2, mode: "immediate" },
];
for (const input of bad) {
expect(() => parsePolicyRule(input), JSON.stringify(input)).toThrow();
}
});
it("accepts the full §1a surface and normalises defaults", () => {
const rule = parsePolicyRule(quorumRule);
expect(rule).toMatchObject({
version: 1,
mode: "quorum",
join: "all",
allowVeto: true,
anonymity: "named",
allowSelfApproval: false,
resetOnChange: true,
dryRun: false,
minDwellSeconds: 0,
});
expect(rule.arms).toHaveLength(2);
expect(rule.timebox).toEqual({ duration: "PT2H", onExpire: "reject" });
});
it("ISO-8601 duration shapes accepted include days/minutes/seconds", () => {
for (const duration of ["PT30M", "PT45S", "P1DT12H", "PT2H30M"]) {
expect(() => parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 1 }], timebox: { duration, onExpire: "escalateOwner" } })).not.toThrow();
}
});
});