diff --git a/packages/core-testing/src/instrumentation/index.ts b/packages/core-testing/src/instrumentation/index.ts index c9e8673..8d29f2f 100644 --- a/packages/core-testing/src/instrumentation/index.ts +++ b/packages/core-testing/src/instrumentation/index.ts @@ -4,3 +4,4 @@ export { RecordingMetrics, type RecordedMetric } from "./recording-metrics"; export { RecordingJobQueue } from "./recording-job-queue"; export { RecordingEventBus } from "./recording-event-bus"; export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster"; +export { RecordingAuditLog } from "./recording-audit-log"; diff --git a/packages/core-testing/src/instrumentation/recording-audit-log.test.ts b/packages/core-testing/src/instrumentation/recording-audit-log.test.ts new file mode 100644 index 0000000..27a90d6 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-audit-log.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { RecordingAuditLog } from "./recording-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; + +const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: [], + action: "CREATE", + resource: { type: "articles" }, + at: new Date(), + scope: { feature: "blog", environment: "test", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "test" }, + containsPii: false, + outcome: "success", +}; + +describe("RecordingAuditLog", () => { + it("record() pushes to recorded[]", async () => { + const log = new RecordingAuditLog(); + await log.record(sample); + expect(log.recorded).toHaveLength(1); + expect(log.recorded[0]!.actorId).toBe("user_1"); + }); + + it("eraseSubject(pseudonymize) tracks erasure + rewrites actorId in recorded", async () => { + const log = new RecordingAuditLog(); + await log.record(sample); + await log.eraseSubject("user_1", "pseudonymize"); + expect(log.erasures).toEqual([{ actorId: "user_1", mode: "pseudonymize" }]); + expect(log.recorded[0]!.actorId).toBe("erased-user_1"); // sentinel rewrite + }); + + it("eraseSubject(delete) removes matching entries from recorded", async () => { + const log = new RecordingAuditLog(); + await log.record(sample); + await log.record({ ...sample, actorId: "user_2" }); + await log.eraseSubject("user_1", "delete"); + expect(log.recorded.map((r) => r.actorId)).toEqual(["user_2"]); + expect(log.erasures).toEqual([{ actorId: "user_1", mode: "delete" }]); + }); + + it("reset() clears recorded + erasures", async () => { + const log = new RecordingAuditLog(); + await log.record(sample); + await log.eraseSubject("user_1", "pseudonymize"); + log.reset(); + expect(log.recorded).toEqual([]); + expect(log.erasures).toEqual([]); + }); +}); diff --git a/packages/core-testing/src/instrumentation/recording-audit-log.ts b/packages/core-testing/src/instrumentation/recording-audit-log.ts new file mode 100644 index 0000000..735e83a --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-audit-log.ts @@ -0,0 +1,37 @@ +import type { AuditEntry } from "@repo/core-shared/audit"; + +/** + * Test-side recording double for IAuditLog. Mirrors Payload semantics in + * eraseSubject (pseudonymize rewrites in place; delete filters out) so tests + * can assert against the same observable state the real impl produces. + * + * Use directly via constructor injection in factory-function tests — no + * container manipulation needed. + */ +export class RecordingAuditLog { + public recorded: AuditEntry[] = []; + public erasures: { actorId: string; mode: "pseudonymize" | "delete" }[] = []; + + async record(entry: AuditEntry): Promise { + // Shallow copy to avoid mutating caller's object during eraseSubject + this.recorded.push({ ...entry }); + } + + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + this.erasures.push({ actorId, mode }); + if (mode === "pseudonymize") { + for (const r of this.recorded) { + if (r.actorId === actorId) { + r.actorId = `erased-${actorId}`; + } + } + } else { + this.recorded = this.recorded.filter((r) => r.actorId !== actorId); + } + } + + reset(): void { + this.recorded = []; + this.erasures = []; + } +}