Initial Commit
This commit is contained in:
103
packages/api-stack/api/api-audit-stdout/src/hmac/hmac.test.ts
Normal file
103
packages/api-stack/api/api-audit-stdout/src/hmac/hmac.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user