mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Refuse a backup that claims a section it does not carry (#237)
A 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 did not "restore everything else". It emptied the vault, reported success, and left the operator to find the hole later. The whole file is refused instead, above the transaction that wipes the account, and the refusal names the section and says nothing was changed — an operator holding the only copy of an account cannot act on "restore failed". The sections ride the error envelope as meta.errorCode backup.section.missing, and the admin console holds them in a panel beside the skip report rather than a toast that outlives nothing. The manifest is the discriminator, and only the manifest. A portable export leaves the mental-health screeners and the consent receipts out on purpose and declares both as omitted; those files still restore. So does a file whose section is present and empty, which is what an account with no documents honestly writes, and so does every backup written before the manifest existed. The integration test proves the distinction with the same builder and the same deleted keys, differing only in what the manifest said. The route is deliberately outside the published contract — requireAdmin is cookie-only — so the OpenAPI document is unchanged.
This commit is contained in:
1 parent
e3be71f0e5
commit
283699db63
12 files changed
+789
-8
No files matched your search
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<RestoreRefusalNotice sections={sections} onDismiss={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useTranslations>["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 (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="restore-refusal-notice"
|
||||
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("admin.section.backups.restoreMissingTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.restoreMissingDescription")}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{sections.map((section) => (
|
||||
<li key={section} className="text-xs">
|
||||
{missingSectionLabel(section, t)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("admin.section.backups.restoreMissingRemedy")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last restore could not put back.
|
||||
*
|
||||
@@ -585,6 +700,13 @@ export function BackupsSection() {
|
||||
// until they dismiss it.
|
||||
const [skipped, setSkipped] = useState<RestoreSkipSummary | null>(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 `<RestoreRowDialog>` 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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RestoreRefusalNotice
|
||||
sections={refusedSections}
|
||||
onDismiss={() => setRefusedSections([])}
|
||||
/>
|
||||
|
||||
{skipped ? (
|
||||
<RestoreSkipReport
|
||||
report={skipped}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* A missing section and a declared omission are not the same thing.
|
||||
*
|
||||
* `findMissingBackupSections` is the rule that separates them, and the whole
|
||||
* value of the refusal it feeds rests on it getting the separation right in
|
||||
* both directions:
|
||||
*
|
||||
* - too lax and a portable file whose documents section is gone still runs,
|
||||
* which is the defect (#237) — the restore deletes the vault it is about to
|
||||
* rebuild and rebuilds nothing;
|
||||
* - too strict and it refuses the two files that legitimately leave a section
|
||||
* out. The portable export omits the mental-health screeners and the consent
|
||||
* receipts ON PURPOSE, declares both in the manifest as `included:
|
||||
* "omitted"`, and has to stay restorable. So does every backup written
|
||||
* before the manifest existed, which declares nothing at all.
|
||||
*
|
||||
* The manifest is the only discriminator, and the cases below say so by proving
|
||||
* the two things it is NOT: not the emptiness of the array (an account with no
|
||||
* documents honestly writes `[]`), and not a guess from the export's purpose,
|
||||
* which the payload never states.
|
||||
*
|
||||
* Mutation check: treat `[]` as missing and the empty-section case goes red;
|
||||
* drop the `included === "omitted"` skip and the declared-omission case goes
|
||||
* red; return early on a present-but-manifest-less file and the legacy case
|
||||
* stays green while the first case goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { findMissingBackupSections } from "../restore-skips";
|
||||
|
||||
/** A portable file's manifest, as `buildFullBackupPayload` writes it. */
|
||||
const PORTABLE_MANIFEST = {
|
||||
documents: { included: "metadata-only", note: "…" },
|
||||
workouts: { included: "summary-only", note: "…" },
|
||||
mentalHealth: { included: "omitted", note: "…" },
|
||||
consent: { included: "omitted", note: "…" },
|
||||
};
|
||||
|
||||
/** The disaster-recovery writer's manifest — every section carried. */
|
||||
const DR_MANIFEST = {
|
||||
documents: { included: "encrypted-content", note: "…" },
|
||||
workouts: { included: "summary-only", note: "…" },
|
||||
mentalHealth: { included: "full", note: "…" },
|
||||
consent: { included: "full", note: "…" },
|
||||
};
|
||||
|
||||
function portable(overrides: Record<string, unknown> = {}) {
|
||||
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<string, unknown>).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<string, unknown>).mentalHealthAssessments;
|
||||
delete (file as Record<string, unknown>).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<string, unknown>).mentalHealthAssessments;
|
||||
delete (file as Record<string, unknown>).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<string, unknown>).documents;
|
||||
delete (legacy as Record<string, unknown>).workouts;
|
||||
|
||||
expect(findMissingBackupSections(legacy)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports every missing section rather than the first", () => {
|
||||
const file = portable({ manifest: DR_MANIFEST });
|
||||
delete (file as Record<string, unknown>).documents;
|
||||
delete (file as Record<string, unknown>).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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown> }> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
async function restore(backupId: string) {
|
||||
return await POST(
|
||||
makeRequest(backupId) as unknown as Parameters<typeof POST>[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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user