import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { PayloadAuditLog } from "./payload-audit-log"; import type { AuditEntry } from "@repo/core-shared/audit"; const sample: AuditEntry = { actorId: "user_1", actorType: "user", actorRoles: ["admin"], action: "UPDATE", resource: { type: "articles", id: "abc" }, changedFields: ["title", "body"], at: new Date("2026-05-11T10:00:00.000Z"), scope: { feature: "blog", environment: "production", tenant: "default" }, from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" }, containsPii: false, outcome: "success", }; describe("PayloadAuditLog.record", () => { it("maps AuditEntry → flat collection doc + calls payload.create", async () => { const mockCreate = vi.fn().mockResolvedValue({ id: "doc_1" }); const mockGetPayload = vi.fn().mockResolvedValue({ create: mockCreate }); const log = new PayloadAuditLog({} as never, mockGetPayload); await log.record(sample); expect(mockCreate).toHaveBeenCalledOnce(); const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record }; expect(call.collection).toBe("audit-logs"); expect(call.data.actorId).toBe("user_1"); expect(call.data.action).toBe("UPDATE"); expect(call.data.resourceType).toBe("articles"); expect(call.data.resourceId).toBe("abc"); expect(call.data.changedFields).toEqual(["title", "body"]); expect(call.data.scopeFeature).toBe("blog"); expect(call.data.scopeTenant).toBe("default"); expect(call.data.ipTruncated).toBe("10.0.0.0"); expect(call.data.containsPii).toBe(false); 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(); }); });