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 () => {