From ff8277695afbb4729c5f7c5376333c46cceed2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 13:12:25 +0200 Subject: [PATCH] test(backup): round-trip every account setting through a real restore Fill every User column on the source account, export through the streaming writer, recreate the account with credentials of its own, restore through the route, and compare: each setting column must come back with the value it left with, and every identity, credential and bookkeeping column must still hold the target's value. The section in the file is checked to carry nothing but setting columns. --- src/lib/export/backup-plan.ts | 3 - tests/integration/backup-round-trip.test.ts | 201 +++++++++++++++++++- 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index bebdc9649..a6e83e2d2 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -934,9 +934,6 @@ export const USER_COLUMN_BACKUP_CLASS = { Record >; -export type UserColumnBackupClass = - (typeof USER_COLUMN_BACKUP_CLASS)[keyof typeof USER_COLUMN_BACKUP_CLASS]; - /** The `User` columns the `accountSettings` section carries. */ export type AccountSettingColumn = { [ diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index c955cb2d5..3b0e4510e 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -77,7 +77,12 @@ import { } from "@/lib/labs/biomarker-store"; import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; -import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; +import { + TWO_ENDED_MODELS, + USER_COLUMN_BACKUP_CLASS, + type TwoEndedModel, +} from "@/lib/export/backup-plan"; +import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup"; import { parseOnboardingSteps } from "@/lib/onboarding/needs"; import { POST } from "./restore-job-driver"; import { @@ -3641,3 +3646,197 @@ describe("every column of every two-ended model survives a real restore", () => expect(stale).toEqual([]); }); }); + +/** + * A 1x1 PNG: the smallest picture the avatar upload, and so the restore, + * accepts. + */ +const AVATAR_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + "base64", +); + +/** + * Values for `User` columns where the type alone would produce one the + * restore rightly refuses: a vocabulary, a time zone, an AI endpoint, a + * threshold band, an image. + */ +const USER_FILL_OVERRIDES: Readonly> = { + "User.gender": "FEMALE", + "User.locale": "fr", + "User.timezone": "America/Chicago", + "User.homeTimezone": "Asia/Tokyo", + "User.unitPreference": "imperial", + "User.glucoseUnit": "mmol/L", + "User.insightsPrivacyMode": "raw", + "User.aiProvider": "LOCAL", + "User.aiBaseUrl": "https://llm.example.com/v1", + "User.aiCompatBaseUrl": "https://gateway.example.com/v1", + "User.thresholdsJson": { WEIGHT: { min: 60, max: 80 } }, + "User.globalExcludedInjectionSites": ["THIGH_LEFT"], + "User.avatarBytes": () => { + const bytes = new Uint8Array(new ArrayBuffer(AVATAR_PNG.byteLength)); + bytes.set(AVATAR_PNG); + return bytes; + }, + "User.avatarContentType": "image/png", +}; + +/** + * Columns of the account row the restore writes for reasons of its own, so + * "unchanged on the target" does not apply to them. + */ +const USER_ROW_STAMPED: Readonly> = { + updatedAt: "@updatedAt; Prisma stamps it on the restore's write", + syncResetAt: + "the restore stamps it so paired clients' delta cursors are refused", +}; + +describe("the account's own settings survive a real restore", () => { + it("brings every setting column back and leaves the rest of the target's row alone", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + + const [userModel] = readAuditModels(prisma, ["User"]); + const enumValues = enumValuesFromSchema(); + const settings = new Set(ACCOUNT_SETTING_COLUMNS); + const classes = USER_COLUMN_BACKUP_CLASS as Readonly< + Record + >; + + // Every column the source account can be given a non-default value in, + // settings and everything else alike: a credential that came back on the + // target is as much a failure as a setting that did not. + const fillable = userModel.columns.filter( + (column) => + !column.isId && + !column.isUpdatedAt && + column.name !== "username" && + column.name !== "role", + ); + const unfillable: string[] = []; + for (const column of fillable) { + try { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { + [column.name]: syntheticValue( + column, + enumValues, + USER_FILL_OVERRIDES, + ), + }, + }); + } catch (error) { + unfillable.push( + `${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`, + ); + } + } + expect(unfillable, "columns the fill could not set").toEqual([]); + const source = (await prisma.user.findUniqueOrThrow({ + where: { id: OWNER_ID }, + })) as unknown as Record; + const defaulted = [...settings].filter((name) => + isDefaultValue( + userModel.columns.find((c) => c.name === name)!, + source[name], + ), + ); + expect( + defaulted, + "these settings still hold their default, so coming back with the " + + "default would read as carried", + ).toEqual([]); + + let json = ""; + await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + json += chunk; + }, + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-08-01T00:00:00.000Z"), + }, + ); + // Nothing but setting columns reaches the file, and no secret at all. + const section = (JSON.parse(json) as { accountSettings: object }) + .accountSettings; + expect( + Object.keys(section).filter( + (key) => !settings.has(key) && key !== "insuranceNumber", + ), + ).toEqual([]); + + // A fresh account under the same id, the way an operator recreates one + // on a new host, given credentials of its own that must survive. + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { + passwordHash: "target-account-password-hash", + aiOpenaiKeyEncrypted: encrypt("target account key"), + totpSecretEncrypted: encrypt("target account totp"), + }, + }); + const target = (await prisma.user.findUniqueOrThrow({ + where: { id: OWNER_ID }, + })) as unknown as Record; + + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "ACCOUNT_SETTINGS_ROUND_TRIP", + data: await legacyStreamedBlobFrom(async (write) => { + await write(json); + }), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + const refused = ( + body.data.skipped.catalogueKeys as Array<{ catalogue: string }> + ).filter((entry) => entry.catalogue === "accountSetting"); + expect(refused, "the restore refused a setting the fixture set").toEqual( + [], + ); + + const after = (await prisma.user.findUniqueOrThrow({ + where: { id: OWNER_ID }, + })) as unknown as Record; + + const lost = [...settings].filter( + (name) => opened(after[name]) !== opened(source[name]), + ); + expect( + lost, + "these settings left with a value and came back without it", + ).toEqual([]); + expect(settings.size).toBeGreaterThanOrEqual(55); + + const overwritten = userModel.columns + .map((column) => column.name) + .filter( + (name) => + classes[name] !== "SETTING" && !Object.hasOwn(USER_ROW_STAMPED, name), + ) + .filter((name) => opened(after[name]) !== opened(target[name])); + expect( + overwritten, + "the restore changed these identity, credential or bookkeeping columns " + + "on the account it restored into", + ).toEqual([]); + }); +});