104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
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);
|
|
});
|
|
});
|