feat(core-audit): createAuditAfterReadHook factory for opt-in VIEW capture
Adds the afterRead hook factory for per-collection opt-in automatic VIEW audit entry emission. Fire-and-forget design ensures failing sinks never propagate to the user-facing read. Includes sentinel IP/UA fallbacks, truncateIp /24 integration, shouldSkip predicate, and system actor resolution. All 6 TDD test cases pass; both barrels updated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
103
packages/core-audit/src/hooks/audit-after-read-hook.test.ts
Normal file
103
packages/core-audit/src/hooks/audit-after-read-hook.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user