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/.
173 lines
8 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|