Show what a backup contains before the operator types RESTORE

The restore dialog asked for its typed confirmation on nothing but a
filename and a date; the counts only surfaced in the audit row after the
rows were already replaced. A read-only summary endpoint now decrypts and
schema-validates the stored file through the same helpers the restore
itself runs and answers the summarizeBackup counts, and the dialog shows
them — per section, with an other-records line so a future summary key
can never silently vanish from the preview. A failed preview states its
own absence and never blocks the restore.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:51:40 +02:00
1 parent 90bad17cc8
commit 9d64eb6aa2
10 files changed
+403

No files matched your search

@@ -0,0 +1,95 @@
/**
* v1.37.20 — restore preview: the summary read decrypts and validates the
* stored file through the SAME helpers the restore uses, and only ever
* reads. Watched red: written against the route before its annotate landed;
* the not-found and decrypt-failure arms were asserted first.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
HttpError: class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const findUniqueMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) },
},
}));
const decryptMock = vi.fn();
vi.mock("@/lib/crypto", () => ({
decrypt: (...a: unknown[]) => decryptMock(...a),
}));
const parseMock = vi.fn();
const summarizeMock = vi.fn();
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: vi.fn(() => true),
}));
import { GET } from "../route";
const request = new NextRequest(
"http://localhost/api/admin/backups/b1/summary",
);
const params = { params: Promise.resolve({ id: "b1" }) };
beforeEach(() => {
vi.clearAllMocks();
findUniqueMock.mockResolvedValue({
id: "b1",
userId: "u1",
data: "ciphertext",
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
});
decryptMock.mockReturnValue("{}");
parseMock.mockReturnValue({ schemaVersion: 3 });
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
});
describe("GET /api/admin/backups/[id]/summary", () => {
it("answers the file's counts without writing anything", async () => {
const res = await GET(request, params);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 });
expect(body.data.owner).toBe("self-hoster");
});
it("404s an unknown backup id", async () => {
findUniqueMock.mockResolvedValue(null);
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
});
it("degrades to an error response when the file cannot be decrypted", async () => {
decryptMock.mockImplementation(() => {
throw new Error("bad key");
});
const res = await GET(request, params);
expect(res.status).toBe(500);
});
it("422s a file that fails schema validation", async () => {
parseMock.mockImplementation(() => {
throw new Error("nope");
});
const res = await GET(request, params);
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,79 @@
/**
* v1.37.20 — restore preview: what a backup file contains, BEFORE the
* operator confirms a restore.
*
* The restore dialog used to ask for the typed confirmation on nothing but
* a filename and a date; the counts only appeared in the audit row after
* the rows were already replaced. This read decrypts and schema-validates
* the stored file exactly the way the restore itself does (same decrypt,
* same `parseBackupPayload`, same compatibility gate) and answers with the
* `summarizeBackup` counts — so what the operator confirms is what the
* restore will write, derived from the same code path. Read-only; nothing
* is touched.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
parseBackupPayload,
summarizeBackup,
} from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user: admin } = await requireAdmin();
void admin;
const { id } = await params;
const backup = await prisma.dataBackup.findUnique({
where: { id },
include: { user: { select: { id: true, username: true } } },
});
if (!backup) {
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
try {
plaintext = decrypt(backup.data);
} catch {
return apiError("Failed to decrypt backup payload", 500);
}
let payload;
try {
payload = parseBackupPayload(plaintext);
} catch {
return apiError("Backup payload failed schema validation", 422);
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
return apiError(
`Backup schema version ${payload.schemaVersion} is not restorable by this release`,
422,
);
}
const summary = summarizeBackup(payload);
annotate({
action: { name: "admin.backups.summary" },
meta: { backupId: id, ownerId: backup.userId },
});
return apiSuccess({
summary,
owner: backup.user?.username ?? null,
createdAt: backup.createdAt.toISOString(),
});
},
);
+124
View File
@@ -68,6 +68,46 @@ import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
* Mirrors the wipe dialog's pattern but adds the typed gate because the
* blast radius is bigger (re-creates rows, not just deletes).
*/
/**
* v1.37.20 — the sections a restore preview names, in render order. Each
* entry sums one or more `BackupSummary` counters under one human label;
* every counter NOT named here still reaches the operator through the
* trailing "other records" line, so a future summary key can be forgotten
* here without silently vanishing from the preview.
*/
const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{
labelKey: string;
keys: readonly string[];
}> = [
{ labelKey: "previewMeasurements", keys: ["measurements"] },
{
labelKey: "previewMedications",
keys: ["medications", "intakeEvents", "medicationSideEffects"],
},
{ labelKey: "previewMood", keys: ["moodEntries"] },
{ labelKey: "previewLabs", keys: ["labResults", "biomarkers"] },
{ labelKey: "previewDocuments", keys: ["documents"] },
{ labelKey: "previewWorkouts", keys: ["workouts"] },
{ labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] },
{
labelKey: "previewVisits",
keys: ["encounters", "practitioners", "encounterLinks"],
},
{
labelKey: "previewVaccinations",
keys: ["vaccinations", "vaccinationLinks"],
},
{
labelKey: "previewReminders",
keys: ["measurementReminders", "measurementReminderEvents"],
},
{
labelKey: "previewCustomMetrics",
keys: ["customMetrics", "customMetricEntries"],
},
{ labelKey: "previewHealthScores", keys: ["healthScoreRecords"] },
];
function RestoreRowDialog({
row,
pending,
@@ -83,6 +123,39 @@ function RestoreRowDialog({
const [typed, setTyped] = useState("");
const matched = typed.trim() === "RESTORE";
// v1.37.20 — restore preview: fetch what the file contains the moment the
// dialog opens, so the typed confirmation is an informed one. Derived from
// the same decrypt + schema path the restore itself runs. A failed preview
// never blocks the restore — it states its own absence instead.
const preview = useQuery({
queryKey: queryKeys.adminBackupSummary(row.id),
queryFn: () =>
apiGet<{ summary: Record<string, unknown> }>(
`/api/admin/backups/${row.id}/summary`,
),
enabled: open,
staleTime: 60_000,
});
const numericSummary: Record<string, number> = {};
if (preview.data?.summary) {
for (const [key, value] of Object.entries(preview.data.summary)) {
if (typeof value === "number") numericSummary[key] = value;
}
}
const namedKeys = new Set(
RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys),
);
const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({
labelKey: section.labelKey,
count: section.keys.reduce(
(sum, key) => sum + (numericSummary[key] ?? 0),
0,
),
})).filter((entry) => entry.count > 0);
const otherCount = Object.entries(numericSummary)
.filter(([key]) => !namedKeys.has(key))
.reduce((sum, [, value]) => sum + value, 0);
return (
<AlertDialog
open={open}
@@ -121,6 +194,57 @@ function RestoreRowDialog({
})}
</AlertDialogDescription>
</AlertDialogHeader>
{/* v1.37.20 — what the file contains, before the typed gate. */}
<div
className="rounded-md border p-3 text-sm"
data-slot="restore-preview"
>
<p className="text-muted-foreground mb-1.5 font-medium">
{t("admin.section.backups.previewTitle")}
</p>
{preview.isLoading && (
<p className="text-muted-foreground">
{t("admin.section.backups.previewLoading")}
</p>
)}
{preview.isError && (
<p className="text-muted-foreground">
{t("admin.section.backups.previewUnavailable")}
</p>
)}
{preview.isSuccess && (
<ul className="grid list-none grid-cols-1 gap-x-4 gap-y-0.5 p-0 sm:grid-cols-2">
{previewRows.map((entry) => (
<li
key={entry.labelKey}
className="flex items-baseline justify-between gap-2"
>
<span className="text-muted-foreground">
{t(`admin.section.backups.${entry.labelKey}`)}
</span>
<span className="tabular-nums">
{fmt.integer(entry.count)}
</span>
</li>
))}
{otherCount > 0 && (
<li className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">
{t("admin.section.backups.previewOther")}
</span>
<span className="tabular-nums">
{fmt.integer(otherCount)}
</span>
</li>
)}
{previewRows.length === 0 && otherCount === 0 && (
<li className="text-muted-foreground">
{t("admin.section.backups.previewEmpty")}
</li>
)}
</ul>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`restore-prompt-${row.id}`}>
{t("admin.section.backups.restorePromptLabel")}
+3
View File
@@ -33,6 +33,9 @@ export const adminKeys = {
adminModuleAvailability: () =>
["admin", "settings", "module-availability"] as const,
adminBackups: () => ["admin", "backups"] as const,
/** v1.37.20 — restore preview: the counts a stored backup file carries. */
adminBackupSummary: (id: string) =>
["admin", "backups", id, "summary"] as const,
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,