From 42940c3a9827492b5ed69eea4ac0961bf0057705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 17:51:06 +0100 Subject: [PATCH] fix(jobs): the encryption backfill still runs for an account pending deletion The per-account free-text encryption backfill seals what is already stored and changes nothing the record holds, which is why its module is exempt from the frozen-account rule; the new pending-deletion gate dropped its per-user jobs anyway. A queue can now say it acts for pending accounts (actsForPendingAccounts), and this one does. Also types the ingest route test's token fixture to the row it now reads, and drops an unused variable from the deletion grace test. --- .../ingest/medication/__tests__/route.test.ts | 2 +- .../__tests__/pending-deletion-gate.test.ts | 17 +++++++++++++++++ src/lib/jobs/registry/define-job.ts | 8 ++++++++ src/lib/jobs/registry/maintenance.ts | 2 ++ src/lib/jobs/registry/register.ts | 1 + .../integration/account-deletion-grace.test.ts | 1 - 6 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index c1fa13ee5..0ab967c9d 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -376,7 +376,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. expiresAt: null, lastUsedAt: null, user: { deletionRequestedAt: null }, - } as Awaited>); + } as never); } it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => { diff --git a/src/lib/jobs/__tests__/pending-deletion-gate.test.ts b/src/lib/jobs/__tests__/pending-deletion-gate.test.ts index 83b38d5de..51f6d2c49 100644 --- a/src/lib/jobs/__tests__/pending-deletion-gate.test.ts +++ b/src/lib/jobs/__tests__/pending-deletion-gate.test.ts @@ -76,4 +76,21 @@ describe("the pending-deletion gate on queued jobs", () => { expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith([job("3", { userId: "u" })]); }); + + it("lets a queue that only changes how rows are stored run for it", async () => { + pending.clear(); + pending.add("frozen"); + const handler = vi.fn(async () => ({ ok: true as const, did: {} })); + const bound = bindableHandler( + { + queue: "free-text-encryption-backfill", + work: {}, + actsForPendingAccounts: true, + load: async () => handler, + } as never, + { boss: {} as PgBoss }, + ); + await bound([job("1", { userId: "frozen" })]); + expect(handler).toHaveBeenCalledWith([job("1", { userId: "frozen" })]); + }); }); diff --git a/src/lib/jobs/registry/define-job.ts b/src/lib/jobs/registry/define-job.ts index acaa67254..1e31e990a 100644 --- a/src/lib/jobs/registry/define-job.ts +++ b/src/lib/jobs/registry/define-job.ts @@ -65,6 +65,14 @@ export interface JobSpec> { * filter of the jobs that are heavy on a queue that also carries light ones. */ readonly lane?: true | ((job: J) => boolean); + /** + * The queue's per-account jobs run for an account pending deletion too. + * Without it a job whose payload names such an account is completed + * without running (`pending-deletion-gate`). Only for work that changes + * how rows are stored and not what the record holds (an encryption + * backfill), which is done or erased the same either way. + */ + readonly actsForPendingAccounts?: true; /** Loads the handler that drains the queue. */ readonly load: (ctx: JobContext) => Promise>; } diff --git a/src/lib/jobs/registry/maintenance.ts b/src/lib/jobs/registry/maintenance.ts index e56de8081..587123022 100644 --- a/src/lib/jobs/registry/maintenance.ts +++ b/src/lib/jobs/registry/maintenance.ts @@ -660,6 +660,8 @@ const jobs: readonly JobDefinition[] = [ "Per-user encryption backfill (Coach title, custom-metric note, practitioner phone + address, workout GPS track); discovery drops the user once no readable value remains.", ), work: { localConcurrency: FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY }, + // Seals what is already stored; see the module's account-deletion note. + actsForPendingAccounts: true, load: async () => (await encryption()).handleFreeTextEncryptionBackfill, }), // Physical purge of documents past the 30-day undo grace. diff --git a/src/lib/jobs/registry/register.ts b/src/lib/jobs/registry/register.ts index 412495a56..6e162bbd6 100644 --- a/src/lib/jobs/registry/register.ts +++ b/src/lib/jobs/registry/register.ts @@ -87,6 +87,7 @@ export function bindableHandler( // A job for an account pending deletion completes without running: it // was queued before the account was frozen (see `pending-deletion-gate`). let handler: BatchHandler> = async (jobs) => { + if (definition.actsForPendingAccounts) return (await load())(jobs); const { live, dropped } = await dropJobsOfPendingAccounts(jobs); if (live.length === 0) { return jobDone({ jobs_skipped_deletion_pending: dropped }); diff --git a/tests/integration/account-deletion-grace.test.ts b/tests/integration/account-deletion-grace.test.ts index 8e91fbc17..410474e40 100644 --- a/tests/integration/account-deletion-grace.test.ts +++ b/tests/integration/account-deletion-grace.test.ts @@ -621,7 +621,6 @@ describe("a handover link and a profile pending deletion", () => { }); it("revokes the links a Guardian minted when the Guardian deletes their account", async () => { - const prisma = getPrismaClient(); const leaving = await seedAccount("guardian-leaving"); const staying = await seedAccount("guardian-staying"); const profile = await profileGuardedBy("managed-kid", [