diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a608c438c..827a07d6c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -34033,6 +34033,187 @@ components: - documentQuotaBytes additionalProperties: {} - type: "null" + accountSettings: + default: null + anyOf: + - type: object + properties: + heightCm: + anyOf: + - type: number + - type: "null" + dateOfBirth: + anyOf: + - type: string + minLength: 1 + - type: "null" + gender: + anyOf: + - type: string + - type: "null" + hasDiabetes: + type: boolean + displayName: + anyOf: + - type: string + - type: "null" + fullName: + anyOf: + - type: string + - type: "null" + insurerName: + anyOf: + - type: string + - type: "null" + insuranceNumber: + anyOf: + - type: string + - type: "null" + insuranceNumberEncrypted: + anyOf: + - type: string + - type: "null" + insurerIkNumber: + anyOf: + - type: string + - type: "null" + lastReportPracticeName: + anyOf: + - type: string + - type: "null" + avatarBytes: + anyOf: + - type: string + minLength: 1 + - type: "null" + avatarContentType: + anyOf: + - type: string + - type: "null" + avatarUpdatedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + homeLat: + anyOf: + - type: number + - type: "null" + homeLon: + anyOf: + - type: number + - type: "null" + homeLabel: + anyOf: + - type: string + - type: "null" + homeTimezone: + anyOf: + - type: string + - type: "null" + homeSince: + anyOf: + - type: string + minLength: 1 + - type: "null" + timezone: + type: string + locale: + anyOf: + - type: string + - type: "null" + unitPreference: + anyOf: + - type: string + - type: "null" + glucoseUnit: + anyOf: + - type: string + - type: "null" + timeFormat: + type: string + dateFormat: + type: string + modulePreferencesJson: {} + thresholdsJson: {} + healthScoreConfigJson: {} + sourcePriorityJson: {} + dashboardWidgetsJson: {} + insightsLayoutJson: {} + medicationListLayoutJson: {} + moodTagLayoutJson: {} + reportSelectionJson: {} + globalExcludedInjectionSites: + type: array + items: + type: string + healthKitConfigJson: {} + notificationPrefs: {} + moodReminderEnabled: + type: boolean + aiProvider: + anyOf: + - type: string + - type: "null" + aiModel: + anyOf: + - type: string + - type: "null" + aiBaseUrl: + anyOf: + - type: string + - type: "null" + aiCompatBaseUrl: + anyOf: + - type: string + - type: "null" + aiCompatModel: + anyOf: + - type: string + - type: "null" + aiProviderChain: {} + aiResponseTimeoutSeconds: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + useCentralCodex: + type: boolean + insightsPrivacyMode: + type: string + insightsExcludeMetrics: + type: array + items: + type: string + disableCoach: + type: boolean + coachPrefsJson: {} + documentsAutoAiRead: + type: boolean + labsLocalOcrEnabled: + type: boolean + onboardingCompletedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + onboardingTourCompleted: + type: boolean + onboardingTourProgressJson: {} + disclaimerAcknowledgedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + disclaimerAcknowledgedVersion: + anyOf: + - type: string + - type: "null" + passkeyUpgradeNudgeDismissed: + type: boolean + additionalProperties: {} + - type: "null" measurements: default: [] type: array @@ -38434,6 +38615,7 @@ components: - exportedAt - userId - appSettings + - accountSettings - measurements - medications - intakeEvents diff --git a/messages/de.json b/messages/de.json index 6b2f2bf51..371a8386b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation", "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume", + "restoreSkippedAccountSetting": "Kontoeinstellung", "restoreMissingTitle": "Wiederherstellung abgelehnt — die Datei ist unvollständig", "restoreMissingDescription": "Das Manifest der Datei sagt, sie enthalte den folgenden Abschnitt, und sie enthält ihn nicht. Es wurde nichts geändert: Eine Wiederherstellung ersetzt eine ganze Datenklasse, ein fehlender Abschnitt hätte sie also geleert.", "restoreMissingRemedy": "Nimm eine Notfall-Sicherung oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest ausdrücklich als weggelassen ausweist, ist etwas anderes und wird normal wiederhergestellt.", diff --git a/messages/en.json b/messages/en.json index 90e0d58ea..718b70a66 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "ECG strip provenance", "restoreSkippedMedicationTarget": "Medication target analyte", "restoreSkippedScheduleRevisionLink": "Schedule era succession", + "restoreSkippedAccountSetting": "Account setting", "restoreMissingTitle": "Restore refused — the file is incomplete", "restoreMissingDescription": "The file's own manifest says it carries the section below, and it does not. Nothing was changed: a restore replaces a whole class of records, so putting back a section that is not in the file would have emptied it.", "restoreMissingRemedy": "Use a disaster-recovery snapshot, or export the account again and upload the new file. A section the manifest declares as omitted is a different thing and restores normally.", diff --git a/messages/es.json b/messages/es.json index 8e850ae00..5fdcd3a00 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento", "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta", + "restoreSkippedAccountSetting": "Ajuste de la cuenta", "restoreMissingTitle": "Restauración rechazada: el archivo está incompleto", "restoreMissingDescription": "El propio manifiesto del archivo dice que incluye la sección siguiente, y no la incluye. No se cambió nada: una restauración sustituye toda una clase de registros, así que una sección ausente la habría vaciado.", "restoreMissingRemedy": "Usa una copia de recuperación ante desastres, o vuelve a exportar la cuenta y sube el archivo nuevo. Una sección que el manifiesto declara omitida es otra cosa y se restaura con normalidad.", diff --git a/messages/fr.json b/messages/fr.json index 7cf020417..771801950 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Provenance d'un tracé ECG", "restoreSkippedMedicationTarget": "Analyte cible d'un médicament", "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma", + "restoreSkippedAccountSetting": "Réglage du compte", "restoreMissingTitle": "Restauration refusée — le fichier est incomplet", "restoreMissingDescription": "Le manifeste du fichier indique qu'il contient la section ci-dessous, et il ne la contient pas. Rien n'a été modifié : une restauration remplace toute une classe d'enregistrements, une section absente l'aurait donc vidée.", "restoreMissingRemedy": "Utilise une sauvegarde de reprise après sinistre, ou réexporte le compte et téléverse le nouveau fichier. Une section que le manifeste déclare omise est autre chose et se restaure normalement.", diff --git a/messages/it.json b/messages/it.json index 83b14de00..904422f6e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco", "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema", + "restoreSkippedAccountSetting": "Impostazione dell'account", "restoreMissingTitle": "Ripristino rifiutato — il file è incompleto", "restoreMissingDescription": "Il manifesto del file dichiara di contenere la sezione qui sotto, e non la contiene. Non è stato modificato nulla: un ripristino sostituisce un'intera classe di record, quindi una sezione assente l'avrebbe svuotata.", "restoreMissingRemedy": "Usa uno snapshot di disaster recovery, oppure esporta di nuovo l'account e carica il file nuovo. Una sezione che il manifesto dichiara omessa è un'altra cosa e si ripristina normalmente.", diff --git a/messages/ko.json b/messages/ko.json index bb57177ef..9b67626fa 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7322,6 +7322,7 @@ "restoreSkippedEcgReference": "ECG 파형 출처", "restoreSkippedMedicationTarget": "약 목표 분석물", "restoreSkippedScheduleRevisionLink": "일정 개정 연결", + "restoreSkippedAccountSetting": "계정 설정", "restoreMissingTitle": "복원을 중단했어요 — 파일이 불완전해요", "restoreMissingDescription": "파일의 매니페스트에는 아래 항목이 들어 있다고 적혀 있는데 실제로는 없어요. 아무것도 바꾸지 않았어요. 복원은 해당 기록 전체를 갈아엎기 때문에, 파일에 없는 항목을 되돌리면 그 항목이 통째로 비워졌을 거예요.", "restoreMissingRemedy": "재해 복구용 스냅샷을 쓰거나, 계정을 다시 내보내서 새 파일을 업로드해 주세요. 매니페스트가 처음부터 '빠졌다'고 표시한 항목은 다른 경우라 정상적으로 복원돼요.", diff --git a/messages/pl.json b/messages/pl.json index 90fcc23d4..f5eb1ceb2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", "restoreSkippedMedicationTarget": "Analit docelowy leku", "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu", + "restoreSkippedAccountSetting": "Ustawienie konta", "restoreMissingTitle": "Przywracanie odrzucone — plik jest niekompletny", "restoreMissingDescription": "Manifest pliku mówi, że zawiera on poniższą sekcję, a jej nie zawiera. Nic nie zostało zmienione: przywracanie zastępuje całą klasę rekordów, więc brakująca sekcja by ją opróżniła.", "restoreMissingRemedy": "Użyj kopii do odtwarzania po awarii albo wyeksportuj konto ponownie i wgraj nowy plik. Sekcja, którą manifest jawnie deklaruje jako pominiętą, to co innego i przywraca się normalnie.", diff --git a/src/__tests__/user-column-backup-classification.test.ts b/src/__tests__/user-column-backup-classification.test.ts new file mode 100644 index 000000000..a5fa12e90 --- /dev/null +++ b/src/__tests__/user-column-backup-classification.test.ts @@ -0,0 +1,70 @@ +/** + * Every `User` column has a backup verdict, read from the schema rather than + * from a list somebody keeps. + * + * The account row mixes the person's choices with sign-in credentials, + * provider keys, the operator's policy for the account and the host's own + * cursors, so it cannot have one verdict the way a model does. A column added + * tomorrow has to land in `USER_COLUMN_BACKUP_CLASS` (`src/lib/export/ + * backup-plan.ts`) or this fails naming it: a new preference that silently + * stays behind on a restore, or a new secret that silently rides along in + * every backup file, are the two failures this exists to stop. + * + * Mutation check: delete any key from the table and the first case goes red + * naming the column; add a `User` column to the schema without classifying it + * and the same case goes red naming that one. + */ +import { describe, expect, it } from "vitest"; + +import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape"; +import { USER_COLUMN_BACKUP_CLASS } from "@/lib/export/backup-plan"; +import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup"; + +const columns = parseWipeSchema().get("User")?.scalars ?? []; +const classified = USER_COLUMN_BACKUP_CLASS as Readonly>; + +describe("User column backup classification", () => { + it("classifies every User column", () => { + // A floor, so a parser that stops matching cannot pass on an empty list. + expect(columns.length).toBeGreaterThan(100); + const unclassified = columns.filter((c) => !Object.hasOwn(classified, c)); + expect( + unclassified, + "these User columns have no backup verdict. Add each to " + + "USER_COLUMN_BACKUP_CLASS as SETTING, CREDENTIAL, IDENTITY or " + + "OPERATIONAL, and if it is a SETTING give it a codec in " + + "account-settings-backup.ts", + ).toEqual([]); + }); + + it("names no column the schema no longer has", () => { + const known = new Set(columns); + expect(Object.keys(classified).filter((c) => !known.has(c))).toEqual([]); + }); + + it("carries exactly the columns classified as settings", () => { + expect([...ACCOUNT_SETTING_COLUMNS].sort()).toEqual( + Object.entries(classified) + .filter(([, verdict]) => verdict === "SETTING") + .map(([column]) => column) + .sort(), + ); + }); + + it("carries no credential-shaped column as a setting", () => { + // The insurance number is the one sealed setting: it is the person's own + // data, sealed at rest for privacy, not a secret that grants anything. + const sealedSettings = ACCOUNT_SETTING_COLUMNS.filter((column) => + /Encrypted$|Token|Secret|password|totp|oidc/i.test(column), + ); + expect(sealedSettings).toEqual(["insuranceNumberEncrypted"]); + }); + + it("never classifies a sealed column other than the insurance number as anything but a credential", () => { + const sealed = columns.filter( + (c) => /Encrypted$/.test(c) && c !== "insuranceNumberEncrypted", + ); + expect(sealed.length).toBeGreaterThan(20); + expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]); + }); +}); diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 0f716e5d0..3df29eb17 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -27,6 +27,9 @@ vi.mock("@/lib/db", () => ({ intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, + // The account row's settings, read by `buildAccountSettingsBackupSection`. + // Absent here; the section has its own tests. + user: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 39f99924f..bcd8cca24 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -467,6 +467,9 @@ function catalogueLabel( if (catalogue === "scheduleRevisionLink") { return t("admin.section.backups.restoreSkippedScheduleRevisionLink"); } + if (catalogue === "accountSetting") { + return t("admin.section.backups.restoreSkippedAccountSetting"); + } // Not a fallback: the chain above is exhaustive and this line is what makes // the compiler say so. A catalogue added without a label here now stops the // build instead of shipping under a label that belongs to something else, diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts new file mode 100644 index 000000000..b0768b7e9 --- /dev/null +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -0,0 +1,267 @@ +/** + * The account-settings section at both ends, without a database. + * + * The integration round trip proves every setting column comes back; this + * file pins what the restore refuses and why, and what a portable file shows + * a person instead of ciphertext. Every refusal here leaves the account's + * current value in place and names the column in the restore's skip report. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { Prisma } from "@/generated/prisma/client"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { + ACCOUNT_SETTING_COLUMNS, + admitAccountSettings, + buildAccountSettingsBackupSection, +} from "@/lib/export/account-settings-backup"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; + +/** A 1x1 PNG, the smallest image the avatar upload accepts. */ +const PNG_1X1 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +const ctx = { operatorTrusted: false }; + +function rowWith(overrides: Record = {}) { + const row: Record = Object.fromEntries( + ACCOUNT_SETTING_COLUMNS.map((column) => [column, null]), + ); + return { ...row, ...overrides }; +} + +function prismaReturning(row: Record | null) { + return { user: { findUnique: vi.fn().mockResolvedValue(row) } } as never; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("buildAccountSettingsBackupSection", () => { + it("writes only setting columns, plus the readable insurance number alias", async () => { + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith()), + "u1", + { purpose: "disaster-recovery" }, + ); + const allowed = new Set([ + ...ACCOUNT_SETTING_COLUMNS, + "insuranceNumber", + ]); + expect( + Object.keys(accountSettings!).filter((key) => !allowed.has(key)), + ).toEqual([]); + }); + + it("carries the insurance number sealed in a disaster-recovery file", async () => { + const sealed = encrypt("A123456789"); + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith({ insuranceNumberEncrypted: sealed })), + "u1", + { purpose: "disaster-recovery" }, + ); + expect(accountSettings).toMatchObject({ + insuranceNumber: null, + insuranceNumberEncrypted: sealed, + }); + }); + + it("carries it readable in a portable file, and never the ciphertext", async () => { + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning( + rowWith({ insuranceNumberEncrypted: encrypt("A123456789") }), + ), + "u1", + { purpose: "portable-export" }, + ); + expect(accountSettings!.insuranceNumber).toBe("A123456789"); + expect(accountSettings).not.toHaveProperty("insuranceNumberEncrypted"); + }); + + it("writes the marker for an insurance number this host cannot open", async () => { + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith({ insuranceNumberEncrypted: "zz.notreal" })), + "u1", + { purpose: "portable-export" }, + ); + expect(accountSettings!.insuranceNumber).toBe(UNREADABLE_EXPORT_MARKER); + }); + + it("answers null for an account that is not there", async () => { + expect( + await buildAccountSettingsBackupSection(prismaReturning(null), "u1"), + ).toEqual({ accountSettings: null }); + }); +}); + +describe("admitAccountSettings", () => { + it("leaves out what the file does not carry", () => { + expect(admitAccountSettings({}, ctx)).toEqual({ data: {}, refused: [] }); + }); + + it("refuses a language, unit or time zone this release does not know", () => { + const { data, refused } = admitAccountSettings( + { + locale: "xx", + unitPreference: "furlongs", + glucoseUnit: "mmol/L", + timezone: "Mars/Olympus_Mons", + homeTimezone: "Europe/Lisbon", + timeFormat: "H36", + dateFormat: "YMD", + }, + ctx, + ); + expect(refused.sort()).toEqual([ + "locale", + "timeFormat", + "timezone", + "unitPreference", + ]); + expect(data).toEqual({ + glucoseUnit: "mmol/L", + homeTimezone: "Europe/Lisbon", + dateFormat: "YMD", + }); + }); + + it("refuses an AI endpoint on a private address this host has not allowed", () => { + vi.stubEnv("AI_PRIVATE_ORIGINS", ""); + const { data, refused } = admitAccountSettings( + { + aiProvider: "LOCAL", + aiBaseUrl: "http://192.168.1.20:11434", + aiCompatBaseUrl: "http://169.254.169.254/latest", + }, + ctx, + ); + expect(refused.sort()).toEqual(["aiBaseUrl", "aiCompatBaseUrl"]); + expect(data).toEqual({ aiProvider: "LOCAL" }); + }); + + it("keeps a private AI endpoint the operator allowed here", () => { + vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434"); + const { data, refused } = admitAccountSettings( + { aiProvider: "LOCAL", aiBaseUrl: "http://ollama.lan:11434" }, + ctx, + ); + expect(refused).toEqual([]); + expect(data.aiBaseUrl).toBe("http://ollama.lan:11434"); + }); + + it("does not attach a base URL to a provider other than Local", () => { + const { data, refused } = admitAccountSettings( + { aiProvider: "OPENAI", aiBaseUrl: "https://llm.example.com/v1" }, + ctx, + ); + expect(refused).toEqual(["aiBaseUrl"]); + expect(data).toEqual({ aiProvider: "OPENAI" }); + }); + + it("reads the provider the account already has when the file names none", () => { + expect( + admitAccountSettings( + { aiBaseUrl: "https://llm.example.com/v1" }, + ctx, + "LOCAL", + ).data, + ).toEqual({ aiBaseUrl: "https://llm.example.com/v1" }); + }); + + it("keeps the valid threshold bands and names the ones it drops", () => { + const { data, refused } = admitAccountSettings( + { + thresholdsJson: { + WEIGHT: { min: 60, max: 80 }, + PULSE: { min: 100, max: 50 }, + RETIRED_METRIC: { min: 1, max: 2 }, + }, + }, + ctx, + ); + expect(data.thresholdsJson).toEqual({ WEIGHT: { min: 60, max: 80 } }); + expect(refused.sort()).toEqual([ + "thresholdsJson.PULSE", + "thresholdsJson.RETIRED_METRIC", + ]); + }); + + it("writes a database null for a JSON setting the file clears", () => { + expect( + admitAccountSettings({ dashboardWidgetsJson: null }, ctx).data + .dashboardWidgetsJson, + ).toBe(Prisma.DbNull); + }); + + it("drops an injection site this release does not know and keeps the rest", () => { + const { data, refused } = admitAccountSettings( + { globalExcludedInjectionSites: ["THIGH_LEFT", "EARLOBE"] }, + ctx, + ); + expect(data.globalExcludedInjectionSites).toEqual(["THIGH_LEFT"]); + expect(refused).toEqual(["globalExcludedInjectionSites.EARLOBE"]); + }); + + it("takes the avatar's type from its bytes, not from the file", () => { + const { data, refused } = admitAccountSettings( + { + avatarBytes: PNG_1X1, + avatarContentType: "text/html", + avatarUpdatedAt: "2026-06-30T12:00:00.000Z", + }, + ctx, + ); + expect(refused).toEqual([]); + expect(data.avatarContentType).toBe("image/png"); + expect(data.avatarUpdatedAt).toEqual(new Date("2026-06-30T12:00:00.000Z")); + expect(data.avatarBytes).toBeInstanceOf(Uint8Array); + }); + + it("refuses an avatar that is not an image the upload accepts", () => { + const { data, refused } = admitAccountSettings( + { + avatarBytes: Buffer.from("....").toString( + "base64", + ), + avatarContentType: "image/svg+xml", + }, + ctx, + ); + expect(refused).toEqual(["avatarBytes"]); + expect(data).toEqual({}); + }); + + it("seals a readable insurance number under this host's key", () => { + const { data } = admitAccountSettings({ insuranceNumber: "A123" }, ctx); + expect(decrypt(data.insuranceNumberEncrypted as string)).toBe("A123"); + }); + + it("refuses the unreadable marker as an insurance number", () => { + const { data, refused } = admitAccountSettings( + { insuranceNumber: UNREADABLE_EXPORT_MARKER }, + ctx, + ); + expect(refused).toEqual(["insuranceNumberEncrypted"]); + expect(data).toEqual({}); + }); + + it("never admits a column that is not a setting, whatever the file says", () => { + const { data } = admitAccountSettings( + { + passwordHash: "x", + role: "ADMIN", + email: "someone@example.test", + aiOpenaiKeyEncrypted: encrypt("sk-test"), + managedProfileAt: "2026-01-01T00:00:00.000Z", + syncResetAt: "2026-01-01T00:00:00.000Z", + heightCm: 180, + }, + ctx, + ); + expect(data).toEqual({ heightCm: 180 }); + }); +}); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index e0dcf020b..c4a65b1ec 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -384,6 +384,9 @@ function makePrisma() { }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, + // The account row's settings, read by `buildAccountSettingsBackupSection`. + // Absent here; the section has its own tests. + user: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index 287a97a9f..a988ddfed 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -253,6 +253,9 @@ function sourceClient() { // ordinary case: the export has to answer for a record that never entered // the flow without inventing one. onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, + // The account row's settings, read by `buildAccountSettingsBackupSection`. + // Absent here; the section has its own tests. + user: { findUnique: vi.fn().mockResolvedValue(null) }, // The score as it was SHOWN on a local day. Not recomputable: today's // number always comes from today's rows, so once the readings behind this // day moved, nothing can reproduce what it said. diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts new file mode 100644 index 000000000..b2d5c9703 --- /dev/null +++ b/src/lib/export/account-settings-backup.ts @@ -0,0 +1,613 @@ +/** + * The account's own settings, with both backup ends in one file. + * + * Which `User` columns these are is decided in `USER_COLUMN_BACKUP_CLASS` + * (`src/lib/export/backup-plan.ts`); this file is how each one travels. The + * table below is keyed by that type, so a column classified as a setting + * without a way to carry it does not compile, and neither does a way to carry + * a column that is not one. + * + * ── What the restore does with them ──────────────────────────────────────── + * + * Writes them to the account being restored, column by column, and nothing + * else on the row. The value from the file is not trusted just because this + * host once wrote it: the file may come from another host, from an older + * release, or from an operator's text editor, so each column is checked the + * way the settings route that writes it checks it. A value that fails is not + * written, the account keeps what it had, and the column is named in the + * restore's skip report, so a setting that did not come back is never + * reported as one that did. The checks that can actually fail on a real file: + * + * - an AI endpoint on a private address the receiving host has not allowed + * (`AI_PRIVATE_ORIGINS`), or one the account's role no longer covers; + * - a language, unit or time zone this release does not know; + * - a threshold for a metric this release does not range, or out of range; + * - an avatar that is not one of the image types the upload accepts. + * + * A setting naming something the receiving host does not have (a provider + * with no key, a source that is not connected, a module the operator has + * turned off) is kept: the app already reads that state as "not available" + * everywhere, because it is exactly the state after a key is removed or an + * integration disconnected. + * + * ── Encryption ───────────────────────────────────────────────────────────── + * + * One column is sealed, the insurance number, and it follows the split every + * other section uses: a disaster-recovery file carries the ciphertext + * verbatim (so the restore's key check sees it), a portable file carries the + * readable value and the restore seals it under this host's key. + */ +import { Buffer } from "node:buffer"; + +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; +import { + DateFormatPreference, + InjectionSite, + TimeFormatPreference, +} from "@/generated/prisma/enums"; +import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; +import { + AVATAR_MAX_BYTES, + detectAvatarMimeType, + readAvatarDimensions, +} from "@/lib/avatar"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { + USER_COLUMN_BACKUP_CLASS, + type AccountSettingColumn, +} from "@/lib/export/backup-plan"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; +import { locales } from "@/lib/i18n/config"; +import { thresholdsUpdateSchema } from "@/lib/validations/thresholds"; +import { isPublicUrl } from "@/lib/validations/notifications"; +import { + glucoseUnitPatchSchema, + unitPreferencePatchSchema, +} from "@/lib/validations/user-prefs"; + +export interface AccountSettingsBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** The section as it rides the wire: setting columns by name, JSON-safe. */ +export type AccountSettingsBackupEntry = Record; + +export interface AccountSettingsBackupSection { + accountSettings: AccountSettingsBackupEntry | null; +} + +/** Every setting column, in the order the table below declares them. */ +export const ACCOUNT_SETTING_COLUMNS = Object.entries(USER_COLUMN_BACKUP_CLASS) + .filter(([, verdict]) => verdict === "SETTING") + .map(([column]) => column as AccountSettingColumn); + +type SettingsRow = Record; + +/** What the restore knows about the account it writes into. */ +export interface AccountSettingsRestoreContext { + /** Whether the account is an admin, which widens the AI endpoint policy. */ + operatorTrusted: boolean; +} + +/** One column's two ends. */ +interface SettingCodec { + /** The wire fragment for this column, read from the database row. */ + write(row: SettingsRow, disasterRecovery: boolean): Record; + /** + * The database value for this column from the file, `undefined` when the + * file does not carry it (the account keeps what it has), or a refusal. + */ + read( + entry: AccountSettingsBackupEntry, + ctx: AccountSettingsRestoreContext, + ): unknown; +} + +/** A value from the file that this host will not write. */ +class Refused { + constructor(readonly key: string) {} +} + +/** A value that came back in part, with the parts that did not named. */ +class PartlyRefused { + constructor( + readonly value: unknown, + readonly refused: string[], + ) {} +} + +function has(entry: AccountSettingsBackupEntry, key: string): boolean { + return Object.hasOwn(entry, key) && entry[key] !== undefined; +} + +/** Carried as it is: a string, number, boolean or list, or null. */ +function plain(column: AccountSettingColumn): SettingCodec { + return { + write: (row) => ({ [column]: row[column] }), + read: (entry) => (has(entry, column) ? entry[column] : undefined), + }; +} + +/** A timestamp, carried as ISO-8601. */ +function instant(column: AccountSettingColumn): SettingCodec { + return { + write: (row) => ({ + [column]: + row[column] instanceof Date + ? (row[column] as Date).toISOString() + : null, + }), + read: (entry) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + return value === null ? null : new Date(value as string); + }, + }; +} + +/** + * A JSON column, carried as it is stored. Every reader of these parses + * fail-soft (a key it does not know is ignored, a malformed blob reads as the + * default), so a value written by another release restores rather than being + * refused here on a stricter opinion than the app's own. + */ +function json(column: AccountSettingColumn): SettingCodec { + return { + write: (row) => ({ [column]: row[column] ?? null }), + read: (entry) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + return value === null ? Prisma.DbNull : value; + }, + }; +} + +/** A closed vocabulary. A value outside it is refused, not coerced. */ +function oneOf( + column: AccountSettingColumn, + values: readonly string[], + nullable: boolean, +): SettingCodec { + return { + write: (row) => ({ [column]: row[column] }), + read: (entry) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + if (value === null && nullable) return null; + if (typeof value === "string" && values.includes(value)) return value; + return new Refused(column); + }, + }; +} + +function isTimeZone(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0 || value.length > 64) { + return false; + } + try { + Intl.DateTimeFormat(undefined, { timeZone: value }); + return true; + } catch { + return false; + } +} + +/** An IANA zone this runtime knows, the check the settings routes apply. */ +function timeZone( + column: AccountSettingColumn, + nullable: boolean, +): SettingCodec { + return { + write: (row) => ({ [column]: row[column] }), + read: (entry) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + if (value === null && nullable) return null; + return isTimeZone(value) ? value : new Refused(column); + }, + }; +} + +/** + * An AI endpoint, held to the rule `PATCH /api/user/ai-provider` applies when + * it is saved: a public address, or a private one the operator of THIS host + * allowed for this account. The address arrived from a file, so it has never + * been through that check here. + */ +function aiEndpoint(column: "aiBaseUrl" | "aiCompatBaseUrl"): SettingCodec { + return { + write: (row) => ({ [column]: row[column] }), + read: (entry, ctx) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + if (value === null) return null; + if (typeof value !== "string") return new Refused(column); + const trimmed = value.trim(); + if (trimmed === "") return null; + const allowed = + isLocalAiHostAllowed(trimmed, { + operatorTrusted: ctx.operatorTrusted, + }) || isPublicUrl(trimmed); + return allowed ? trimmed : new Refused(column); + }, + }; +} + +/** + * The threshold overrides, metric by metric. The readers that apply a band + * cast the column rather than parse it, so a band this release does not + * range, or one outside the metric's bounds, is dropped here and named, + * while the valid bands beside it still come back. + */ +const thresholds: SettingCodec = { + write: (row) => ({ thresholdsJson: row.thresholdsJson ?? null }), + read: (entry) => { + if (!has(entry, "thresholdsJson")) return undefined; + const value = entry.thresholdsJson; + if (value === null) return Prisma.DbNull; + if (typeof value !== "object" || Array.isArray(value)) { + return new Refused("thresholdsJson"); + } + const kept: Record = {}; + const refused: string[] = []; + for (const [metric, band] of Object.entries(value)) { + if (thresholdsUpdateSchema.safeParse({ [metric]: band }).success) { + kept[metric] = band; + } else { + refused.push(`thresholdsJson.${metric}`); + } + } + return new PartlyRefused( + Object.keys(kept).length === 0 ? Prisma.DbNull : kept, + refused, + ); + }, +}; + +const injectionSites = Object.values(InjectionSite) as string[]; + +/** The injection sites to avoid everywhere; an unknown site is dropped. */ +const excludedSites: SettingCodec = { + write: (row) => ({ + globalExcludedInjectionSites: row.globalExcludedInjectionSites, + }), + read: (entry) => { + if (!has(entry, "globalExcludedInjectionSites")) return undefined; + const value = entry.globalExcludedInjectionSites; + if (!Array.isArray(value)) + return new Refused("globalExcludedInjectionSites"); + const kept = value.filter( + (site): site is string => + typeof site === "string" && injectionSites.includes(site), + ); + const refused = value + .filter((site) => !kept.includes(site as string)) + .map((site) => `globalExcludedInjectionSites.${String(site)}`); + return new PartlyRefused(kept, refused); + }, +}; + +/** + * The picture, re-checked the way the upload route checks one: sniffed from + * its bytes, within the size and dimension limits. The stored content type + * is the sniffed one, never the file's, because the avatar is served with it. + * The three avatar columns travel as one: the bytes decide, the type and the + * stamp follow them. + */ +const avatar: SettingCodec = { + write: (row) => ({ + avatarBytes: + row.avatarBytes instanceof Uint8Array + ? Buffer.from(row.avatarBytes).toString("base64") + : null, + avatarContentType: row.avatarContentType, + avatarUpdatedAt: + row.avatarUpdatedAt instanceof Date + ? row.avatarUpdatedAt.toISOString() + : null, + }), + read: (entry) => { + if (!has(entry, "avatarBytes")) return undefined; + const value = entry.avatarBytes; + if (value === null) { + return { + avatarBytes: null, + avatarContentType: null, + avatarUpdatedAt: null, + }; + } + if (typeof value !== "string") return new Refused("avatarBytes"); + const buffer = Buffer.from(value, "base64"); + if (buffer.byteLength === 0 || buffer.byteLength > AVATAR_MAX_BYTES) { + return new Refused("avatarBytes"); + } + const mime = detectAvatarMimeType(buffer); + const dimensions = mime ? readAvatarDimensions(buffer, mime) : null; + if (!mime || !dimensions) return new Refused("avatarBytes"); + if (dimensions.width > 2048 || dimensions.height > 2048) { + return new Refused("avatarBytes"); + } + const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength)); + bytes.set(buffer); + const stamp = entry.avatarUpdatedAt; + return { + avatarBytes: bytes, + avatarContentType: mime, + avatarUpdatedAt: typeof stamp === "string" ? new Date(stamp) : new Date(), + }; + }, +}; + +/** Carried by the avatar codec above. */ +const ridesWithAvatar: SettingCodec = { + write: () => ({}), + read: () => undefined, +}; + +/** + * The insurance number. Sealed at rest; see the file comment for the split. + * A portable file written while this host could not open the value carries + * the unreadable marker, and the marker is not an insurance number, so it is + * refused rather than sealed as one. + */ +const insuranceNumber: SettingCodec = { + write: (row, disasterRecovery) => { + const sealed = row.insuranceNumberEncrypted as string | null; + if (disasterRecovery) { + return { insuranceNumber: null, insuranceNumberEncrypted: sealed }; + } + if (sealed === null) return { insuranceNumber: null }; + try { + return { insuranceNumber: decrypt(sealed) }; + } catch { + return { insuranceNumber: UNREADABLE_EXPORT_MARKER }; + } + }, + read: (entry) => { + if (has(entry, "insuranceNumberEncrypted")) { + const sealed = entry.insuranceNumberEncrypted; + if (sealed === null) return null; + return typeof sealed === "string" + ? sealed + : new Refused("insuranceNumberEncrypted"); + } + if (!has(entry, "insuranceNumber")) return undefined; + const readable = entry.insuranceNumber; + if (readable === null || readable === "") return null; + if (typeof readable !== "string" || readable === UNREADABLE_EXPORT_MARKER) { + return new Refused("insuranceNumberEncrypted"); + } + return encrypt(readable); + }, +}; + +const ACCOUNT_SETTING_CODECS: Readonly< + Record +> = { + heightCm: plain("heightCm"), + dateOfBirth: instant("dateOfBirth"), + gender: oneOf("gender", ["MALE", "FEMALE", "OTHER"], true), + hasDiabetes: plain("hasDiabetes"), + displayName: plain("displayName"), + fullName: plain("fullName"), + insurerName: plain("insurerName"), + insuranceNumberEncrypted: insuranceNumber, + insurerIkNumber: plain("insurerIkNumber"), + lastReportPracticeName: plain("lastReportPracticeName"), + avatarBytes: avatar, + avatarContentType: ridesWithAvatar, + avatarUpdatedAt: ridesWithAvatar, + homeLat: plain("homeLat"), + homeLon: plain("homeLon"), + homeLabel: plain("homeLabel"), + homeTimezone: timeZone("homeTimezone", true), + homeSince: instant("homeSince"), + timezone: timeZone("timezone", false), + locale: oneOf("locale", locales, true), + // The vocabularies the two preference routes accept, read from their own + // schemas so the restore cannot drift from them. + unitPreference: oneOf( + "unitPreference", + unitPreferencePatchSchema.shape.unitPreference.options, + true, + ), + glucoseUnit: oneOf( + "glucoseUnit", + glucoseUnitPatchSchema.shape.glucoseUnit.options, + true, + ), + timeFormat: oneOf("timeFormat", Object.values(TimeFormatPreference), false), + dateFormat: oneOf("dateFormat", Object.values(DateFormatPreference), false), + modulePreferencesJson: json("modulePreferencesJson"), + thresholdsJson: thresholds, + healthScoreConfigJson: json("healthScoreConfigJson"), + sourcePriorityJson: json("sourcePriorityJson"), + dashboardWidgetsJson: json("dashboardWidgetsJson"), + insightsLayoutJson: json("insightsLayoutJson"), + medicationListLayoutJson: json("medicationListLayoutJson"), + moodTagLayoutJson: json("moodTagLayoutJson"), + reportSelectionJson: json("reportSelectionJson"), + globalExcludedInjectionSites: excludedSites, + healthKitConfigJson: json("healthKitConfigJson"), + notificationPrefs: json("notificationPrefs"), + moodReminderEnabled: plain("moodReminderEnabled"), + aiProvider: plain("aiProvider"), + aiModel: plain("aiModel"), + aiBaseUrl: aiEndpoint("aiBaseUrl"), + aiCompatBaseUrl: aiEndpoint("aiCompatBaseUrl"), + aiCompatModel: plain("aiCompatModel"), + aiProviderChain: json("aiProviderChain"), + aiResponseTimeoutSeconds: plain("aiResponseTimeoutSeconds"), + useCentralCodex: plain("useCentralCodex"), + insightsPrivacyMode: oneOf( + "insightsPrivacyMode", + ["aggregated", "raw"], + false, + ), + insightsExcludeMetrics: plain("insightsExcludeMetrics"), + disableCoach: plain("disableCoach"), + coachPrefsJson: json("coachPrefsJson"), + documentsAutoAiRead: plain("documentsAutoAiRead"), + labsLocalOcrEnabled: plain("labsLocalOcrEnabled"), + onboardingCompletedAt: instant("onboardingCompletedAt"), + onboardingTourCompleted: plain("onboardingTourCompleted"), + onboardingTourProgressJson: json("onboardingTourProgressJson"), + disclaimerAcknowledgedAt: instant("disclaimerAcknowledgedAt"), + disclaimerAcknowledgedVersion: plain("disclaimerAcknowledgedVersion"), + passkeyUpgradeNudgeDismissed: plain("passkeyUpgradeNudgeDismissed"), +}; + +/** Exactly the setting columns, so the read is the declaration. */ +const SETTINGS_SELECT = Object.fromEntries( + ACCOUNT_SETTING_COLUMNS.map((column) => [column, true]), +) as Record; + +/** + * Build the account-settings slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole `PrismaClient`, matching the + * other section builders. + */ +export async function buildAccountSettingsBackupSection( + prisma: Pick, + userId: string, + options: AccountSettingsBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + const row = (await prisma.user.findUnique({ + where: { id: userId }, + select: SETTINGS_SELECT, + })) as SettingsRow | null; + if (!row) return { accountSettings: null }; + + const entry: AccountSettingsBackupEntry = {}; + for (const column of ACCOUNT_SETTING_COLUMNS) { + Object.assign( + entry, + ACCOUNT_SETTING_CODECS[column].write(row, disasterRecovery), + ); + } + return { accountSettings: entry }; +} + +/** What the file's settings section turned into, before anything is written. */ +export interface AdmittedAccountSettings { + /** Column -> the value to write. Only setting columns ever appear here. */ + data: Partial>; + /** Columns (or `column.part`) the file carried and this host refused. */ + refused: string[]; +} + +/** + * Decide, without writing, what the file's settings section may put on the + * account. Pure, so every refusal rule is testable without a database. + * + * `currentAiProvider` is the account's provider before the restore, for the + * one rule that spans two columns: only the Local provider uses + * `aiBaseUrl`, and the settings route clears the address whenever the + * provider moves away from Local so that a cloud key is never sent to it. + */ +export function admitAccountSettings( + entry: AccountSettingsBackupEntry, + ctx: AccountSettingsRestoreContext, + currentAiProvider: string | null = null, +): AdmittedAccountSettings { + const data: Partial> = {}; + const refused: string[] = []; + + for (const column of ACCOUNT_SETTING_COLUMNS) { + const value = ACCOUNT_SETTING_CODECS[column].read(entry, ctx); + if (value === undefined) continue; + if (value instanceof Refused) { + refused.push(value.key); + continue; + } + if (value instanceof PartlyRefused) { + data[column] = value.value; + refused.push(...value.refused); + continue; + } + if (column === "avatarBytes") { + // The avatar codec answers for all three of its columns at once. + Object.assign(data, value); + continue; + } + data[column] = value; + } + + const provider = + "aiProvider" in data + ? (data.aiProvider as string | null) + : currentAiProvider; + if (typeof data.aiBaseUrl === "string" && provider !== "LOCAL") { + delete data.aiBaseUrl; + refused.push("aiBaseUrl"); + } + + return { data, refused }; +} + +/** The slice of a parsed backup this restore consumes. */ +export interface AccountSettingsRestoreInput { + /** Absent from every file written before the section existed. */ + accountSettings?: AccountSettingsBackupEntry | null; +} + +export interface AccountSettingsRestoreResult { + /** How many setting columns were written. */ + applied: number; + /** What the file carried and the account did not take, by column. */ + refused: string[]; + /** Whether a unit preference changed, so stored texts quote old units. */ + unitsChanged: boolean; +} + +/** + * Put the account's settings back, inside the caller's transaction. + * + * Only the columns `admitAccountSettings` admitted, and only setting columns + * can be admitted. Identity, credentials and the host's own bookkeeping on + * the row are never in the write, so they stay exactly as the receiving + * account has them. A file without the section leaves the row untouched, + * which is how every backup written before the section existed restores. + */ +export async function restoreAccountSettings( + tx: Prisma.TransactionClient, + ownerId: string, + payload: AccountSettingsRestoreInput, +): Promise { + const entry = payload.accountSettings; + if (!entry) return { applied: 0, refused: [], unitsChanged: false }; + + const current = await tx.user.findUniqueOrThrow({ + where: { id: ownerId }, + select: { + role: true, + aiProvider: true, + unitPreference: true, + glucoseUnit: true, + }, + }); + const { data, refused } = admitAccountSettings( + entry, + { operatorTrusted: current.role === "ADMIN" }, + current.aiProvider, + ); + + const columns = Object.keys(data); + if (columns.length > 0) { + await tx.user.update({ + where: { id: ownerId }, + data: data as Prisma.UserUpdateInput, + }); + } + + return { + applied: columns.length, + refused, + unitsChanged: + ("unitPreference" in data && + data.unitPreference !== current.unitPreference) || + ("glucoseUnit" in data && data.glucoseUnit !== current.glucoseUnit), + }; +} diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 21301b399..a6e83e2d2 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -738,6 +738,209 @@ export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [ "InviteRedemption", ]; +/** + * Every column of the `User` row, and whether it travels. + * + * The models above are decided one table at a time. The account row cannot + * be: it holds the person's own choices next to their sign-in credentials, + * the integration keys, the operator's policy for the account and a dozen + * cursors, and a single verdict for the whole row would either leave the + * choices behind or carry the credentials with them. Before this table the + * row was simply not in the backup, so a restore brought the record back and + * dropped units, language, time zone, modules, reminder thresholds and the + * rest of how the account had been set up. + * + * `SETTING` — something the person chose. Exported in the + * `accountSettings` section and written back to the account + * being restored, column by column, after the same checks + * the settings routes apply. Both ends live in + * `src/lib/export/account-settings-backup.ts`. + * `CREDENTIAL` — a secret, a token, a provider registration or an external + * identity binding, and the state that only means something + * next to one. A backup file is not a credential store; the + * same reasoning as `ApiToken` above. + * `IDENTITY` — what the account IS rather than how it is set up: its key, + * its sign-in name, its role, whether it is a managed record, + * and the limits the operator set for it. A restore puts data + * back into an account; it never changes which account that + * is or what the operator allowed it. + * `OPERATIONAL` — cursors, caches and timestamps this host wrote while + * working for the account. Carrying them would assert work + * the receiving host never did, the reason `IntegrationStatus` + * and `ProviderHealth` are excluded above. + * + * `src/__tests__/user-column-backup-classification.test.ts` reads the + * schema and fails on a column that is missing here, so a new column gets a + * decision rather than a default. + */ +export const USER_COLUMN_BACKUP_CLASS = { + // ── Identity ────────────────────────────────────────────────────────────── + id: "IDENTITY", + username: "IDENTITY", + email: "IDENTITY", + role: "IDENTITY", + createdAt: "IDENTITY", + updatedAt: "IDENTITY", + // Whether somebody else runs this record. Account shape, and the gate for + // the guardian's reach: the wipe keeps it for the same reason. + managedProfileAt: "IDENTITY", + // The operator's per-account policy and quota, set by the operator and not + // by the person; a file from Monday must not undo Tuesday's decision. + mfaEnforced: "IDENTITY", + documentQuotaBytes: "IDENTITY", + + // ── Credentials ─────────────────────────────────────────────────────────── + passwordHash: "CREDENTIAL", + oidcIssuer: "CREDENTIAL", + oidcSub: "CREDENTIAL", + totpSecretEncrypted: "CREDENTIAL", + totpConfirmedAt: "CREDENTIAL", + totpLastStep: "CREDENTIAL", + codexAccessTokenEncrypted: "CREDENTIAL", + codexRefreshTokenEncrypted: "CREDENTIAL", + codexTokenExpiresAt: "CREDENTIAL", + codexConnectedAt: "CREDENTIAL", + codexConnectionStatus: "CREDENTIAL", + aiAnthropicKeyEncrypted: "CREDENTIAL", + aiLocalKeyEncrypted: "CREDENTIAL", + aiOpenaiKeyEncrypted: "CREDENTIAL", + aiCompatKeyEncrypted: "CREDENTIAL", + // The bot token, the chat it is bound to, and the switch that only means + // something with both: the same reasoning as `NotificationChannel`. + telegramBotToken: "CREDENTIAL", + telegramChatId: "CREDENTIAL", + telegramEnabled: "CREDENTIAL", + withingsClientIdEncrypted: "CREDENTIAL", + withingsClientSecretEncrypted: "CREDENTIAL", + whoopClientIdEncrypted: "CREDENTIAL", + whoopClientSecretEncrypted: "CREDENTIAL", + fitbitClientIdEncrypted: "CREDENTIAL", + fitbitClientSecretEncrypted: "CREDENTIAL", + googleHealthClientIdEncrypted: "CREDENTIAL", + googleHealthClientSecretEncrypted: "CREDENTIAL", + // The address is stored sealed beside its token because it can carry one, + // and the private-host exemption is a security decision about that one + // connection, not a preference that should arrive ahead of it. + nightscoutUrlEncrypted: "CREDENTIAL", + nightscoutTokenEncrypted: "CREDENTIAL", + nightscoutAllowPrivateHost: "CREDENTIAL", + polarAccessTokenEncrypted: "CREDENTIAL", + polarUserIdEncrypted: "CREDENTIAL", + polarClientIdEncrypted: "CREDENTIAL", + polarClientSecretEncrypted: "CREDENTIAL", + ouraAccessTokenEncrypted: "CREDENTIAL", + ouraRefreshTokenEncrypted: "CREDENTIAL", + ouraClientIdEncrypted: "CREDENTIAL", + ouraClientSecretEncrypted: "CREDENTIAL", + stravaClientIdEncrypted: "CREDENTIAL", + stravaClientSecretEncrypted: "CREDENTIAL", + stravaAccessTokenEncrypted: "CREDENTIAL", + stravaRefreshTokenEncrypted: "CREDENTIAL", + stravaAthleteId: "CREDENTIAL", + + // ── Operational ─────────────────────────────────────────────────────────── + insightsCachedAt: "OPERATIONAL", + insightsCachedText: "OPERATIONAL", + insightsCachedLocale: "OPERATIONAL", + insightsSnapshotHash: "OPERATIONAL", + insightsWarmFailedAt: "OPERATIONAL", + insightsBriefingRerollDate: "OPERATIONAL", + morningDigestRefreshedOn: "OPERATIONAL", + coachLastSeenAt: "OPERATIONAL", + stravaLastActivityAt: "OPERATIONAL", + stravaBackfillCompletedAt: "OPERATIONAL", + healthKitLastSyncedAt: "OPERATIONAL", + healthKitLastSyncTrigger: "OPERATIONAL", + healthKitLastBackgroundSyncAt: "OPERATIONAL", + lastSyncedAt: "OPERATIONAL", + // The restore stamps its own: paired clients' cursors from before it must + // be refused, which a carried value would undo. + syncResetAt: "OPERATIONAL", + + // ── Settings ────────────────────────────────────────────────────────────── + // The body and the person, for the reference ranges, the score and the + // doctor's report. No other section carries them. + heightCm: "SETTING", + dateOfBirth: "SETTING", + gender: "SETTING", + hasDiabetes: "SETTING", + displayName: "SETTING", + fullName: "SETTING", + insurerName: "SETTING", + insuranceNumberEncrypted: "SETTING", + insurerIkNumber: "SETTING", + lastReportPracticeName: "SETTING", + avatarBytes: "SETTING", + avatarContentType: "SETTING", + avatarUpdatedAt: "SETTING", + // Home, which the environment history is resolved against. + homeLat: "SETTING", + homeLon: "SETTING", + homeLabel: "SETTING", + homeTimezone: "SETTING", + homeSince: "SETTING", + // How the interface reads. + timezone: "SETTING", + locale: "SETTING", + unitPreference: "SETTING", + glucoseUnit: "SETTING", + timeFormat: "SETTING", + dateFormat: "SETTING", + // What is switched on, what is shown where, and what counts as in range. + modulePreferencesJson: "SETTING", + thresholdsJson: "SETTING", + healthScoreConfigJson: "SETTING", + sourcePriorityJson: "SETTING", + dashboardWidgetsJson: "SETTING", + insightsLayoutJson: "SETTING", + medicationListLayoutJson: "SETTING", + moodTagLayoutJson: "SETTING", + reportSelectionJson: "SETTING", + globalExcludedInjectionSites: "SETTING", + healthKitConfigJson: "SETTING", + // Reminders and notifications, as preferences rather than channels. + notificationPrefs: "SETTING", + moodReminderEnabled: "SETTING", + // The AI choices, never the keys: which provider, which model, which + // order, how long to wait, what may be sent, and whether to use it at all. + // The keys stay behind, so a restored choice of provider reads as "not + // configured" until a key is entered, which is also how it reads after a + // key is removed in Settings. + aiProvider: "SETTING", + aiModel: "SETTING", + aiBaseUrl: "SETTING", + aiCompatBaseUrl: "SETTING", + aiCompatModel: "SETTING", + aiProviderChain: "SETTING", + aiResponseTimeoutSeconds: "SETTING", + useCentralCodex: "SETTING", + insightsPrivacyMode: "SETTING", + insightsExcludeMetrics: "SETTING", + disableCoach: "SETTING", + coachPrefsJson: "SETTING", + documentsAutoAiRead: "SETTING", + labsLocalOcrEnabled: "SETTING", + // Where the person is in the app's introductions. Without these a restored + // account is sent through setup and the tour again, and asked to accept the + // disclaimer it already accepted (a newer disclaimer still asks, because the + // version is compared). + onboardingCompletedAt: "SETTING", + onboardingTourCompleted: "SETTING", + onboardingTourProgressJson: "SETTING", + disclaimerAcknowledgedAt: "SETTING", + disclaimerAcknowledgedVersion: "SETTING", + passkeyUpgradeNudgeDismissed: "SETTING", +} as const satisfies Readonly< + Record +>; + +/** The `User` columns the `accountSettings` section carries. */ +export type AccountSettingColumn = { + [ + K in keyof typeof USER_COLUMN_BACKUP_CLASS + ]: (typeof USER_COLUMN_BACKUP_CLASS)[K] extends "SETTING" ? K : never; +}[keyof typeof USER_COLUMN_BACKUP_CLASS]; + export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH"; /** diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index a1c9396c3..5f5e35fed 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -118,6 +118,10 @@ import { type OnboardingBackupCounts, type OnboardingBackupSection, } from "@/lib/export/onboarding-backup"; +import { + buildAccountSettingsBackupSection, + type AccountSettingsBackupSection, +} from "@/lib/export/account-settings-backup"; import { getMedicationCategories } from "@/lib/medication-category"; export interface FullBackupCounts @@ -707,6 +711,7 @@ export async function buildFullBackupPayload( onboardingRecord, ecg, nutrientDays, + accountSettings, ] = await Promise.all([ disasterRecovery ? prisma.appSettings.findUnique({ where: { id: "singleton" } }) @@ -903,6 +908,13 @@ export async function buildFullBackupPayload( }, orderBy: [{ day: "desc" }, { nutrient: "asc" }], }), + // The account's own settings: units, language, modules, thresholds, the + // AI choices without the keys. Which columns those are is decided in + // `USER_COLUMN_BACKUP_CLASS`; both ends live in + // `src/lib/export/account-settings-backup.ts`. + buildAccountSettingsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), ]); // Pinned to the section interfaces so the spreads below cannot quietly widen @@ -927,6 +939,7 @@ export async function buildFullBackupPayload( const awardsSection: AwardsBackupSection = awards; const environmentSection: EnvironmentBackupSection = environment; const ecgSection: EcgBackupSection = ecg; + const accountSettingsSection: AccountSettingsBackupSection = accountSettings; // The three unbounded tables, either read into arrays or left as markers // the streaming writer will pull through. One place decides, so the payload @@ -985,6 +998,7 @@ export async function buildFullBackupPayload( documentQuotaBytes: appSettings.documentQuotaBytes.toString(), } : null, + ...accountSettingsSection, measurements: bulk.measurements, medications: medications.map((m) => ({ ...(disasterRecovery diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index da1e3baca..8b80622d1 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -89,6 +89,11 @@ import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { restoreOnboardingData } from "@/lib/export/onboarding-backup"; +import { + restoreAccountSettings, + type AccountSettingsRestoreResult, +} from "@/lib/export/account-settings-backup"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; import { restoreSensitiveData } from "@/lib/export/sensitive-backup"; @@ -675,6 +680,7 @@ export async function restoreBackup( expiredTombstonesSkipped: number; cleared: RestoreResponse["cleared"]; skipped: RestoreSkipSummary; + accountSettings: AccountSettingsRestoreResult; }; // The category side table is created lazily. Doing that here, before the // transaction, keeps its DDL (which takes a table lock) out of a @@ -1785,6 +1791,21 @@ export async function restoreBackup( payload, ); + // The account's own settings, onto the account row, column by column. + // Only what `USER_COLUMN_BACKUP_CLASS` calls a setting can be written; + // identity, credentials and this host's bookkeeping on the row stay as + // the receiving account has them. A value this host will not take is + // left out and named in the skip report. Both ends live in + // `src/lib/export/account-settings-backup.ts`. + const accountSettingsResult = await restoreAccountSettings( + tx, + ownerId, + payload, + ); + for (const column of accountSettingsResult.refused) { + skips.push({ catalogue: "accountSetting", key: column, links: 1 }); + } + reportSection("labs"); const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); @@ -2455,6 +2476,7 @@ export async function restoreBackup( cleared, skipped: summarizeRestoreSkips(skips), expiredTombstonesSkipped, + accountSettings: accountSettingsResult, }; }, { @@ -2532,7 +2554,8 @@ export async function restoreBackup( }); } - const { cleared, skipped, expiredTombstonesSkipped } = outcome; + const { cleared, skipped, expiredTombstonesSkipped, accountSettings } = + outcome; const measurementsRestored = streamed.measurementCount - expiredTombstonesSkipped; annotate({ @@ -2646,6 +2669,9 @@ export async function restoreBackup( // the trail has to be able to answer it later. restoreInstanceSettings, cleared, + // How many of the account's own settings came back. The ones that did + // not are named in `skipped` under `accountSetting`. + accountSettingsApplied: accountSettings.applied, // The durable half of the report. The response reaches whoever was // looking at the screen; the audit row is still here next week when // someone asks why a day-log lost a symptom. @@ -2685,6 +2711,14 @@ export async function restoreBackup( // The complete transaction succeeded; only now evict owner-scoped caches. invalidateUserData(ownerId); + // A restored unit preference leaves the stored status notes and period + // narratives quoting the old unit, the same gap the settings routes close + // on a unit change, closed the same way. Fire-and-forget: the restore has + // committed and never fails over a re-warm. + if (accountSettings.unitsChanged) { + void refreshTextsAfterUnitChange(ownerId); + } + const response: RestoreResponse = { restored: true, summary, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 11ffe61d7..5e98d1b4b 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -139,6 +139,13 @@ * one rather than left to assume it worked. It is filed when a delegate files * a visit against a preventive-care checkup their grant does not reach — the * visit saves, the checkup stays due, and the response says so. + * + * The fourteenth, `accountSetting`, names one of the account's own settings + * the file carried and this host would not write: an AI endpoint on a private + * address this host has not allowed, a language or unit this release does not + * know, a threshold out of range, an avatar the upload would refuse. Its key + * is the column (or `column.part` for one band or one site), never the value. + * The account keeps what it had for that setting; everything else comes back. */ export type SkippedCatalogue = | "cycleSymptom" @@ -157,7 +164,8 @@ export type SkippedCatalogue = | "ecgReference" | "medicationTarget" | "scheduleRevisionLink" - | "checkupClosure"; + | "checkupClosure" + | "accountSetting"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index e6412d98f..0613a290d 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1728,6 +1728,85 @@ const ecgRecordingBackupSchema = z }) .passthrough(); +/** + * The account's own settings: the `User` columns `USER_COLUMN_BACKUP_CLASS` + * calls `SETTING`, by column name. Never a credential, an identity column or + * the host's own bookkeeping on the row. + * + * Every field is optional: a column a file does not carry leaves the account + * as it is, so a file from a release with fewer setting columns restores + * what it has. Types are checked here; whether a value is one this host will + * write (a known language, an allowed AI endpoint, an image the upload would + * accept) is decided by the restore, which drops and names what it refuses + * rather than refusing the whole account over one preference. + * + * The insurance number arrives as `insuranceNumberEncrypted` (ciphertext) in + * a disaster-recovery file and as `insuranceNumber` (readable) in a portable + * one, the split every other sealed column uses. + */ +const accountSettingsBackupSchema = z + .object({ + heightCm: z.number().nullable().optional(), + dateOfBirth: isoDateTime.nullable().optional(), + gender: z.string().nullable().optional(), + hasDiabetes: z.boolean().optional(), + displayName: z.string().nullable().optional(), + fullName: z.string().nullable().optional(), + insurerName: z.string().nullable().optional(), + insuranceNumber: z.string().nullable().optional(), + insuranceNumberEncrypted: z.string().nullable().optional(), + insurerIkNumber: z.string().nullable().optional(), + lastReportPracticeName: z.string().nullable().optional(), + avatarBytes: base64BytesSchema.nullable().optional(), + avatarContentType: z.string().nullable().optional(), + avatarUpdatedAt: isoDateTime.nullable().optional(), + homeLat: z.number().nullable().optional(), + homeLon: z.number().nullable().optional(), + homeLabel: z.string().nullable().optional(), + homeTimezone: z.string().nullable().optional(), + homeSince: isoDateTime.nullable().optional(), + timezone: z.string().optional(), + locale: z.string().nullable().optional(), + unitPreference: z.string().nullable().optional(), + glucoseUnit: z.string().nullable().optional(), + timeFormat: z.string().optional(), + dateFormat: z.string().optional(), + modulePreferencesJson: z.unknown().optional(), + thresholdsJson: z.unknown().optional(), + healthScoreConfigJson: z.unknown().optional(), + sourcePriorityJson: z.unknown().optional(), + dashboardWidgetsJson: z.unknown().optional(), + insightsLayoutJson: z.unknown().optional(), + medicationListLayoutJson: z.unknown().optional(), + moodTagLayoutJson: z.unknown().optional(), + reportSelectionJson: z.unknown().optional(), + globalExcludedInjectionSites: z.array(z.string()).optional(), + healthKitConfigJson: z.unknown().optional(), + notificationPrefs: z.unknown().optional(), + moodReminderEnabled: z.boolean().optional(), + aiProvider: z.string().nullable().optional(), + aiModel: z.string().nullable().optional(), + aiBaseUrl: z.string().nullable().optional(), + aiCompatBaseUrl: z.string().nullable().optional(), + aiCompatModel: z.string().nullable().optional(), + aiProviderChain: z.unknown().optional(), + aiResponseTimeoutSeconds: z.number().int().nullable().optional(), + useCentralCodex: z.boolean().optional(), + insightsPrivacyMode: z.string().optional(), + insightsExcludeMetrics: z.array(z.string()).optional(), + disableCoach: z.boolean().optional(), + coachPrefsJson: z.unknown().optional(), + documentsAutoAiRead: z.boolean().optional(), + labsLocalOcrEnabled: z.boolean().optional(), + onboardingCompletedAt: isoDateTime.nullable().optional(), + onboardingTourCompleted: z.boolean().optional(), + onboardingTourProgressJson: z.unknown().optional(), + disclaimerAcknowledgedAt: isoDateTime.nullable().optional(), + disclaimerAcknowledgedVersion: z.string().nullable().optional(), + passkeyUpgradeNudgeDismissed: z.boolean().optional(), + }) + .passthrough(); + const backupManifestSchema = z .object({ documents: z @@ -1750,6 +1829,9 @@ export const backupPayloadSchema = z exportedAt: isoDateTime, userId: z.string().min(1), appSettings: appSettingsBackupSchema.nullable().default(null), + // The account's own settings. Null in every file written before the + // section existed, and a null restores by leaving the account row alone. + accountSettings: accountSettingsBackupSchema.nullable().default(null), measurements: z.array(measurementSchema).default([]), medications: z.array(medicationSchema).default([]), intakeEvents: z.array(intakeEventSchema).default([]), 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([]); + }); +});