diff --git a/messages/de.json b/messages/de.json index a2d2e6d9e..f7dd21931 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6922,7 +6922,14 @@ "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation", - "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume" + "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume", + "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.", + "restoreMissingDocuments": "Dokumente", + "restoreMissingWorkouts": "Workouts", + "restoreMissingMentalHealth": "Psychische Fragebögen", + "restoreMissingConsent": "Einwilligungsnachweise" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index ba0a5ac87..db7c97817 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6922,7 +6922,14 @@ "restoreSkippedPersonalRecordReference": "Personal best provenance", "restoreSkippedEcgReference": "ECG strip provenance", "restoreSkippedMedicationTarget": "Medication target analyte", - "restoreSkippedScheduleRevisionLink": "Schedule era succession" + "restoreSkippedScheduleRevisionLink": "Schedule era succession", + "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.", + "restoreMissingDocuments": "Documents", + "restoreMissingWorkouts": "Workouts", + "restoreMissingMentalHealth": "Mental-health screeners", + "restoreMissingConsent": "Consent receipts" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 632a9f9ed..3562ce5b7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6922,7 +6922,14 @@ "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal", "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento", - "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta" + "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta", + "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.", + "restoreMissingDocuments": "Documentos", + "restoreMissingWorkouts": "Entrenamientos", + "restoreMissingMentalHealth": "Cuestionarios de salud mental", + "restoreMissingConsent": "Comprobantes de consentimiento" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 5e03401f1..5faaecd7a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6922,7 +6922,14 @@ "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel", "restoreSkippedEcgReference": "Provenance d'un tracé ECG", "restoreSkippedMedicationTarget": "Analyte cible d'un médicament", - "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma" + "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma", + "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.", + "restoreMissingDocuments": "Documents", + "restoreMissingWorkouts": "Séances", + "restoreMissingMentalHealth": "Questionnaires de santé mentale", + "restoreMissingConsent": "Preuves de consentement" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index eb02d4511..85ad8bcf1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6922,7 +6922,14 @@ "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale", "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco", - "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema" + "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema", + "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.", + "restoreMissingDocuments": "Documenti", + "restoreMissingWorkouts": "Allenamenti", + "restoreMissingMentalHealth": "Questionari sulla salute mentale", + "restoreMissingConsent": "Ricevute di consenso" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index 18514d304..7e83719f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6922,7 +6922,14 @@ "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", "restoreSkippedMedicationTarget": "Analit docelowy leku", - "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu" + "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu", + "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.", + "restoreMissingDocuments": "Dokumenty", + "restoreMissingWorkouts": "Treningi", + "restoreMissingMentalHealth": "Kwestionariusze zdrowia psychicznego", + "restoreMissingConsent": "Potwierdzenia zgody" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index f15f3bc8a..ec71b6817 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -40,6 +40,7 @@ import { import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; import { + findMissingBackupSections, recordUnknownKeys, summarizeRestoreSkips, type RestoreSkipLog, @@ -193,9 +194,14 @@ const handler = apiHandler( return apiError("Failed to decrypt backup payload", 500); } + // Parsed once and kept, because the schema's per-section `.default([])` + // erases the difference between a section that is absent and one that is + // empty — and that difference is what the completeness check below reads. + let raw: unknown; let payload; try { - payload = parseBackupPayload(plaintext); + raw = JSON.parse(plaintext); + payload = parseBackupPayload(raw); } catch (err) { await auditLog("admin.backups.restore.failed", { userId: admin.id, @@ -289,6 +295,39 @@ const handler = apiHandler( }, }); + // A section the file's own manifest says it carries, and does not. + // + // The restore's first act is to delete the class it is about to rebuild, so + // an absent section is not a gap in the file — it is an erasure of that part + // of the account, reported as a success. #237: a portable file whose + // document payload was gone emptied the vault and said nothing. The whole + // file is refused instead, above the transaction, for the same reason the + // metadata-only check below sits there. + // + // A DECLARED omission is not this. The portable export leaves the screener + // administrations and the consent receipts out on purpose and says so in the + // manifest; `findMissingBackupSections` reads the manifest and not the + // emptiness of an array, so those files stay restorable. So does every file + // written before the manifest existed, which declares nothing at all. + const missingSections = findMissingBackupSections(raw); + if (missingSections.length > 0) { + await auditLog("admin.backups.restore.failed", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { + backupId: id, + ownerId, + reason: "section_missing", + sections: missingSections, + }, + }); + return apiError( + `Backup is missing ${missingSections.join(", ")} — the file's own manifest says it carries ${missingSections.length === 1 ? "that section" : "those sections"}. Nothing was changed. Use a disaster-recovery snapshot, or export the account again.`, + 422, + { errorCode: "backup.section.missing", sections: missingSections }, + ); + } + // Portable exports intentionally omit document ciphertext. They remain // valid upload/download artifacts, but cannot be used to manufacture an // InboundDocument row. A DR restore must fail before any delete rather diff --git a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx new file mode 100644 index 000000000..d818af957 --- /dev/null +++ b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx @@ -0,0 +1,111 @@ +/** + * A refused backup has to tell the operator what to do next. + * + * The server refuses a file whose manifest claims a section the file does not + * carry, before it deletes anything. That refusal reaches a person through the + * `meta.errorCode` on the error envelope, and a bare "restore failed" toast + * would waste it: the operator on the other end is frequently holding the only + * copy of an account, and the two facts they can act on — which section is + * missing, and that nothing was changed — have to stay on screen. + * + * Pinned here: + * 1. the missing sections are named, not counted, + * 2. the notice says nothing was changed and what to do instead, + * 3. no sections means no notice — a panel that appears after every restore + * trains the operator to ignore it, + * 4. the copy resolves in more than one locale, + * 5. `missingSectionsOf` keys on the errorCode and not on the message text, + * so rewording the server's prose cannot silently remove the panel. + * + * Mutation check: render a count instead of the list and case 1 goes red; drop + * the remedy line and case 2 goes red; return the notice unconditionally and + * case 3 goes red; match on the message string in `missingSectionsOf` and case + * 5 goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + RestoreRefusalNotice, + missingSectionsOf, +} from "@/components/admin/backups-section"; +import { ApiError } from "@/lib/api/api-fetch"; +import type { MissingBackupSection } from "@/lib/export/restore-skips"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/backups", +})); + +function render( + sections: MissingBackupSection[], + locale: "en" | "de" = "en", +): string { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe("RestoreRefusalNotice", () => { + it("names every missing section", () => { + const html = render(["documents", "consent"]); + + expect(html).toContain('data-slot="restore-refusal-notice"'); + expect(html).toContain("Documents"); + expect(html).toContain("Consent receipts"); + }); + + it("says nothing was changed, and what to do instead", () => { + const html = render(["documents"]); + + expect(html).toContain("Nothing was changed"); + expect(html).toContain("disaster-recovery snapshot"); + }); + + it("renders nothing when no section was missing", () => { + expect(render([])).toBe(""); + }); + + it("speaks the operator's language", () => { + const german = render(["documents", "mentalHealth"], "de"); + + expect(german).toContain("Dokumente"); + expect(german).toContain("Psychische Fragebögen"); + expect(german).toContain("Es wurde nichts geändert"); + }); +}); + +describe("missingSectionsOf", () => { + it("reads the sections off the refusal envelope", () => { + const err = new ApiError("Backup is missing documents", 422, { + errorCode: "backup.section.missing", + sections: ["documents"], + }); + + expect(missingSectionsOf(err)).toEqual(["documents"]); + }); + + it("ignores an error whose message merely looks like one", () => { + // The message is prose the server may reword. Only the code counts. + const err = new ApiError("Backup is missing documents", 422, { + errorCode: "something_else", + sections: ["documents"], + }); + + expect(missingSectionsOf(err)).toEqual([]); + expect(missingSectionsOf(new Error("Backup is missing documents"))).toEqual( + [], + ); + }); + + it("survives a refusal with no section list", () => { + const err = new ApiError("Refused", 422, { + errorCode: "backup.section.missing", + }); + + expect(missingSectionsOf(err)).toEqual([]); + }); +}); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 598d6aab9..5e2243a91 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -49,11 +49,18 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; import type { + MissingBackupSection, RestoreSkipSummary, SkippedCatalogue, } from "@/lib/export/restore-skips"; import { getApiErrorMessage } from "./_shared"; -import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { + ApiError, + apiFetch, + apiFetchRaw, + apiGet, + apiPost, +} from "@/lib/api/api-fetch"; /** * Typed-confirmation dialog for restore. The destructive Restore button @@ -374,6 +381,114 @@ function catalogueLabel( return unlabelled; } +/** + * Which section a refused file claimed and did not carry, as a label. + * + * Spelled out with literal `t()` calls for the same reason as + * `catalogueLabel` above — an interpolated key is invisible to + * `i18n-call-site-coverage.test.ts`, and a renamed section would ship as raw + * dot notation. The `never` at the foot is what makes the compiler say the + * chain is exhaustive. + */ +function missingSectionLabel( + section: MissingBackupSection, + t: ReturnType["t"], +) { + if (section === "documents") { + return t("admin.section.backups.restoreMissingDocuments"); + } + if (section === "workouts") { + return t("admin.section.backups.restoreMissingWorkouts"); + } + if (section === "mentalHealth") { + return t("admin.section.backups.restoreMissingMentalHealth"); + } + if (section === "consent") { + return t("admin.section.backups.restoreMissingConsent"); + } + const unlabelled: never = section; + return unlabelled; +} + +/** + * Why a file was refused, and what to do about it. + * + * Held on screen rather than left to the toast, exactly like the skip report + * below it. The operator refused here is often holding the only copy of an + * account; "restore failed" for four seconds is not enough to act on, and the + * two facts that let them act — which section the file claimed and did not + * carry, and that nothing was changed — have to survive long enough to be read. + */ +/** + * The refused sections carried on an `ApiError`, or none. + * + * Keyed on `meta.errorCode` and not on the message text: the message is prose + * the server may reword, and matching on it would turn a copy edit into a + * silent loss of the panel. + */ +export function missingSectionsOf(err: unknown): MissingBackupSection[] { + if (!(err instanceof ApiError)) return []; + if (err.meta?.errorCode !== "backup.section.missing") return []; + const sections = err.meta?.sections; + if (!Array.isArray(sections)) return []; + return sections.filter( + (section): section is MissingBackupSection => typeof section === "string", + ); +} + +export function RestoreRefusalNotice({ + sections, + onDismiss, +}: { + sections: MissingBackupSection[]; + onDismiss: () => void; +}) { + const { t } = useTranslations(); + if (sections.length === 0) return null; + return ( +
+
+
+
+ +
+
+ ); +} + /** * What the last restore could not put back. * @@ -585,6 +700,13 @@ export function BackupsSection() { // until they dismiss it. const [skipped, setSkipped] = useState(null); + // Why the last restore was refused before it touched anything. Same reason + // the skip report is held rather than toasted: the operator has to be able to + // read it after the four seconds are up. + const [refusedSections, setRefusedSections] = useState< + MissingBackupSection[] + >([]); + // Restore: typed-confirmation dialog. The mutation is keyed by row id // and used inline by `` below — keeping the // mutation here lets the parent invalidate the list query on success. @@ -604,6 +726,7 @@ export function BackupsSection() { // A restore that dropped links is not a plain success and must not read // as one. The count and the exact keys go on screen; the same report is // in the audit row for anyone asking later. + setRefusedSections([]); const report = data.skipped; if (report && report.links > 0) { setSkipped(report); @@ -622,6 +745,16 @@ export function BackupsSection() { queryClient.invalidateQueries(); }, onError: (err) => { + // The server refuses an incomplete file before it deletes anything, and + // names the sections in `meta`. That is the one restore failure the + // operator can act on, so it gets the panel rather than only a toast. + const sections = missingSectionsOf(err); + if (sections.length > 0) { + setRefusedSections(sections); + toast.error(t("admin.section.backups.restoreMissingTitle")); + return; + } + setRefusedSections([]); toast.error( err instanceof Error && err.message ? err.message @@ -745,6 +878,11 @@ export function BackupsSection() { + setRefusedSections([])} + /> + {skipped ? ( = {}) { + return { + schemaVersion: "2", + exportedAt: "2026-08-01T00:00:00.000Z", + userId: "user-1", + manifest: PORTABLE_MANIFEST, + documents: [], + workouts: [], + mentalHealthAssessments: [], + consentReceipts: [], + ...overrides, + }; +} + +describe("findMissingBackupSections", () => { + it("names the documents section a portable file claims and does not carry", () => { + const file = portable(); + delete (file as Record).documents; + + expect(findMissingBackupSections(file)).toEqual(["documents"]); + }); + + it("finds nothing in a complete portable file", () => { + expect(findMissingBackupSections(portable())).toEqual([]); + }); + + it("does not read an empty section as a missing one", () => { + // An account with no documents writes `[]`, and that file is fine. Refusing + // it would make the check worse than the silence it replaces. + expect(findMissingBackupSections(portable({ documents: [] }))).toEqual([]); + }); + + it("leaves a DECLARED omission alone", () => { + // The portable export omits both of these on purpose and says so. This is + // the boundary: the keys are gone, and the file is still restorable. + const file = portable(); + delete (file as Record).mentalHealthAssessments; + delete (file as Record).consentReceipts; + + expect(findMissingBackupSections(file)).toEqual([]); + }); + + it("names the same two sections when the manifest says they ARE carried", () => { + // Identical payload to the case above, one word different in the manifest. + // That word is the whole rule. + const file = portable({ manifest: DR_MANIFEST }); + delete (file as Record).mentalHealthAssessments; + delete (file as Record).consentReceipts; + + expect(findMissingBackupSections(file)).toEqual([ + "mentalHealth", + "consent", + ]); + }); + + it("claims nothing about a file with no manifest", () => { + // Every backup written before the manifest existed. It declares nothing, so + // nothing is missing from it, and it stays restorable. + const legacy = portable({ manifest: null }); + delete (legacy as Record).documents; + delete (legacy as Record).workouts; + + expect(findMissingBackupSections(legacy)).toEqual([]); + }); + + it("reports every missing section rather than the first", () => { + const file = portable({ manifest: DR_MANIFEST }); + delete (file as Record).documents; + delete (file as Record).workouts; + + expect(findMissingBackupSections(file)).toEqual(["documents", "workouts"]); + }); + + it("survives a payload that is not an object at all", () => { + expect(findMissingBackupSections(null)).toEqual([]); + expect(findMissingBackupSections("nope")).toEqual([]); + expect(findMissingBackupSections([])).toEqual([]); + }); +}); diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index ef8acb6c3..11ffe61d7 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -216,3 +216,91 @@ export function summarizeRestoreSkips(log: RestoreSkipLog): RestoreSkipSummary { links: catalogueKeys.reduce((total, entry) => total + entry.links, 0), }; } + +/** + * A section the file's own manifest says it carries, and does not. + * + * This is the other half of the honesty contract above, and it is deliberately + * the opposite answer. A skip drops one edge and names it, because the record + * it hangs off survives and giving the operator the rest is worth more than + * refusing them everything. A missing SECTION is not one edge — it is a whole + * class of the account, and the restore's first act is to delete the class it + * is about to rebuild. Restoring a file whose documents section is not there + * therefore does not lose a link. It empties the vault, tells the operator the + * restore succeeded, and leaves them to find out later. + * + * So this one refuses the file. Nothing is deleted, and the response names the + * section rather than saying "invalid backup", because an operator holding the + * only copy of an account needs to know whether the file is worthless or + * whether they picked the wrong one of two. + * + * ── The manifest is what tells a missing section from an omitted one ──────── + * + * A payload that omits a section on purpose is not broken, and refusing it + * would break the two exports that do it. `buildSensitiveBackupSection` leaves + * the screener administrations and the consent receipts out of a PORTABLE file + * — the first because the encrypted per-item answers include the PHQ-9 + * self-harm item, the second because a consent belongs to the operator it was + * given to — and says so in the manifest as `included: "omitted"`. That is a + * declared omission, and it stays restorable. + * + * The discriminator is therefore the manifest and nothing else. Not the emptiness + * of the array: an account with no documents honestly writes `[]`, and treating + * that as a loss would refuse a perfectly good file. Not the export's purpose + * either, which the payload does not state. Only "the file said it carries this, + * and the key is not there". + * + * A file with NO manifest at all declares nothing, so nothing is missing from + * it. Every backup written before the manifest existed is in that category and + * has to stay restorable. + */ +export type MissingBackupSection = + "documents" | "workouts" | "mentalHealth" | "consent"; + +/** + * Which payload key each manifest entry speaks for. + * + * `included: "omitted"` is the one value that means "not carried". Every other + * value the writers produce — `metadata-only`, `encrypted-content`, + * `summary-only`, `full` — describes HOW MUCH of the section travels, not + * whether the section is there, and all of them come with the key present. + */ +const MANIFEST_SECTIONS: ReadonlyArray<{ + manifestKey: MissingBackupSection; + payloadKey: string; +}> = [ + { manifestKey: "documents", payloadKey: "documents" }, + { manifestKey: "workouts", payloadKey: "workouts" }, + { manifestKey: "mentalHealth", payloadKey: "mentalHealthAssessments" }, + { manifestKey: "consent", payloadKey: "consentReceipts" }, +]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Sections the raw file claims and does not carry, in manifest order. + * + * Takes the RAW parsed JSON, not a `BackupPayload`. Every section key in + * `backupPayloadSchema` carries `.default([])`, so by the time the payload has + * been through the schema an absent section and an empty one are the same + * value — which is exactly the distinction this function exists to make. Run it + * on the object that came out of `JSON.parse`. + */ +export function findMissingBackupSections( + raw: unknown, +): MissingBackupSection[] { + if (!isRecord(raw)) return []; + const manifest = raw.manifest; + if (!isRecord(manifest)) return []; + + const missing: MissingBackupSection[] = []; + for (const { manifestKey, payloadKey } of MANIFEST_SECTIONS) { + const entry = manifest[manifestKey]; + if (!isRecord(entry)) continue; + if (entry.included === "omitted") continue; + if (raw[payloadKey] === undefined) missing.push(manifestKey); + } + return missing; +} diff --git a/tests/integration/backup-missing-section-restore.test.ts b/tests/integration/backup-missing-section-restore.test.ts new file mode 100644 index 000000000..89702af11 --- /dev/null +++ b/tests/integration/backup-missing-section-restore.test.ts @@ -0,0 +1,238 @@ +/** + * A file that claims a section it does not carry is refused whole (#237). + * + * The restore's first act is to delete the class of records it is about to + * rebuild. So a payload whose documents section is simply absent does not + * "restore everything else": it empties the vault, reports success, and leaves + * the operator to discover the hole later. The maintainer's decision is to + * refuse the file instead — above the transaction, like the metadata-only + * check beside it, so the account survives the refusal. + * + * The hard part is not the refusal. It is the boundary, and both halves are + * asserted here against the real route and real rows: + * + * MISSING — the manifest says the file carries documents, and the key is not + * there. Refused, named, nothing deleted. + * + * DECLARED OMITTED — the manifest says the file does NOT carry the + * mental-health screeners or the consent receipts, and the keys are not + * there. That is what every portable export writes, on purpose, and it has to + * restore. The two cases below are the same builder, the same account and the + * same deleted-key trick; the only difference is what the manifest said, which + * is exactly the claim being made. + * + * Mutation check: drop the `included === "omitted"` skip in + * `findMissingBackupSections` and the second case goes red. Move the refusal + * below the `prisma.$transaction(...)` call and the first case's surviving-rows + * assertions go red while its status assertion stays green — which is why the + * rows are asserted separately rather than trusted to follow from the status. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +/** + * An admin account with one measurement and nothing else, signed in. + * + * The measurement is the witness. It references nothing the refusal is about, + * so it can only be lost to the wipe — which is what tells a refused restore + * apart from a destroyed account. + */ +async function seedOwner(username: string) { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { username, email: `${username}@example.test`, role: "ADMIN" }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + await prisma.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 79.2, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-11T06:15:00.000Z"), + }, + }); + return owner; +} + +/** + * A real portable export of that account, with the named keys cut out of the + * JSON before it is sealed. + * + * Cutting them from the RAW object rather than from a parsed payload is the + * whole point: every section in `backupPayloadSchema` carries `.default([])`, + * so a payload that has been through the schema no longer knows the difference + * between a section that was absent and one that was empty. + */ +async function sealPortableWithout( + ownerId: string, + keys: string[], +): Promise<{ backupId: string; manifest: Record }> { + const prisma = getPrismaClient(); + // No `purpose`, so the builder takes the portable branch — the file a person + // gets from "export my data". + const { payload } = await buildFullBackupPayload(prisma, ownerId); + const raw = JSON.parse(JSON.stringify(payload)) as Record; + for (const key of keys) delete raw[key]; + + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "MISSING_SECTION_RESTORE", + data: encrypt(JSON.stringify(raw)), + }, + }); + return { + backupId: backup.id, + manifest: raw.manifest as Record, + }; +} + +async function restore(backupId: string) { + return await POST( + makeRequest(backupId) as unknown as Parameters[0], + { params: Promise.resolve({ id: backupId }) }, + ); +} + +describe("a backup whose manifest claims a section the file does not carry", () => { + it("refuses the whole file, names the section, and changes nothing", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner("missing-section-owner"); + const { backupId, manifest } = await sealPortableWithout(owner.id, [ + "documents", + ]); + + // Prove the premise rather than assume it: the file still SAYS it carries + // documents. Without this the test would keep passing if the builder ever + // stopped writing the manifest entry. + expect( + (manifest.documents as { included?: string } | undefined)?.included, + ).toBe("metadata-only"); + + const response = await restore(backupId); + + expect(response.status).toBe(422); + const body = (await response.json()) as { + error: string; + meta?: { errorCode?: string; sections?: string[] }; + }; + expect( + body.error, + "the message names the section, so the operator can act on it", + ).toContain("documents"); + expect( + body.error, + "and says the account is intact, because that is the decision they need", + ).toContain("Nothing was changed"); + expect(body.meta?.errorCode).toBe("backup.section.missing"); + expect(body.meta?.sections).toEqual(["documents"]); + + // The refusal is only worth anything above the wipe. + expect( + await prisma.measurement.count({ where: { userId: owner.id } }), + "a refused restore must not have run the wipe", + ).toBe(1); + // A second, independent witness: the invalidation only fires at the end of + // a restore that actually happened. + expect(vi.mocked(invalidateUserData)).not.toHaveBeenCalled(); + + const auditRow = await prisma.auditLog.findFirstOrThrow({ + where: { action: "admin.backups.restore.failed" }, + orderBy: { createdAt: "desc" }, + }); + const details = JSON.parse(auditRow.details ?? "{}") as { + reason?: string; + sections?: string[]; + }; + expect(details.reason).toBe("section_missing"); + expect(details.sections).toEqual(["documents"]); + }); + + it("restores a file whose manifest DECLARES the same keys omitted", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner("declared-omission-owner"); + // The two sections a portable export leaves out on purpose, cut from the + // file exactly the way `documents` was cut above. + const { backupId, manifest } = await sealPortableWithout(owner.id, [ + "mentalHealthAssessments", + "consentReceipts", + ]); + + expect( + (manifest.mentalHealth as { included?: string } | undefined)?.included, + ).toBe("omitted"); + expect( + (manifest.consent as { included?: string } | undefined)?.included, + ).toBe("omitted"); + + const response = await restore(backupId); + + expect( + response.status, + "a declared omission is not a missing section", + ).toBe(200); + const body = (await response.json()) as { data: { restored: boolean } }; + expect(body.data.restored).toBe(true); + // The restore genuinely ran: the measurement came back out of the file it + // was exported into, and the cache was evicted. + expect( + await prisma.measurement.count({ where: { userId: owner.id } }), + ).toBe(1); + expect(vi.mocked(invalidateUserData)).toHaveBeenCalledWith(owner.id); + }); +});