The audit-logs collection was never registered (record() would throw), bindAudit/createAuditErasureHook were unused, and DSR cascade-hard never touched the audit trail (audit finding A6). core-cms now registers the collection and wires a req-scoped afterDelete erasure hook on users; bindAllProduction binds the audit log into consent/DSR; cascade-hard pseudonymizes the subject's audit entries; the action select accepts the full AuditAction enum so consent/DSR entries pass validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
createAuditErasureHook,
|
|
createReqScopedAuditErasureHook,
|
|
} from "./audit-erasure-hook";
|
|
import type { IAuditLog } from "../audit-log.interface";
|
|
|
|
function makeAuditLog(): IAuditLog {
|
|
return {
|
|
record: vi.fn().mockResolvedValue(undefined),
|
|
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
/** Minimal CollectionAfterDeleteHook args shape (only `doc` matters here). */
|
|
function hookArgs(id: unknown) {
|
|
return {
|
|
doc: { id },
|
|
req: {} as never,
|
|
id: String(id),
|
|
collection: {} as never,
|
|
context: {},
|
|
};
|
|
}
|
|
|
|
describe("createAuditErasureHook", () => {
|
|
it("defaults to 'pseudonymize' mode", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog });
|
|
await hook(hookArgs("user_1") as never);
|
|
expect(auditLog.eraseSubject).toHaveBeenCalledWith(
|
|
"user_1",
|
|
"pseudonymize",
|
|
);
|
|
});
|
|
|
|
it("respects explicit mode='delete'", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog, mode: "delete" });
|
|
await hook(hookArgs("user_2") as never);
|
|
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete");
|
|
});
|
|
|
|
it("coerces numeric id to string", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog });
|
|
await hook(hookArgs(42) as never);
|
|
expect(auditLog.eraseSubject).toHaveBeenCalledWith("42", "pseudonymize");
|
|
});
|
|
|
|
it("skips when doc.id is undefined", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog });
|
|
await hook(hookArgs(undefined) as never);
|
|
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips when doc.id is null", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog });
|
|
await hook(hookArgs(null) as never);
|
|
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips when doc.id is an object", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog });
|
|
await hook(hookArgs({ nested: true }) as never);
|
|
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("createReqScopedAuditErasureHook (A6)", () => {
|
|
function makeReqPayload(withAuditCollection: boolean) {
|
|
const find = vi.fn().mockResolvedValue({ docs: [{ id: "log-1" }] });
|
|
const update = vi.fn().mockResolvedValue({});
|
|
const del = vi.fn().mockResolvedValue({});
|
|
const payload = {
|
|
config: {
|
|
collections: withAuditCollection ? [{ slug: "audit-logs" }] : [],
|
|
},
|
|
find,
|
|
update,
|
|
delete: del,
|
|
};
|
|
return { payload, find, update, del };
|
|
}
|
|
|
|
function reqHookArgs(id: unknown, payload: unknown) {
|
|
return {
|
|
doc: { id },
|
|
req: { payload } as never,
|
|
id: String(id),
|
|
collection: {} as never,
|
|
context: {},
|
|
};
|
|
}
|
|
|
|
it("pseudonymizes the deleted subject's audit entries via req.payload", async () => {
|
|
const { payload, find, update } = makeReqPayload(true);
|
|
const hook = createReqScopedAuditErasureHook();
|
|
await hook(reqHookArgs("user_1", payload) as never);
|
|
|
|
expect(find).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
collection: "audit-logs",
|
|
where: { actorId: { equals: "user_1" } },
|
|
}),
|
|
);
|
|
expect(update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
collection: "audit-logs",
|
|
id: "log-1",
|
|
data: { actorId: expect.stringMatching(/^erased-/) },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("respects mode='delete'", async () => {
|
|
const { payload, del } = makeReqPayload(true);
|
|
const hook = createReqScopedAuditErasureHook({ mode: "delete" });
|
|
await hook(reqHookArgs("user_2", payload) as never);
|
|
expect(del).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
collection: "audit-logs",
|
|
where: { actorId: { equals: "user_2" } },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("no-ops when the audit-logs collection is not registered", async () => {
|
|
const { payload, find, update, del } = makeReqPayload(false);
|
|
const hook = createReqScopedAuditErasureHook();
|
|
await hook(reqHookArgs("user_1", payload) as never);
|
|
expect(find).not.toHaveBeenCalled();
|
|
expect(update).not.toHaveBeenCalled();
|
|
expect(del).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips invalid doc ids", async () => {
|
|
const { payload, find } = makeReqPayload(true);
|
|
const hook = createReqScopedAuditErasureHook();
|
|
await hook(reqHookArgs(undefined, payload) as never);
|
|
expect(find).not.toHaveBeenCalled();
|
|
});
|
|
});
|