import { describe, expect, it } from "@suaveplan/testing/runner"; import type { AuditRecord } from "../schema/schema.js"; import { canonicalBytes, signRecord, verifyRecord } from "./hmac.js"; const KEY = new TextEncoder().encode("test-key-32-bytes-padding-padding!"); const RECORD: AuditRecord = { ts: "2026-05-26T10:00:00.000Z", type: "admin.plugin.disabled", principal: { id: "user_1", scopes: ["admin"] }, tenant: "tenant_a", request_id: "req_1", trace_id: null, subject: "plugin.exports", outcome: "success", }; const FAILURE: AuditRecord = { ...RECORD, outcome: "failure", cause: { code: "PLUGIN.NOT_FOUND", message: "no such plugin" }, }; describe("canonicalBytes", () => { it("is deterministic across calls", { timeout: 5000 }, () => { const a = canonicalBytes(RECORD); const b = canonicalBytes(RECORD); expect(a).toEqual(b); }); it("omits cause when absent", { timeout: 5000 }, () => { const json = new TextDecoder().decode(canonicalBytes(RECORD)); expect(json.includes('"cause"')).toBe(false); }); it("includes cause when present", { timeout: 5000 }, () => { const json = new TextDecoder().decode(canonicalBytes(FAILURE)); expect(json.includes('"cause"')).toBe(true); }); it("emits keys in design-doc ยง12.5 order", { timeout: 5000 }, () => { const json = new TextDecoder().decode(canonicalBytes(RECORD)); // Top-level keys appear in the fixed order const order = [ '"ts"', '"type"', '"principal"', '"tenant"', '"request_id"', '"trace_id"', '"subject"', '"outcome"', ]; let last = -1; for (const k of order) { const idx = json.indexOf(k); expect(idx).toBeGreaterThan(last); last = idx; } }); }); describe("signRecord / verifyRecord", () => { it("HMAC verifies under the same key", { timeout: 5000 }, () => { const tag = signRecord(RECORD, KEY); expect(verifyRecord(RECORD, KEY, tag)).toBe(true); }); it("HMAC is hex-encoded and length-64 for SHA-256", { timeout: 5000, }, () => { const tag = signRecord(RECORD, KEY); expect(tag.length).toBe(64); expect(/^[0-9a-f]+$/.test(tag)).toBe(true); }); it("tampering invalidates HMAC", { timeout: 5000 }, () => { const tag = signRecord(RECORD, KEY); const tampered: AuditRecord = { ...RECORD, outcome: "failure", cause: { code: "X", message: "y" }, }; expect(verifyRecord(tampered, KEY, tag)).toBe(false); }); it("wrong key fails verification", { timeout: 5000 }, () => { const tag = signRecord(RECORD, KEY); const other = new TextEncoder().encode( "different-key-padding-padding!!!" ); expect(verifyRecord(RECORD, other, tag)).toBe(false); }); it("malformed hex returns false", { timeout: 5000 }, () => { expect(verifyRecord(RECORD, KEY, "zz")).toBe(false); expect(verifyRecord(RECORD, KEY, "abc")).toBe(false); }); it("length mismatch returns false", { timeout: 5000 }, () => { expect(verifyRecord(RECORD, KEY, "ab")).toBe(false); }); });