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