import { describe, it, expect, vi } from "vitest"; import { createAuditAfterReadHook } from "./audit-after-read-hook"; import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "../audit-log.interface"; function makeAuditLog(): IAuditLog & { recorded: AuditEntry[] } { const recorded: AuditEntry[] = []; return { recorded, async record(e) { recorded.push(e); }, eraseSubject: vi.fn(), }; } function baseOpts(auditLog: IAuditLog) { return { auditLog, resourceType: "users", feature: "auth", environment: "test", resolveTenant: () => "default", containsPii: true, piiCategories: ["email"], }; } describe("createAuditAfterReadHook", () => { it("emits a VIEW entry with the resource type + feature + tenant", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook(baseOpts(auditLog)); const doc = { id: "abc", email: "x@y.com" }; const req = { user: { id: "user_1", roles: ["user"] }, headers: { "user-agent": "Mozilla" }, ip: "10.0.0.5" }; await hook({ doc, req } as never); // Wait one tick for fire-and-forget to flush await new Promise((r) => setImmediate(r)); expect(auditLog.recorded).toHaveLength(1); const e = auditLog.recorded[0]!; expect(e.action).toBe("VIEW"); expect(e.resource.type).toBe("users"); expect(e.resource.id).toBe("abc"); expect(e.actorId).toBe("user_1"); expect(e.actorRoles).toEqual(["user"]); expect(e.scope.feature).toBe("auth"); expect(e.scope.tenant).toBe("default"); expect(e.containsPii).toBe(true); expect(e.piiCategories).toEqual(["email"]); expect(e.outcome).toBe("success"); expect(e.from.ipTruncated).toBe("10.0.0.0"); // /24 truncation applied }); it("uses 'system' actor when req.user is null", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook(baseOpts(auditLog)); await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); await new Promise((r) => setImmediate(r)); expect(auditLog.recorded[0]!.actorId).toBe("system"); expect(auditLog.recorded[0]!.actorType).toBe("system"); }); it("falls back to 'internal' / 'payload-internal' sentinels when no IP/UA", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook(baseOpts(auditLog)); await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); await new Promise((r) => setImmediate(r)); expect(auditLog.recorded[0]!.from.ipTruncated).toBe("internal"); expect(auditLog.recorded[0]!.from.userAgent).toBe("payload-internal"); }); it("shouldSkip predicate prevents emission", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook({ ...baseOpts(auditLog), shouldSkip: () => true }); await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); await new Promise((r) => setImmediate(r)); expect(auditLog.recorded).toHaveLength(0); }); it("returns the doc unchanged (afterRead hook contract)", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook(baseOpts(auditLog)); const doc = { id: "abc", title: "Hello" }; const result = await hook({ doc, req: { user: null, headers: {} } } as never); expect(result).toBe(doc); }); it("audit-sink failures do not propagate (fire-and-forget)", async () => { const auditLog: IAuditLog = { record: async () => { throw new Error("sink-failed"); }, eraseSubject: vi.fn(), }; const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const hook = createAuditAfterReadHook(baseOpts(auditLog)); await expect( hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never), ).resolves.toBeDefined(); // Give the microtask queue a moment to flush the catch handler await new Promise((r) => setImmediate(r)); expect(errSpy).toHaveBeenCalled(); errSpy.mockRestore(); }); });