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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 17:51:13 +01:00
1 parent e6c709876d
commit 42940c3a98
6 files changed
+29 -2

No files matched your search

@@ -376,7 +376,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
expiresAt: null,
lastUsedAt: null,
user: { deletionRequestedAt: null },
} as Awaited<ReturnType<typeof prisma.apiToken.findUnique>>);
} as never);
}
it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => {
@@ -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" })]);
});
});
+8
View File
@@ -65,6 +65,14 @@ export interface JobSpec<J extends Job<unknown>> {
* 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<BatchHandler<J>>;
}
+2
View File
@@ -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.
+1
View File
@@ -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<Job<unknown>> = 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 });
@@ -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", [