From 648810745a94ff2d9027b4db4d661c910146e8b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 21:14:25 +0100 Subject: [PATCH] fix(backup): let an account fresh from setup take an erased account's copy The empty-account check counted every backed-up model, and signing up and answering the setup questions always leaves a setup row, plus a cycle profile when the cycle area is chosen. The account of someone who has signed up again, the case the restore target is for, was therefore always refused. Those two models are one row per account and are deleted and re-created from the copy by every restore, so they no longer count. A reading or medication entered on setup's last screen still refuses the account, because the restore would delete it. A guard holds the exempt models to that shape, and the erased-account integration file now drives the real setup routes before the restore. --- docs/ops/backup-restore.md | 7 +- .../__tests__/restore-target-models.test.ts | 37 ++++- src/lib/export/restore-target.ts | 36 ++++- .../backup-erased-account-restore.test.ts | 128 ++++++++++++++++++ 4 files changed, 203 insertions(+), 5 deletions(-) diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 2ab5215f3..6f3e1baaa 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -108,7 +108,12 @@ exists, and the console then asks where the records should go: account and type its username to confirm. The account must hold no records yet: a restore replaces an account's records and does not merge them, so an account with even one record of any kind a copy carries is refused, with the - kinds named. Managed profiles and accounts pending deletion are not offered. + kinds named. What signing up and the setup questions leave behind does not + count (the setup answers and the cycle settings): the restore replaces both + with the copy's. A reading or medication entered on setup's last screen is a + record, and the account is refused until it is deleted, because the restore + would delete it too. Managed profiles and accounts pending deletion are not + offered. The file is then uploaded a second time, and its owner is changed to the chosen account as it is read. Nothing else in the file changes. The stored copy is diff --git a/src/lib/export/__tests__/restore-target-models.test.ts b/src/lib/export/__tests__/restore-target-models.test.ts index f6bfffaaf..c7e1a25f2 100644 --- a/src/lib/export/__tests__/restore-target-models.test.ts +++ b/src/lib/export/__tests__/restore-target-models.test.ts @@ -7,17 +7,26 @@ * * Mutation check: drop a model from `BACKED_UP_CHILD_MODELS` and the first * case names it (no `userId`); add an owned model to it and the second does. + * Add a model with many rows per account (`Measurement`) to the setup list + * and the third does. */ import { describe, expect, it } from "vitest"; -import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape"; +import { readFileSync } from "node:fs"; + +import { + parseWipeSchema, + WIPE_SCHEMA_PATH, +} from "@/__tests__/helpers/wipe-schema-shape"; import { BACKED_UP_MODELS } from "@/lib/export/backup-plan"; import { BACKED_UP_CHILD_MODELS, RESTORE_TARGET_OWNED_MODELS, + RESTORE_TARGET_SETUP_MODELS, } from "@/lib/export/restore-target"; const models = parseWipeSchema(); +const SCHEMA = readFileSync(WIPE_SCHEMA_PATH, "utf8"); describe("restore target models", () => { it("reads every owned backed-up model by its userId", () => { @@ -42,4 +51,30 @@ describe("restore target models", () => { expect(RESTORE_TARGET_OWNED_MODELS, parent).toContain(parent); } }); + + it("leaves out only one-row-per-account setup state that no other backed-up model hangs off", () => { + const setup = Object.keys(RESTORE_TARGET_SETUP_MODELS); + expect(setup.sort()).toEqual(["CycleProfile", "OnboardingRecord"]); + for (const model of setup) { + expect(RESTORE_TARGET_OWNED_MODELS, model).toContain(model); + // One row per account: a list of things the person recorded is not + // setup state. + const body = SCHEMA.match( + new RegExp(`^model ${model} \\{([\\s\\S]*?)^\\}`, "m"), + )?.[1]; + expect( + /^\s*userId\s+String\s+@unique/m.test(body ?? "") || + /@@unique\(\[userId\]\)/.test(body ?? ""), + model, + ).toBe(true); + // Nothing backed up hangs off it, so leaving it out of the check + // leaves no record unchecked behind it. + for (const other of BACKED_UP_MODELS) { + expect( + models.get(other)?.relations.some((r) => r.target === model), + `${other} -> ${model}`, + ).not.toBe(true); + } + } + }); }); diff --git a/src/lib/export/restore-target.ts b/src/lib/export/restore-target.ts index 3b5d4a33b..4f14be052 100644 --- a/src/lib/export/restore-target.ts +++ b/src/lib/export/restore-target.ts @@ -8,7 +8,11 @@ * that account holds none of those rows, and the check is exactly that: a row * of any model the backup carries ({@link BACKED_UP_MODELS}) under the * account refuses it. Everything a restore leaves alone (credentials, - * sessions, devices, grants) does not count. + * sessions, devices, grants) does not count, and neither does the setup state + * signing up leaves behind ({@link RESTORE_TARGET_SETUP_MODELS}): the person + * did not enter it as a record, and the restore replaces it with the copy's. + * A reading or a medication entered during setup is a record like any other + * and still refuses, because the restore would delete it. * * The other two refusals keep the account rules intact. A managed profile is * its Guardians' record, which an admin does not write into (the users list @@ -47,6 +51,29 @@ export const BACKED_UP_CHILD_MODELS: Readonly> = { export const RESTORE_TARGET_OWNED_MODELS: readonly string[] = BACKED_UP_MODELS.filter((model) => !(model in BACKED_UP_CHILD_MODELS)); +/** + * The backed-up models an account holds a row of after signing up and going + * through setup, before anything has been recorded, each with the writer. + * One row per account, settings-shaped, and deleted and re-created from the + * copy by every restore, so the copy's state replaces it and nothing the + * person entered is lost. Without this list the account of someone who has + * signed up again was always refused, which is the case the target exists + * for. `restore-target-models.test.ts` holds each to that shape. + */ +export const RESTORE_TARGET_SETUP_MODELS: Readonly> = { + // `PATCH /api/onboarding/answers` and `POST /api/onboarding/complete`. + OnboardingRecord: "the setup answers", + // Created by the setup confirm when the cycle area is chosen, and by the + // first read of the cycle settings. + CycleProfile: "the cycle settings", +}; + +/** The models whose rows make an account a refused restore target. */ +const RESTORE_TARGET_CHECKED_MODELS: readonly string[] = + RESTORE_TARGET_OWNED_MODELS.filter( + (model) => !(model in RESTORE_TARGET_SETUP_MODELS), + ); + interface OwnedDelegate { findFirst(args: { where: { userId: string }; @@ -65,13 +92,16 @@ function ownedDelegate(db: object, model: string): OwnedDelegate { return delegate; } -/** The backed-up models under which `userId` holds at least one row. */ +/** + * The backed-up models under which `userId` holds at least one row, setup + * state left out. + */ export async function modelsHoldingRecords( db: Prisma.TransactionClient, userId: string, ): Promise { const held: string[] = []; - for (const model of RESTORE_TARGET_OWNED_MODELS) { + for (const model of RESTORE_TARGET_CHECKED_MODELS) { const row = await ownedDelegate(db, model).findFirst({ where: { userId }, select: { userId: true }, diff --git a/tests/integration/backup-erased-account-restore.test.ts b/tests/integration/backup-erased-account-restore.test.ts index 39f6afc56..ef0530a20 100644 --- a/tests/integration/backup-erased-account-restore.test.ts +++ b/tests/integration/backup-erased-account-restore.test.ts @@ -22,6 +22,9 @@ * empty account (typed confirmation) and into a new account it creates, * every section comes back, the audit trail says what happened, and * nothing names the erased account, the refused first upload included. + * An account that has just been through the setup routes counts as + * empty, and the copy's setup state replaces its own; one whose setup + * ended on a reading of its own does not. * 6. A target that holds records, is a managed profile, is pending deletion * or is not confirmed is refused before the file is read; so is a target * for a file whose own account is still here, and a new account made for @@ -290,6 +293,52 @@ async function checkTarget(userId: string) { }; } +function jsonRequest(path: string, method: string, body: unknown): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** + * `userId` signs up again and goes through setup the way the flow does it: + * every question answered, the units written, cycle tracking asked for, and + * the confirm screen's profile fields. The session is left on `userId`. + */ +async function goThroughOnboarding(prisma: PrismaClient, userId: string) { + cookieJar.clear(); + await signIn(prisma, userId); + const { PATCH } = await import("@/app/api/onboarding/answers/route"); + const answer = async (body: unknown) => { + const res = await (PATCH as (r: Request) => Promise)( + jsonRequest("/api/onboarding/answers", "PATCH", body), + ); + expect(res.status, await res.clone().text()).toBe(200); + }; + await answer({ step: "who", recordTarget: "me" }); + await answer({ step: "areas", areas: ["blood-pressure", "cycle", "labs"] }); + await answer({ step: "medication", medication: "yes" }); + await answer({ step: "sources", status: "skipped" }); + await answer({ step: "visit", visit: "no" }); + await answer({ + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }); + const { POST } = await import("@/app/api/onboarding/complete/route"); + const done = await (POST as (r: Request) => Promise)( + jsonRequest("/api/onboarding/complete", "POST", { + displayName: "Returning", + heightCm: 172, + dateOfBirth: "1980-05-04", + gender: "FEMALE", + }), + ); + expect(done.status, await done.clone().text()).toBe(200); + await answer({ step: "first-result", status: "skipped" }); + await answer({ step: "done" }); +} + /** The stored copy as the restore will read it, as text. */ async function storedCopyText(prisma: PrismaClient, backupId: string) { const row = await prisma.dataBackup.findUniqueOrThrow({ @@ -453,6 +502,85 @@ describe("restoring the backup of an erased account into a chosen account", () = SLOW, ); + it( + "into an account that has just been through setup: the setup state does not count, and the copy's replaces it", + async () => { + const prisma = getPrismaClient(); + const { admin, json, counts } = await eraseAfterExport(prisma); + const person = await prisma.user.create({ + data: { + username: "returning-person", + email: "returning-person@example.test", + }, + }); + await goThroughOnboarding(prisma, person.id); + // What setup left behind: its own row, and the cycle profile the cycle + // answer switched on. + expect( + await prisma.onboardingRecord.count({ where: { userId: person.id } }), + ).toBe(1); + expect( + await prisma.cycleProfile.findUniqueOrThrow({ + where: { userId: person.id }, + select: { cycleTrackingEnabled: true }, + }), + ).toEqual({ cycleTrackingEnabled: true }); + + cookieJar.clear(); + await signIn(prisma, admin.id); + const check = await checkTarget(person.id); + expect(check.body.data).toMatchObject({ + eligible: true, + reason: null, + models: [], + }); + + const stored = await uploadInto(json, { + into: "existing", + userId: person.id, + confirm: "returning-person", + }); + expect(stored.status, JSON.stringify(stored.body)).toBe(201); + const restored = await restore(stored.body.data!.id); + expect(restored.status, JSON.stringify(restored.body)).toBe(200); + // Replaced, not merged: the counts are the file's, setup rows included. + expect(await countsOf(prisma, person.id)).toEqual(counts); + expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]); + }, + SLOW, + ); + + it( + "refuses an account whose setup ended on a reading of its own, because the restore would delete it", + async () => { + const prisma = getPrismaClient(); + const { admin } = await eraseAfterExport(prisma); + const person = await prisma.user.create({ + data: { username: "logged-one", email: "logged-one@example.test" }, + }); + await goThroughOnboarding(prisma, person.id); + // The first-result step's reading goes through the ordinary write. + await prisma.measurement.create({ + data: { + userId: person.id, + type: "BLOOD_PRESSURE_SYS", + value: 128, + unit: "mmHg", + measuredAt: new Date("2026-10-28T08:00:00.000Z"), + }, + }); + + cookieJar.clear(); + await signIn(prisma, admin.id); + expect((await checkTarget(person.id)).body.data).toMatchObject({ + eligible: false, + reason: "holds_records", + models: ["Measurement"], + }); + }, + SLOW, + ); + it( "into a new account: created without a password, filled, and signed into through the admin reset", async () => {