mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user