From a857fd0264b8703f6fb64b9b9898a39d3441477f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 17:26:39 +0100 Subject: [PATCH] fix(backup-restore): a restored pre-wipe copy withdraws only its own wipe's purge Restoring the copy a data wipe took withdrew every held data_wiped purge of the account. With two wipes in one window, restoring the first copy also withdrew the second wipe's purge, and the off-host copies of what the person entered between the wipes, and wiped, stayed until the bucket's rule removed them. Only the request written after this copy and before the next one goes now. The withdrawal had no test; it has one against Postgres. --- src/lib/jobs/backup-restore.ts | 25 ++++++- .../account-deletion-grace.test.ts | 70 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/lib/jobs/backup-restore.ts b/src/lib/jobs/backup-restore.ts index 6c4e8e6ab..d5b3e1796 100644 --- a/src/lib/jobs/backup-restore.ts +++ b/src/lib/jobs/backup-restore.ts @@ -52,7 +52,10 @@ import { createHash } from "node:crypto"; import type { Job, PgBoss } from "pg-boss"; -import { isPreWipeBackupType } from "@/lib/account-deletion/pre-wipe-type"; +import { + isPreWipeBackupType, + PRE_WIPE_BACKUP_TYPE_PREFIX, +} from "@/lib/account-deletion/pre-wipe-type"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { prisma, toJson } from "@/lib/db"; @@ -139,14 +142,32 @@ async function withdrawHeldWipePurge( ): Promise { const backup = await prisma.dataBackup.findUnique({ where: { id: backupId }, - select: { type: true }, + select: { type: true, createdAt: true }, }); if (!backup || !isPreWipeBackupType(backup.type)) return; + // Only the wipe this copy was taken for: its request was written after the + // copy and before the copy of any later wipe. A later wipe's request stays, + // because the restore does not bring back what that wipe removed (it + // replaces the record with this copy), and its off-host copies hold data + // the person wiped and did not get back. + const nextCopy = await prisma.dataBackup.findFirst({ + where: { + userId, + type: { startsWith: PRE_WIPE_BACKUP_TYPE_PREFIX }, + createdAt: { gt: backup.createdAt }, + }, + orderBy: { createdAt: "asc" }, + select: { createdAt: true }, + }); await prisma.offhostPurgeRequest.deleteMany({ where: { subjectId: userId, reason: "data_wiped", notBefore: { gt: new Date() }, + requestedAt: { + gte: backup.createdAt, + ...(nextCopy ? { lt: nextCopy.createdAt } : {}), + }, }, }); } diff --git a/tests/integration/account-deletion-grace.test.ts b/tests/integration/account-deletion-grace.test.ts index f1cc5bb29..8e91fbc17 100644 --- a/tests/integration/account-deletion-grace.test.ts +++ b/tests/integration/account-deletion-grace.test.ts @@ -809,6 +809,76 @@ describe("delete all data with a grace window", () => { ); }); + // The off-host purge a wipe asks for is held to the end of the window. A + // restore of the copy taken for that wipe withdraws it (the account has + // that record again); a later wipe's purge stays, because the restore does + // not bring back what the later wipe removed. + it("withdraws the held off-host purge of the wipe a restore undoes, and only that one", async () => { + vi.stubEnv("BACKUP_S3_ENDPOINT", "https://bucket.invalid"); + vi.stubEnv("BACKUP_S3_BUCKET", "b"); + vi.stubEnv("BACKUP_S3_ACCESS_KEY", "a"); + vi.stubEnv("BACKUP_S3_SECRET_KEY", "s"); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", "e".repeat(64)); + const prisma = getPrismaClient(); + const user = await seedAccount("wiping-twice"); + await signIn(user.id); + const { DELETE } = await import("@/app/api/settings/data/route"); + const wipe = () => + DELETE( + jsonRequest("/api/settings/data", "DELETE", { confirm: "DELETE" }), + ); + + expect((await wipe()).status).toBe(200); + const [firstCopy] = await prisma.dataBackup.findMany({ + where: { userId: user.id }, + select: { id: true }, + }); + const firstRequest = await prisma.offhostPurgeRequest.findFirstOrThrow({ + where: { subjectId: user.id, reason: "data_wiped" }, + }); + expect(firstRequest.notBefore).not.toBeNull(); + + // Data that arrives between the wipes, and is wiped by the second one. + await prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 79, + unit: "kg", + measuredAt: new Date(), + source: "MANUAL", + }, + }); + expect((await wipe()).status).toBe(200); + const requests = await prisma.offhostPurgeRequest.findMany({ + where: { subjectId: user.id, reason: "data_wiped" }, + orderBy: { requestedAt: "asc" }, + }); + expect(requests).toHaveLength(2); + + const admin = await seedAccount("admin", "ADMIN"); + await signIn(admin.id); + const { POST } = await import("./restore-job-driver"); + const restored = await POST( + new Request( + `http://localhost/api/admin/backups/${firstCopy!.id}/restore`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }, + ) as unknown as Parameters[0], + { params: Promise.resolve({ id: firstCopy!.id }) }, + ); + expect(restored.status).toBe(200); + + const left = await prisma.offhostPurgeRequest.findMany({ + where: { subjectId: user.id, reason: "data_wiped" }, + select: { id: true }, + }); + expect(left.map((r) => r.id)).toEqual([requests[1]!.id]); + }); + it("takes no copy with no window, as before", async () => { vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "0"); const prisma = getPrismaClient();