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
792 lines
22 KiB
TypeScript
792 lines
22 KiB
TypeScript
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();
|
||
});
|
||
});
|