diff --git a/packages/core-audit/src/payload-audit-log.test.ts b/packages/core-audit/src/payload-audit-log.test.ts index daffe95..e37e5f4 100644 --- a/packages/core-audit/src/payload-audit-log.test.ts +++ b/packages/core-audit/src/payload-audit-log.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { PayloadAuditLog } from "./payload-audit-log"; import type { AuditEntry } from "@repo/core-shared/audit"; @@ -39,3 +39,90 @@ describe("PayloadAuditLog.record", () => { expect(call.data.outcome).toBe("success"); }); }); + +describe("PayloadAuditLog.eraseSubject", () => { + const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"]; + + beforeEach(() => { + process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-erase"; + }); + + afterEach(() => { + if (originalSalt === undefined) { + delete process.env["AUDIT_PSEUDONYM_SALT"]; + } else { + process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt; + } + }); + + it("mode='delete' calls payload.delete with the correct where clause + overrideAccess", async () => { + const mockDelete = vi.fn().mockResolvedValue({}); + const mockGetPayload = vi.fn().mockResolvedValue({ delete: mockDelete }); + const log = new PayloadAuditLog({} as never, mockGetPayload); + + await log.eraseSubject("user_1", "delete"); + + expect(mockDelete).toHaveBeenCalledOnce(); + const call = mockDelete.mock.calls[0]![0] as { + collection: string; + where: Record; + overrideAccess: boolean; + }; + expect(call.collection).toBe("audit-logs"); + expect(call.where).toEqual({ actorId: { equals: "user_1" } }); + expect(call.overrideAccess).toBe(true); + }); + + it("mode='pseudonymize' finds matching docs and updates each actorId to the pseudonym", async () => { + const mockFind = vi.fn().mockResolvedValue({ + docs: [{ id: "doc_a" }, { id: "doc_b" }], + }); + const mockUpdate = vi.fn().mockResolvedValue({}); + const mockGetPayload = vi.fn().mockResolvedValue({ + find: mockFind, + update: mockUpdate, + }); + const log = new PayloadAuditLog({} as never, mockGetPayload); + + await log.eraseSubject("user_1", "pseudonymize"); + + // find must use overrideAccess + limit=10_000 + const findCall = mockFind.mock.calls[0]![0] as { + collection: string; + where: Record; + limit: number; + overrideAccess: boolean; + }; + expect(findCall.collection).toBe("audit-logs"); + expect(findCall.where).toEqual({ actorId: { equals: "user_1" } }); + expect(findCall.limit).toBe(10_000); + expect(findCall.overrideAccess).toBe(true); + + // update called for each doc + expect(mockUpdate).toHaveBeenCalledTimes(2); + const updateCalls = mockUpdate.mock.calls as Array< + [{ collection: string; id: string; data: Record; overrideAccess: boolean }] + >; + expect(updateCalls[0]![0].id).toBe("doc_a"); + expect(updateCalls[1]![0].id).toBe("doc_b"); + + // both updates replace actorId with the same pseudonym + const pseudonym = updateCalls[0]![0].data["actorId"] as string; + expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/); + expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym); + + // overrideAccess bypasses the append-only rule + expect(updateCalls[0]![0].overrideAccess).toBe(true); + }); + + it("mode='pseudonymize' with no matching docs does not call update", async () => { + const mockFind = vi.fn().mockResolvedValue({ docs: [] }); + const mockUpdate = vi.fn(); + const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate }); + const log = new PayloadAuditLog({} as never, mockGetPayload); + + await log.eraseSubject("unknown_user", "pseudonymize"); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core-audit/src/payload-audit-log.ts b/packages/core-audit/src/payload-audit-log.ts index e819909..ca64543 100644 --- a/packages/core-audit/src/payload-audit-log.ts +++ b/packages/core-audit/src/payload-audit-log.ts @@ -1,6 +1,7 @@ import type { SanitizedConfig } from "payload"; import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "./audit-log.interface"; +import { pseudonymize } from "./pseudonymize"; type GetPayload = (args: { config: SanitizedConfig }) => Promise<{ create: (args: { collection: string; data: Record }) => Promise; @@ -66,8 +67,37 @@ export class PayloadAuditLog implements IAuditLog { }); } - async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise { - // Implemented in Phase 3. - throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)"); + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + const payload = await this.getPayload({ config: this.config }); + + if (mode === "delete") { + await payload.delete({ + collection: "audit-logs", + where: { actorId: { equals: actorId } }, + overrideAccess: true, + }); + return; + } + + // mode === "pseudonymize" + // Fetch all matching docs. Limit is 10_000 — a subject with more than + // 10k audit entries will not have all entries pseudonymized in one call. + // This is an accepted v1 limitation; callers may loop if needed. + const { docs } = await payload.find({ + collection: "audit-logs", + where: { actorId: { equals: actorId } }, + limit: 10_000, + overrideAccess: true, + }); + + const pseudonym = pseudonymize(actorId); + for (const doc of docs) { + await payload.update({ + collection: "audit-logs", + id: doc.id, + data: { actorId: pseudonym }, + overrideAccess: true, + }); + } } }