mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
This commit is contained in:
1 parent
2bb030b656
commit
a857fd0264
2 files changed
+93
-2
No files matched your search
@@ -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<void> {
|
||||
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 } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof POST>[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();
|
||||
|
||||
Reference in new issue
Block a user