Files
agentic-dev/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts
Danijel Martinek f2f24f7bfa 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
2026-07-13 06:07:25 +02:00

792 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { SanitizedConfig } from "payload";
import type { IJobQueue } from "@/jobs/job-queue.interface";
import type { AuditLogProtocol } from "@/di/bind-protocols";
import {
parseDurationMs,
scheduleDelayMs,
buildPurgeHandler,
registerRetentionPurgeJobs,
type PayloadPurgeApi,
type RetentionPurgeJobDeps,
} from "./retention-purge.job";
// ---- test helpers ----
type MockCollection = {
slug: string;
custom?: { retention?: Record<string, unknown> };
fields?: Array<{ name?: string; custom?: { pii?: unknown } }>;
};
function makeConfig(collections: MockCollection[]): SanitizedConfig {
return { collections } as unknown as SanitizedConfig;
}
function makeQueue() {
const enqueue = vi.fn().mockResolvedValue({ jobId: "job-1" });
const queue = { enqueue } as unknown as IJobQueue;
return { queue, enqueue };
}
function makePayloadApi(
docs: Array<Record<string, unknown>> = [],
): PayloadPurgeApi {
return {
find: vi.fn().mockResolvedValue({ docs }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
}
function makeAuditLog(): {
auditLog: AuditLogProtocol;
record: ReturnType<typeof vi.fn>;
} {
const record = vi.fn().mockResolvedValue(undefined);
return { auditLog: { record } as AuditLogProtocol, record };
}
// ---- parseDurationMs ----
describe("parseDurationMs", () => {
it("parses years: P2Y → 2 × 365 days", () => {
expect(parseDurationMs("P2Y")).toBe(2 * 365 * 86_400_000);
});
it("parses months: P1M → 30 days", () => {
expect(parseDurationMs("P1M")).toBe(30 * 86_400_000);
});
it("parses weeks: P1W → 7 days", () => {
expect(parseDurationMs("P1W")).toBe(7 * 86_400_000);
});
it("parses days: P30D → 30 days", () => {
expect(parseDurationMs("P30D")).toBe(30 * 86_400_000);
});
it("combines components: P1Y2M3D", () => {
expect(parseDurationMs("P1Y2M3D")).toBe(
365 * 86_400_000 + 60 * 86_400_000 + 3 * 86_400_000,
);
});
it("returns 0 for P0D", () => {
expect(parseDurationMs("P0D")).toBe(0);
});
it("returns 0 for unrecognised strings", () => {
expect(parseDurationMs("invalid")).toBe(0);
expect(parseDurationMs("")).toBe(0);
expect(parseDurationMs("PT2H")).toBe(0);
});
});
// ---- scheduleDelayMs ----
describe("scheduleDelayMs", () => {
it("returns 1 day for 'daily'", () => {
expect(scheduleDelayMs("daily")).toBe(86_400_000);
});
it("returns 7 days for 'weekly'", () => {
expect(scheduleDelayMs("weekly")).toBe(7 * 86_400_000);
});
it("returns 30 days for 'monthly'", () => {
expect(scheduleDelayMs("monthly")).toBe(30 * 86_400_000);
});
it("falls back to 1 day for cron-style strings", () => {
expect(scheduleDelayMs("0 3 * * 0")).toBe(86_400_000);
});
});
// ---- registerRetentionPurgeJobs ----
describe("registerRetentionPurgeJobs", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("enqueues one job per collection with a purgeSchedule", async () => {
const { queue, enqueue } = makeQueue();
const config = makeConfig([
{ slug: "users", custom: { retention: { purgeSchedule: "daily" } } },
{ slug: "articles", custom: { retention: { purgeSchedule: "weekly" } } },
]);
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
expect(enqueue).toHaveBeenCalledTimes(2);
});
it("skips collections without a retention config", async () => {
const { queue, enqueue } = makeQueue();
const config = makeConfig([{ slug: "media" }]);
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
expect(enqueue).not.toHaveBeenCalled();
});
it("uses the correct taskSlug and runAt for each schedule type", async () => {
const { queue, enqueue } = makeQueue();
const config = makeConfig([
{ slug: "users", custom: { retention: { purgeSchedule: "weekly" } } },
]);
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
expect(enqueue).toHaveBeenCalledWith(
"retention-purge--users",
{},
{ runAt: new Date("2026-01-08T00:00:00.000Z") },
);
});
it("schedules daily purge 1 day from now", async () => {
const { queue, enqueue } = makeQueue();
const config = makeConfig([
{ slug: "sessions", custom: { retention: { purgeSchedule: "daily" } } },
]);
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
expect(enqueue).toHaveBeenCalledWith(
"retention-purge--sessions",
{},
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
);
});
});
// ---- buildPurgeHandler — input validation ----
describe("buildPurgeHandler — input validation", () => {
it("throws when the collection slug is not found in the config", () => {
const { queue } = makeQueue();
const config = makeConfig([]);
expect(() =>
buildPurgeHandler("missing", { queue, config, getPayload: vi.fn() }),
).toThrow("collection not found: missing");
});
it("throws when the collection has no retention config", () => {
const { queue } = makeQueue();
const config = makeConfig([{ slug: "media" }]);
expect(() =>
buildPurgeHandler("media", { queue, config, getPayload: vi.fn() }),
).toThrow("no retention config on collection: media");
});
});
// ---- buildPurgeHandler — hard-delete branch ----
describe("buildPurgeHandler — hard-delete", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("queries by createdAt for from-creation trigger", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([]);
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)();
expect(payload.find).toHaveBeenCalledWith(
expect.objectContaining({
where: {
createdAt: {
less_than: new Date(
Date.now() - parseDurationMs("P2Y"),
).toISOString(),
},
},
}),
);
});
it("queries by updatedAt for from-last-access trigger", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([]);
const config = makeConfig([
{
slug: "sessions",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P30D", trigger: "from-last-access" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("sessions", deps)();
expect(payload.find).toHaveBeenCalledWith(
expect.objectContaining({
where: { updatedAt: { less_than: expect.any(String) } },
}),
);
expect(payload.find).not.toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ createdAt: expect.anything() }),
}),
);
});
it("deletes each returned row", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-1" }, { id: "row-2" }]);
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(payload.delete).toHaveBeenCalledTimes(2);
expect(payload.delete).toHaveBeenCalledWith({
collection: "users",
id: "row-1",
overrideAccess: true,
});
expect(payload.delete).toHaveBeenCalledWith({
collection: "users",
id: "row-2",
overrideAccess: true,
});
expect(payload.update).not.toHaveBeenCalled();
});
it("re-enqueues itself for the next purge cycle", async () => {
const { queue, enqueue } = makeQueue();
const payload = makePayloadApi([]);
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(enqueue).toHaveBeenCalledOnce();
expect(enqueue).toHaveBeenCalledWith(
"retention-purge--users",
{},
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
);
});
it("defaults to hard-delete when postDeletion is not declared", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-x" }]);
const config = makeConfig([
{
slug: "logs",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("logs", deps)();
expect(payload.delete).toHaveBeenCalledWith({
collection: "logs",
id: "row-x",
overrideAccess: true,
});
expect(payload.update).not.toHaveBeenCalled();
});
});
// ---- 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", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("nulls only PII-annotated fields for each matched row", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-2" }]);
const config = makeConfig([
{
slug: "contacts",
custom: {
retention: {
purgeSchedule: "monthly",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "pseudonymize",
duration: "P30D",
trigger: "after-deletion",
},
},
},
fields: [
{ name: "email", custom: { pii: { category: "contact-email" } } },
{ name: "phone", custom: { pii: { category: "contact-phone" } } },
{ name: "status" }, // no pii — must NOT be nulled
],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("contacts", deps)();
expect(payload.update).toHaveBeenCalledWith({
collection: "contacts",
id: "row-2",
data: { email: null, phone: null },
overrideAccess: true,
});
expect(payload.delete).not.toHaveBeenCalled();
});
it("skips update when no PII fields are declared on the collection", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-3" }]);
const config = makeConfig([
{
slug: "tags",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "pseudonymize",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [
{ name: "label" }, // no pii
],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("tags", deps)();
expect(payload.update).not.toHaveBeenCalled();
expect(payload.delete).not.toHaveBeenCalled();
});
});
// ---- buildPurgeHandler — audit emission ----
describe("buildPurgeHandler — audit emission", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("emits one audit record per processed row", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "a" }, { id: "b" }]);
const { auditLog, record } = makeAuditLog();
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
auditLog,
};
await buildPurgeHandler("users", deps)();
expect(record).toHaveBeenCalledTimes(2);
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
actorId: "system",
actorType: "system",
action: "DELETE",
reason: "retention-policy",
outcome: "success",
from: { ipTruncated: "system", userAgent: "background-job" },
}),
);
});
it("includes resource type and id in the audit entry", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-42" }]);
const { auditLog, record } = makeAuditLog();
const config = makeConfig([
{
slug: "orders",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
auditLog,
};
await buildPurgeHandler("orders", deps)();
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: "orders", id: "row-42" },
}),
);
});
it("gracefully skips audit emission when auditLog is not provided", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "x" }]);
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await expect(buildPurgeHandler("users", deps)()).resolves.toBeUndefined();
});
it("skips all processing and audit when activeRetention is not declared", async () => {
const { queue, enqueue } = makeQueue();
const payload = makePayloadApi([{ id: "y" }]);
const { auditLog, record } = makeAuditLog();
const config = makeConfig([
{
slug: "logs",
custom: { retention: { purgeSchedule: "daily" } },
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
auditLog,
};
await buildPurgeHandler("logs", deps)();
expect(payload.find).not.toHaveBeenCalled();
expect(record).not.toHaveBeenCalled();
// Still re-enqueues for the next cycle
expect(enqueue).toHaveBeenCalledOnce();
});
});