feat(core-shared): grace-purge soft-deleted rows + boot registration
The retention purge job gated its whole body on activeRetention while every collection declares only postDeletion, and no app ever called registerRetentionPurgeJobs — retention was dead end to end (audit findings A2 + A3). The DSR soft delete now stamps a deletedAt tombstone on postDeletion collections (kept distinct from processingRestrictedAt so an Art. 18 restriction never feeds the purge), the job grace-purges tombstoned rows past postDeletion.duration with the declared action, core-cms injects the tombstone field + Payload task definitions, and bindAllProduction enqueues the first purge cycle at boot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
// without a pseudonym salt (by design). Provide one for the whole suite.
|
||||
process.env.AUDIT_PSEUDONYM_SALT ??= "test-salt-not-for-production";
|
||||
|
||||
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
||||
// Hoisted so the payload mock and assertions share the same jobs.queue spy.
|
||||
const { jobsQueueMock } = vi.hoisted(() => ({
|
||||
jobsQueueMock: vi.fn(async () => ({ id: "job-1" })),
|
||||
}));
|
||||
|
||||
vi.mock("@repo/core-cms", () => ({
|
||||
default: Promise.resolve({
|
||||
collections: [
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
{ slug: "pages", fields: [] },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
||||
getPayload: vi.fn(async () => ({ jobs: { queue: jobsQueueMock } })),
|
||||
}));
|
||||
vi.mock("@repo/blog/di/bind-production", () => ({
|
||||
bindProductionBlog: vi.fn(),
|
||||
@@ -73,6 +98,17 @@ describe("bindAllProduction", () => {
|
||||
expect(bindProductionMedia).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("registers retention purge jobs at production boot (A3)", async () => {
|
||||
const { bindAllProduction } = await import("./bind-production");
|
||||
await bindAllProduction();
|
||||
|
||||
// one enqueue per collection declaring custom.retention.purgeSchedule
|
||||
expect(jobsQueueMock).toHaveBeenCalledTimes(1);
|
||||
expect(jobsQueueMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ task: "retention-purge--users" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent via bindAll — second call does not re-bind", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
NoopRateLimit,
|
||||
type RateLimitBudget,
|
||||
} from "@repo/core-shared/rate-limit";
|
||||
import {
|
||||
registerRetentionPurgeJobs,
|
||||
type GetPayloadFn,
|
||||
} from "@repo/core-shared/payload";
|
||||
import { bindAudit, type IAuditLog } from "@repo/core-audit";
|
||||
import {
|
||||
bindProductionConsent,
|
||||
@@ -192,6 +196,17 @@ export async function bindAllProduction(): Promise<void> {
|
||||
bindProductionMarketingPages(ctx);
|
||||
bindProductionNavigation(ctx);
|
||||
bindProductionMedia(ctx);
|
||||
|
||||
// Kick off the retention purge cycle (audit finding A3): enqueue the first
|
||||
// `retention-purge--<slug>` job for every collection declaring a
|
||||
// custom.retention.purgeSchedule. The task definitions live in the Payload
|
||||
// config (core-cms jobs.tasks); each run re-enqueues the next cycle.
|
||||
await registerRetentionPurgeJobs({
|
||||
queue,
|
||||
config: resolvedConfig,
|
||||
getPayload: getPayload as unknown as GetPayloadFn,
|
||||
auditLog,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user