fix(compliance): port DSR/consent/audit/retention audit fixes
Ports the upstream compliance-core audit fixes onto the kept core-dsr, core-consent, core-audit, core-cms and core-shared packages: - core-dsr: scope DSR operations to the caller's own subject (A11); include the subject's audit trail in exports; resolve the per-request binding from ctx instead of a throwing singleton proxy. - core-consent: build the consent router from the shared superjson transformer (A10); merge per-category on persist instead of replacing; validate migrated categories against an allow-list. - core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the audit-logs collection and the req-scoped GDPR audit-erasure afterDelete hook (A6). - core-shared: grace-purge soft-deleted rows via a retention-purge task + tombstone field and boot registration (A2/A3); add the require-authenticated tRPC helper; derive clientIp + resolve the session user in createTrpcContext (B2/A11). - core-cms: register audit-logs, wire the audit-erasure hook and retention-purge tasks; adapted to the clean-slate collection set (users only — no workspaces feature on this branch). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -377,6 +377,189 @@ describe("buildPurgeHandler — hard-delete", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — postDeletion grace purge (A2) ----
|
||||
|
||||
describe("buildPurgeHandler — postDeletion grace purge", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function postDeletionOnlyConfig(
|
||||
action: "hard-delete" | "pseudonymize",
|
||||
fields: MockCollection["fields"] = [],
|
||||
) {
|
||||
return makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
action,
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "users",
|
||||
where: {
|
||||
deletedAt: {
|
||||
less_than: new Date(
|
||||
Date.now() - parseDurationMs("P30D"),
|
||||
).toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days
|
||||
{ id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days
|
||||
{ id: "row-live" }, // never soft-deleted
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.delete).toHaveBeenCalledTimes(1);
|
||||
expect(payload.delete).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "row-old",
|
||||
overrideAccess: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("pseudonymize", [
|
||||
{ name: "email", custom: { pii: { category: "contact-email" } } },
|
||||
{ name: "status" },
|
||||
]),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.update).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "row-old",
|
||||
data: { email: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(payload.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a retention-policy audit entry per purged row", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const { auditLog, record } = makeAuditLog();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
auditLog,
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "DELETE",
|
||||
reason: "retention-policy",
|
||||
resource: { type: "users", id: "row-old" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still runs activeRetention alongside postDeletion", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P2Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
// one find per branch: createdAt (activeRetention) + deletedAt (postDeletion)
|
||||
expect(payload.find).toHaveBeenCalledTimes(2);
|
||||
// the fake returns the tombstoned row for both branches: the
|
||||
// activeRetention branch deletes it by date, the postDeletion branch by
|
||||
// tombstone — 2 delete calls for the same doc through different policies.
|
||||
expect(payload.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-enqueues the next cycle for postDeletion-only collections", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(enqueue).toHaveBeenCalledWith(
|
||||
"retention-purge--users",
|
||||
{},
|
||||
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — pseudonymize branch ----
|
||||
|
||||
describe("buildPurgeHandler — pseudonymize", () => {
|
||||
|
||||
Reference in New Issue
Block a user