Files
HealthLog/tests/integration/account-deletion-grace.test.ts
T
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

896 lines
31 KiB
TypeScript

/**
* Account deletion, data wipe and managed-profile deletion wait out a grace
* window, against real Postgres.
*
* Everything here goes through production code: the deletion and wipe
* routes, the sign-in route, the admin undo route, the restore route and job,
* the purge job, and the queries background passes enumerate accounts with.
* Mutations that must turn this red:
* - erase in the route again (the account row is gone after the request);
* - lock without revoking credentials (the session and token survive);
* - drop the pending check from the session resolver (the late session
* reads `/api/auth/me`);
* - drop the predicate from the off-host cohort or the pregenerate query
* (the pending account is enumerated);
* - let the wipe delete its own `PRE_WIPE_<ms>` copy (nothing to restore);
* - let the purge job erase an account that is not yet due.
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
const adminNotices = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(
async (opts: Record<string, unknown>) => {
adminNotices.push(opts);
},
),
}));
const { hashPassword } = await import("@/lib/auth/password");
const PASSWORD = "Correct horse battery staple!42";
const DAY_MS = 24 * 60 * 60 * 1000;
beforeEach(async () => {
const prisma = getPrismaClient();
adminNotices.length = 0;
await truncateAllTables(prisma);
await prisma.rateLimit.deleteMany();
cookieJar.clear();
headerJar.clear();
vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "14");
});
afterEach(() => {
vi.unstubAllEnvs();
});
function jsonRequest(path: string, method: string, body?: unknown) {
return new NextRequest(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
/** An account with a password, a small record and a stored weekly copy. */
async function seedAccount(username: string, role = "USER") {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role,
passwordHash: await hashPassword(PASSWORD),
onboardingCompletedAt: new Date(),
},
});
await prisma.measurement.createMany({
data: [0, 1, 2].map((i) => ({
userId: user.id,
type: "WEIGHT" as const,
value: 80 + i,
unit: "kg",
measuredAt: new Date(Date.UTC(2026, 8, 1 + i, 7)),
source: "MANUAL" as const,
})),
});
await prisma.medication.create({
data: { userId: user.id, name: "Ramipril", dose: "5 mg" },
});
await prisma.apiToken.create({
data: {
userId: user.id,
name: "connector",
tokenHash: `hash-${username}`,
permissions: ["*"],
},
});
await prisma.dataBackup.create({
data: { userId: user.id, type: "WEEKLY_AUTO", data: "sealed-copy" },
});
return user;
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
return session.id;
}
async function recordCounts(userId: string) {
const prisma = getPrismaClient();
const [measurements, medications, backups] = await Promise.all([
prisma.measurement.count({ where: { userId } }),
prisma.medication.count({ where: { userId } }),
prisma.dataBackup.count({ where: { userId } }),
]);
return { measurements, medications, backups };
}
async function deleteOwnAccount() {
const { DELETE } = await import("@/app/api/settings/account/route");
return DELETE(
jsonRequest("/api/settings/account", "DELETE", {
confirm: "DELETE_ACCOUNT",
}),
);
}
async function passwordLogin(email: string) {
const { POST } = await import("@/app/api/auth/login/route");
return POST(
jsonRequest("/api/auth/login", "POST", { email, password: PASSWORD }),
);
}
describe("account deletion with a grace window", () => {
it("locks the account, keeps the record, and an admin's undo gives it back", async () => {
const prisma = getPrismaClient();
const user = await seedAccount("leaving");
const before = await recordCounts(user.id);
await signIn(user.id);
const res = await deleteOwnAccount();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { deleted: boolean; scheduledPurgeAt: string | null };
};
expect(body.data.deleted).toBe(true);
const purgeAt = new Date(body.data.scheduledPurgeAt ?? 0);
expect(purgeAt.getTime() - Date.now()).toBeGreaterThan(13.9 * DAY_MS);
expect(purgeAt.getTime() - Date.now()).toBeLessThan(14.1 * DAY_MS);
// Locked: every credential revoked, the record and its copies untouched.
const row = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
expect(row.deletionRequestedAt).not.toBeNull();
expect(row.deletionPurgeAt?.toISOString()).toBe(purgeAt.toISOString());
expect(row.deletionRequestedById).toBe(user.id);
expect(await prisma.session.count({ where: { userId: user.id } })).toBe(0);
expect(
await prisma.apiToken.count({
where: { userId: user.id, revoked: false },
}),
).toBe(0);
expect(await recordCounts(user.id)).toEqual(before);
expect(await prisma.offhostPurgeRequest.count()).toBe(0);
// Sign-in is refused with the date, after the password was proved.
const refused = await passwordLogin(user.email!);
expect(refused.status).toBe(403);
const refusal = (await refused.json()) as {
meta: { errorCode: string; purgeAt: string };
};
expect(refusal.meta.errorCode).toBe("auth.account.deletion_pending");
expect(refusal.meta.purgeAt).toBe(purgeAt.toISOString());
expect(await prisma.session.count({ where: { userId: user.id } })).toBe(0);
// A wrong password learns nothing about the deletion.
const { POST } = await import("@/app/api/auth/login/route");
const wrong = await POST(
jsonRequest("/api/auth/login", "POST", {
email: user.email,
password: "not the password",
}),
);
expect(wrong.status).toBe(401);
// A session that slipped past the lock is not honoured.
await signIn(user.id);
const { GET: me } = await import("@/app/api/auth/me/route");
expect((await me()).status).toBe(401);
// An admin undoes it: nothing was changed, so nothing has to come back.
const admin = await seedAccount("admin", "ADMIN");
await signIn(admin.id);
const { POST: undo } =
await import("@/app/api/admin/users/[id]/undo-deletion/route");
const undone = await undo(
jsonRequest(`/api/admin/users/${user.id}/undo-deletion`, "POST"),
{ params: Promise.resolve({ id: user.id }) },
);
expect(undone.status).toBe(200);
const restored = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
});
expect(restored.deletionRequestedAt).toBeNull();
expect(restored.deletionPurgeAt).toBeNull();
expect(await recordCounts(user.id)).toEqual(before);
expect(
await prisma.auditLog.count({
where: { action: "admin.user.deletion.undo", userId: admin.id },
}),
).toBe(1);
expect(
await prisma.auditLog.count({
where: { action: "user.account.deletion_cancelled", userId: user.id },
}),
).toBe(1);
// A second undo has nothing to undo.
const again = await undo(
jsonRequest(`/api/admin/users/${user.id}/undo-deletion`, "POST"),
{ params: Promise.resolve({ id: user.id }) },
);
expect(again.status).toBe(409);
// And the person signs in again.
cookieJar.clear();
expect((await passwordLogin(user.email!)).status).toBe(200);
});
it("lists the pending account for admins with its date", async () => {
const user = await seedAccount("listed");
await signIn(user.id);
expect((await deleteOwnAccount()).status).toBe(200);
const admin = await seedAccount("admin", "ADMIN");
await signIn(admin.id);
const { GET } = await import("@/app/api/admin/users/route");
const res = await GET();
const { data } = (await res.json()) as {
data: Array<{ id: string; deletion: unknown }>;
};
const row = data.find((u) => u.id === user.id);
expect(row?.deletion).toMatchObject({ requestedBy: "self" });
expect(data.find((u) => u.id === admin.id)?.deletion).toBeNull();
});
it("keeps background passes away from the pending account", async () => {
const prisma = getPrismaClient();
const pending = await seedAccount("pending");
const active = await seedAccount("active");
await signIn(pending.id);
expect((await deleteOwnAccount()).status).toBe(200);
cookieJar.clear();
const { readOffhostCohort } =
await import("@/lib/jobs/offhost-backup-cohort");
const cohort = await readOffhostCohort(prisma, undefined);
expect(cohort.users.map((u) => u.id)).toEqual([active.id]);
const { findPregenerateCandidates } =
await import("@/lib/jobs/insight-pregenerate");
const candidates = await findPregenerateCandidates(prisma, new Date(), 50);
expect(candidates.map((c) => c.id)).toEqual([active.id]);
// The nightly score jobs: a cross-account `groupBy` the guard used to
// read as scoped because its `orderBy` names `userId`.
for (const userId of [pending.id, active.id]) {
await prisma.measurement.create({
data: {
userId,
type: "HEART_RATE_VARIABILITY",
value: 48,
unit: "ms",
measuredAt: new Date(Date.now() - 3_600_000),
source: "MANUAL",
},
});
}
const { findStressScoreCandidates } =
await import("@/lib/jobs/stress-score");
expect(await findStressScoreCandidates(prisma, new Date(), 50)).toEqual([
active.id,
]);
// A job queued for it before the deletion completes without running.
const { dropJobsOfPendingAccounts } =
await import("@/lib/jobs/pending-deletion-gate");
const queued = [
{ id: "j1", data: { userId: pending.id } },
{ id: "j2", data: { userId: active.id } },
];
expect(
(await dropJobsOfPendingAccounts(queued)).live.map((j) => j.id),
).toEqual(["j2"]);
// Nothing is delivered about or to it, whoever enumerated it.
const { resolveNotificationDeliveryIdentity } =
await import("@/lib/notifications/delivery-identity");
const payload = (userId: string) => ({
eventType: "MEDICATION_REMINDER" as const,
userId,
title: "t",
message: "m",
});
expect(
await resolveNotificationDeliveryIdentity(payload(pending.id)),
).toBeNull();
expect(
await resolveNotificationDeliveryIdentity(payload(active.id)),
).toMatchObject({ recordUserId: active.id });
// And no model work is admitted for it.
const { loadAiCapabilityInputs } =
await import("@/lib/ai/capabilities/load");
const { providerWorkAuthorityForRecord } =
await import("@/lib/sharing/provider-work-authority");
const inputsFor = (recordId: string) =>
loadAiCapabilityInputs({
recordId,
authority: providerWorkAuthorityForRecord(recordId),
sections: null,
recordKind: "self",
});
expect((await inputsFor(pending.id))?.providerWorkAdmitted).toBe(false);
expect((await inputsFor(active.id))?.providerWorkAdmitted).toBe(true);
});
it("erases the account when the window ends, and only then", async () => {
const prisma = getPrismaClient();
const due = await seedAccount("due");
const notYet = await seedAccount("not-yet");
for (const user of [due, notYet]) {
await signIn(user.id);
expect((await deleteOwnAccount()).status).toBe(200);
}
await prisma.user.update({
where: { id: due.id },
data: { deletionPurgeAt: new Date(Date.now() - 60_000) },
});
const { processDueAccountDeletions } =
await import("@/lib/jobs/account-deletion-purge");
const report = await processDueAccountDeletions(new Date());
expect(report).toMatchObject({ due: 1, purged: 1, refused: 0 });
expect(await prisma.user.findUnique({ where: { id: due.id } })).toBeNull();
expect(await recordCounts(due.id)).toEqual({
measurements: 0,
medications: 0,
backups: 0,
});
// The account's own audit history goes with it.
expect(await prisma.auditLog.count({ where: { userId: due.id } })).toBe(0);
const kept = await prisma.user.findUnique({ where: { id: notYet.id } });
expect(kept?.deletionRequestedAt).not.toBeNull();
expect((await recordCounts(notYet.id)).measurements).toBe(3);
});
it("erases at once with no window, as before", async () => {
vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "0");
const prisma = getPrismaClient();
const user = await seedAccount("immediate");
await signIn(user.id);
const res = await deleteOwnAccount();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { scheduledPurgeAt: string | null };
};
expect(body.data.scheduledPurgeAt).toBeNull();
expect(await prisma.user.findUnique({ where: { id: user.id } })).toBeNull();
expect((await recordCounts(user.id)).measurements).toBe(0);
});
it("does not let the last admin hide behind another admin pending deletion", async () => {
const prisma = getPrismaClient();
const first = await seedAccount("admin-one", "ADMIN");
const second = await seedAccount("admin-two", "ADMIN");
await signIn(first.id);
expect((await deleteOwnAccount()).status).toBe(200);
await signIn(second.id);
const res = await deleteOwnAccount();
expect(res.status).toBe(400);
const row = await prisma.user.findUniqueOrThrow({
where: { id: second.id },
});
expect(row.deletionRequestedAt).toBeNull();
});
});
describe("an erasure the Guardian invariant refuses", () => {
it("stays locked and tells every admin once", async () => {
const prisma = getPrismaClient();
const admin = await seedAccount("admin", "ADMIN");
const leaving = await seedAccount("guardian-leaving");
const staying = await seedAccount("guardian-staying");
const profile = await prisma.user.create({
data: {
username: "managed-kid",
managedProfileAt: new Date(),
timezone: "Europe/Berlin",
},
});
for (const guardian of [leaving, staying]) {
await prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: guardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
});
}
// Allowed while another guardian holds the profile.
await signIn(leaving.id);
expect((await deleteOwnAccount()).status).toBe(200);
// The other guardian leaves during the window.
await prisma.accountGrant.updateMany({
where: { granteeId: staying.id },
data: { revokedAt: new Date(), revokedBy: "GRANTEE" },
});
await prisma.user.update({
where: { id: leaving.id },
data: { deletionPurgeAt: new Date(Date.now() - 60_000) },
});
const { processDueAccountDeletions } =
await import("@/lib/jobs/account-deletion-purge");
expect(await processDueAccountDeletions(new Date())).toMatchObject({
purged: 0,
refused: 1,
});
// The next hourly run is refused again, and says nothing new.
expect(await processDueAccountDeletions(new Date())).toMatchObject({
refused: 1,
});
const row = await prisma.user.findUnique({ where: { id: leaving.id } });
expect(row?.deletionRequestedAt).not.toBeNull();
expect(adminNotices).toHaveLength(1);
expect(adminNotices[0]).toMatchObject({
userId: admin.id,
titleKey: "notifications.admin.accountErasureRefusedTitle",
params: { username: "guardian-leaving" },
});
});
});
describe("managed-profile deletion with a grace window", () => {
it("locks the profile away from its Guardian until an admin undoes it", async () => {
const prisma = getPrismaClient();
const guardian = await seedAccount("guardian");
const profile = await prisma.user.create({
data: {
username: "managed-child",
displayName: "Child",
managedProfileAt: new Date(),
timezone: "Europe/Berlin",
},
});
await prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: guardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
});
await prisma.measurement.create({
data: {
userId: profile.id,
type: "WEIGHT",
value: 21,
unit: "kg",
measuredAt: new Date(Date.UTC(2026, 8, 1, 7)),
source: "MANUAL",
},
});
const { deleteManagedProfile, readManagedProfileForGuardian } =
await import("@/lib/managed-profiles/lifecycle");
const { scheduledPurgeAt } = await deleteManagedProfile({
profileId: profile.id,
guardianId: guardian.id,
});
expect(scheduledPurgeAt).not.toBeNull();
const row = await prisma.user.findUniqueOrThrow({
where: { id: profile.id },
});
expect(row.deletionRequestedById).toBe(guardian.id);
expect(
await readManagedProfileForGuardian({
profileId: profile.id,
guardianId: guardian.id,
}),
).toBeNull();
expect(
await prisma.measurement.count({ where: { userId: profile.id } }),
).toBe(1);
// A second deletion of a profile already pending is refused.
await expect(
deleteManagedProfile({ profileId: profile.id, guardianId: guardian.id }),
).rejects.toMatchObject({ code: "not_found" });
const admin = await seedAccount("admin", "ADMIN");
const { cancelAccountDeletion } =
await import("@/lib/account-deletion/lifecycle");
expect(
await cancelAccountDeletion({ userId: profile.id, adminId: admin.id }),
).toBe("cancelled");
expect(
await readManagedProfileForGuardian({
profileId: profile.id,
guardianId: guardian.id,
}),
).toMatchObject({ id: profile.id });
expect(
await prisma.measurement.count({ where: { userId: profile.id } }),
).toBe(1);
});
});
/** A managed profile with the given Guardians, each grant accepted. */
async function profileGuardedBy(username: string, guardianIds: string[]) {
const prisma = getPrismaClient();
const profile = await prisma.user.create({
data: {
username,
displayName: "Kid",
managedProfileAt: new Date(),
timezone: "Europe/Berlin",
},
});
for (const granteeId of guardianIds) {
await prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId,
access: "MANAGE",
acceptedAt: new Date(),
},
});
}
return profile;
}
describe("a handover link and a profile pending deletion", () => {
it("revokes the open link with the deletion and refuses every way to claim the profile", async () => {
const prisma = getPrismaClient();
const guardian = await seedAccount("guardian");
const profile = await profileGuardedBy("managed-teen", [guardian.id]);
const { createHandover, previewHandover, claimManagedProfile } =
await import("@/lib/managed-profiles/handover");
const { deleteManagedProfile } =
await import("@/lib/managed-profiles/lifecycle");
const { token } = await createHandover({
profileId: profile.id,
guardianId: guardian.id,
expiresInDays: 7,
proposals: [],
});
expect(await previewHandover(token)).not.toBeNull();
await deleteManagedProfile({
profileId: profile.id,
guardianId: guardian.id,
});
const link = await prisma.managedProfileHandover.findFirstOrThrow({
where: { profileId: profile.id },
});
expect(link.revokedAt).not.toBeNull();
expect(await previewHandover(token)).toBeNull();
// A link the deletion did not reach (one that was reopened by hand here)
// is still refused: the claim decides on the profile under its lock.
await prisma.managedProfileHandover.update({
where: { id: link.id },
data: { revokedAt: null },
});
expect(await previewHandover(token)).toBeNull();
await expect(
claimManagedProfile({
link: { rawToken: token },
username: "teen-now-adult",
email: "teen@example.test",
passwordHash: "argon2-hash-placeholder",
}),
).rejects.toMatchObject({ name: "HandoverError" });
const row = await prisma.user.findUniqueOrThrow({
where: { id: profile.id },
});
expect(row.managedProfileAt).not.toBeNull();
expect(row.passwordHash).toBeNull();
});
it("revokes the links a Guardian minted when the Guardian deletes their account", async () => {
const leaving = await seedAccount("guardian-leaving");
const staying = await seedAccount("guardian-staying");
const profile = await profileGuardedBy("managed-kid", [
leaving.id,
staying.id,
]);
const { createHandover, previewHandover } =
await import("@/lib/managed-profiles/handover");
const { token } = await createHandover({
profileId: profile.id,
guardianId: leaving.id,
expiresInDays: 7,
proposals: [],
});
await signIn(leaving.id);
expect((await deleteOwnAccount()).status).toBe(200);
expect(await previewHandover(token)).toBeNull();
});
});
describe("the last-Guardian rule and accounts pending deletion", () => {
it("lets a Guardian whose only profile is pending deletion delete their own account", async () => {
const guardian = await seedAccount("guardian");
const profile = await profileGuardedBy("managed-kid", [guardian.id]);
const { deleteManagedProfile } =
await import("@/lib/managed-profiles/lifecycle");
await deleteManagedProfile({
profileId: profile.id,
guardianId: guardian.id,
});
await signIn(guardian.id);
expect((await deleteOwnAccount()).status).toBe(200);
});
it("does not let the second of two Guardians leave on the strength of the first, pending deletion", async () => {
const prisma = getPrismaClient();
const first = await seedAccount("guardian-first");
const second = await seedAccount("guardian-second");
const profile = await profileGuardedBy("managed-kid", [
first.id,
second.id,
]);
await signIn(first.id);
expect((await deleteOwnAccount()).status).toBe(200);
await signIn(second.id);
const refused = await deleteOwnAccount();
expect(refused.status).toBe(409);
expect(
(await prisma.user.findUniqueOrThrow({ where: { id: second.id } }))
.deletionRequestedAt,
).toBeNull();
// Nor through a data wipe, which ends the second Guardian's grant.
const { protectManagedProfilesDuringDataWipe } =
await import("@/lib/managed-profiles/lifecycle");
await expect(
prisma.$transaction((tx) =>
protectManagedProfilesDuringDataWipe(tx, second.id),
),
).rejects.toMatchObject({ name: "LastManagedGuardianError" });
expect(
await prisma.accountGrant.count({ where: { grantorId: profile.id } }),
).toBe(2);
});
it("does not give back a managed profile whose Guardians left during the window", async () => {
const guardian = await seedAccount("guardian");
const profile = await profileGuardedBy("managed-kid", [guardian.id]);
const { deleteManagedProfile } =
await import("@/lib/managed-profiles/lifecycle");
await deleteManagedProfile({
profileId: profile.id,
guardianId: guardian.id,
});
await signIn(guardian.id);
expect((await deleteOwnAccount()).status).toBe(200);
const admin = await seedAccount("admin", "ADMIN");
const { cancelAccountDeletion } =
await import("@/lib/account-deletion/lifecycle");
expect(
await cancelAccountDeletion({ userId: profile.id, adminId: admin.id }),
).toBe("no_guardian");
// The Guardian's own deletion undone first: the profile can come back.
expect(
await cancelAccountDeletion({ userId: guardian.id, adminId: admin.id }),
).toBe("cancelled");
expect(
await cancelAccountDeletion({ userId: profile.id, adminId: admin.id }),
).toBe("cancelled");
});
});
describe("the last admin and admins pending deletion", () => {
it("does not let the last usable admin step down while another is pending deletion", async () => {
const prisma = getPrismaClient();
const first = await seedAccount("admin-one", "ADMIN");
const second = await seedAccount("admin-two", "ADMIN");
await signIn(first.id);
expect((await deleteOwnAccount()).status).toBe(200);
await signIn(second.id);
const { PUT } = await import("@/app/api/admin/users/[id]/route");
const res = await PUT(
jsonRequest(`/api/admin/users/${second.id}`, "PUT", { role: "USER" }),
{ params: Promise.resolve({ id: second.id }) },
);
expect(res.status).toBe(400);
expect(
(await prisma.user.findUniqueOrThrow({ where: { id: second.id } })).role,
).toBe("ADMIN");
});
});
describe("delete all data with a grace window", () => {
it("keeps a copy of the record that an admin can restore", async () => {
const prisma = getPrismaClient();
const user = await seedAccount("wiping");
const before = await recordCounts(user.id);
await signIn(user.id);
const { DELETE } = await import("@/app/api/settings/data/route");
const res = await DELETE(
jsonRequest("/api/settings/data", "DELETE", { confirm: "DELETE" }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { restorableUntil: string | null };
};
expect(body.data.restorableUntil).not.toBeNull();
// The record is gone; the copy taken first is not, and the weekly copy
// went with the record as before.
expect((await recordCounts(user.id)).measurements).toBe(0);
const copies = await prisma.dataBackup.findMany({
where: { userId: user.id },
select: { id: true, type: true },
});
expect(copies).toHaveLength(1);
expect(copies[0]!.type).toMatch(/^PRE_WIPE_\d+$/);
// A second wipe keeps the first copy beside its own.
const second = await DELETE(
jsonRequest("/api/settings/data", "DELETE", { confirm: "DELETE" }),
);
expect(second.status).toBe(200);
expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe(
2,
);
// The ordinary restore brings the record back from the first copy.
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/${copies[0]!.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: copies[0]!.id }) },
);
expect(restored.status).toBe(200);
const after = await recordCounts(user.id);
expect(after.measurements).toBe(before.measurements);
expect(after.medications).toBe(before.medications);
// When the window ends, the purge job deletes the copies.
await prisma.dataBackup.updateMany({
where: { userId: user.id },
data: { createdAt: new Date(Date.now() - 15 * DAY_MS) },
});
const { processDueAccountDeletions } =
await import("@/lib/jobs/account-deletion-purge");
const report = await processDueAccountDeletions(new Date());
expect(report.preWipeCopiesDeleted).toBe(2);
expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe(
0,
);
expect((await recordCounts(user.id)).measurements).toBe(
before.measurements,
);
});
// 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();
const user = await seedAccount("wiping-now");
await signIn(user.id);
const { DELETE } = await import("@/app/api/settings/data/route");
const res = await DELETE(
jsonRequest("/api/settings/data", "DELETE", { confirm: "DELETE" }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { restorableUntil: string | null };
};
expect(body.data.restorableUntil).toBeNull();
expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe(
0,
);
});
});