|
| 1 | +/** |
| 2 | + * Per-org integrity audit chain tests. |
| 3 | + * |
| 4 | + * Chains are scoped per organization: each org gets an independent HMAC chain |
| 5 | + * with its own head and its own 1..N sequence. This means: |
| 6 | + * - one org's events never interleave into another org's chain |
| 7 | + * - a single org's exported slice is contiguous and verifies standalone |
| 8 | + * - relabelling an event into a different org breaks its hash (org is bound |
| 9 | + * into the canonical form) |
| 10 | + * - events with NO organizationId share one org-less chain, byte-for-byte |
| 11 | + * compatible with chains written before per-org scoping existed |
| 12 | + */ |
| 13 | + |
| 14 | +import { describe, it } from "node:test"; |
| 15 | +import assert from "node:assert/strict"; |
| 16 | +import { createGovernance } from "./index"; |
| 17 | +import { verifyAuditIntegrity } from "./audit-integrity-verify"; |
| 18 | + |
| 19 | +const KEY = "per-org-chain-secret"; |
| 20 | + |
| 21 | +describe("integrity chain — per-org scoping", () => { |
| 22 | + it("gives each org an independent, contiguous, standalone-verifiable chain", async () => { |
| 23 | + const gov = createGovernance({ integrityAudit: { signingKey: KEY } }); |
| 24 | + |
| 25 | + // 3 events for org A, 2 for org B, interleaved in time. |
| 26 | + await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" }); |
| 27 | + await gov.enforce({ agentId: "b1", organizationId: "orgB", action: "tool_call", tool: "t" }); |
| 28 | + await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" }); |
| 29 | + await gov.enforce({ agentId: "b1", organizationId: "orgB", action: "tool_call", tool: "t" }); |
| 30 | + await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" }); |
| 31 | + |
| 32 | + const chainA = await gov.integrityChain!.export({ organizationId: "orgA" }); |
| 33 | + const chainB = await gov.integrityChain!.export({ organizationId: "orgB" }); |
| 34 | + |
| 35 | + // Each org's slice carries only its own events, with contiguous sequences. |
| 36 | + assert.equal(chainA.length, 3); |
| 37 | + assert.equal(chainB.length, 2); |
| 38 | + assert.deepEqual(chainA.map((e) => e.integrity.sequence), [1, 2, 3]); |
| 39 | + assert.deepEqual(chainB.map((e) => e.integrity.sequence), [1, 2]); |
| 40 | + assert.ok(chainA.every((e) => e.organizationId === "orgA")); |
| 41 | + assert.ok(chainB.every((e) => e.organizationId === "orgB")); |
| 42 | + |
| 43 | + // Each slice verifies cleanly on its own. |
| 44 | + const rA = await verifyAuditIntegrity(chainA, KEY); |
| 45 | + const rB = await verifyAuditIntegrity(chainB, KEY); |
| 46 | + assert.equal(rA.valid, true, rA.breakDetail ?? ""); |
| 47 | + assert.equal(rB.valid, true, rB.breakDetail ?? ""); |
| 48 | + |
| 49 | + // Heads are tracked per-org. |
| 50 | + assert.equal(gov.integrityChain!.stats("orgA").latestSequence, 3); |
| 51 | + assert.equal(gov.integrityChain!.stats("orgB").latestSequence, 2); |
| 52 | + }); |
| 53 | + |
| 54 | + it("binds organizationId into the hash — relabelling into another org breaks verification", async () => { |
| 55 | + const gov = createGovernance({ integrityAudit: { signingKey: KEY } }); |
| 56 | + await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" }); |
| 57 | + const [event] = await gov.integrityChain!.export({ organizationId: "orgA" }); |
| 58 | + |
| 59 | + // Untampered verifies. |
| 60 | + assert.equal((await verifyAuditIntegrity([event], KEY)).valid, true); |
| 61 | + |
| 62 | + // Move the event into another org without re-signing → hash no longer matches. |
| 63 | + const moved = { ...event, organizationId: "orgB" }; |
| 64 | + const res = await verifyAuditIntegrity([moved], KEY); |
| 65 | + assert.equal(res.valid, false); |
| 66 | + }); |
| 67 | + |
| 68 | + it("resolves org from metadata.organizationId when the field is omitted", async () => { |
| 69 | + const gov = createGovernance({ integrityAudit: { signingKey: KEY } }); |
| 70 | + await gov.enforce({ agentId: "a1", action: "tool_call", tool: "t", metadata: { organizationId: "orgMeta" } }); |
| 71 | + |
| 72 | + const chain = await gov.integrityChain!.export({ organizationId: "orgMeta" }); |
| 73 | + assert.equal(chain.length, 1); |
| 74 | + assert.equal(chain[0].organizationId, "orgMeta"); |
| 75 | + assert.equal(gov.integrityChain!.stats("orgMeta").latestSequence, 1); |
| 76 | + }); |
| 77 | + |
| 78 | + it("keeps the org-less chain independent from org chains", async () => { |
| 79 | + const gov = createGovernance({ integrityAudit: { signingKey: KEY } }); |
| 80 | + await gov.audit.log({ agentId: "x", eventType: "custom", outcome: "success", severity: "info" }); |
| 81 | + await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" }); |
| 82 | + await gov.audit.log({ agentId: "x", eventType: "custom", outcome: "success", severity: "info" }); |
| 83 | + |
| 84 | + // org-less chain has its own contiguous sequence (2), unaffected by orgA. |
| 85 | + const orgless = await gov.integrityChain!.export({}); |
| 86 | + const orglessOnly = orgless.filter((e) => e.organizationId == null); |
| 87 | + assert.deepEqual(orglessOnly.map((e) => e.integrity.sequence), [1, 2]); |
| 88 | + assert.equal(gov.integrityChain!.stats().latestSequence, 2); |
| 89 | + assert.equal(gov.integrityChain!.stats("orgA").latestSequence, 1); |
| 90 | + }); |
| 91 | + |
| 92 | + it("register() stamps the org onto the agent record and the agent_registered event", async () => { |
| 93 | + const gov = createGovernance({ integrityAudit: { signingKey: KEY } }); |
| 94 | + const { id } = await gov.register({ name: "alpha", framework: "mastra", owner: "t", organizationId: "orgA" }); |
| 95 | + |
| 96 | + const stored = await gov.storage.getAgent(id); |
| 97 | + assert.equal(stored?.organizationId, "orgA"); |
| 98 | + |
| 99 | + const chainA = await gov.integrityChain!.export({ organizationId: "orgA" }); |
| 100 | + assert.equal(chainA.length, 1); |
| 101 | + assert.equal(chainA[0].eventType, "agent_registered"); |
| 102 | + assert.equal((await verifyAuditIntegrity(chainA, KEY)).valid, true); |
| 103 | + }); |
| 104 | + |
| 105 | + it("recordOutcome() lands on the agent's org chain", async () => { |
| 106 | + const gov = createGovernance({ integrityAudit: { signingKey: KEY } }); |
| 107 | + await gov.recordOutcome({ agentId: "a1", organizationId: "orgA", action: "tool_call", success: true }); |
| 108 | + const chainA = await gov.integrityChain!.export({ organizationId: "orgA" }); |
| 109 | + assert.equal(chainA.length, 1); |
| 110 | + assert.equal(chainA[0].eventType, "action_outcome"); |
| 111 | + }); |
| 112 | +}); |
0 commit comments