Replaces the Phase-2 stub with a real impl. Mode "delete" issues a bulk payload.delete with overrideAccess:true to bypass the append-only rule. Mode "pseudonymize" fetches up to 10_000 matching docs and patches each actorId to the token produced by pseudonymize(). Adds 3 eraseSubject unit tests to the existing payload-audit-log test file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
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<string, unknown> };
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>; 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();
|
|
});
|
|
});
|