feat(admin): restore from backup with full data replacement + triple-confirm

POST /api/admin/backups/[id]/restore replaces the target user's
personal data with the contents of the snapshot. This is the riskiest
endpoint in the backup family, hardened with five independent gates:

  1. requireAdmin() — cookie-session admins only, never a Bearer token.
  2. Body must carry confirm: "RESTORE" (rejected with 422 + audit
     denial otherwise).
  3. UI gates the destructive button behind a typed-string match —
     three independent steps before fire (open dialog, read warning,
     type RESTORE).
  4. withIdempotency() wraps the handler so duplicate clicks with the
     same Idempotency-Key replay the original outcome instead of
     re-executing the destructive transaction.
  5. Pre-validation of measurement-type / measurement-source /
     intake-source enums OUTSIDE the transaction so a malformed
     payload can't half-wipe the user before failing the create step.

Restore scope mirrors the v1.4.14 wipe (DELETE /api/admin/data) plus
the mood-entries table the wipe didn't cover. AuditLog rows are
intentionally PRESERVED across the restore — the audit trail is the
last thing that should be touched by a destructive op.

Transaction:
  - Delete user's measurements, medications (cascades to schedules +
    intake events + reminder configs + telegram messages), mood
    entries, notification channels, push subscriptions, telegram
    scheduled deletions.
  - Re-create from payload: measurements (skipDuplicates), medications
    + their schedules in one create call, intake events keyed by
    medication name -> id (orphans dropped silently), mood entries
    (skipDuplicates).

Audit:
  - admin.backups.restore.start  (intent, before transaction)
  - admin.backups.restore        (success, after transaction)
  - admin.backups.restore.denied (5 reasons, no transaction ran)
  - admin.backups.restore.failed (decrypt / schema / enum / tx error)

UI:
  - New <RestoreRowDialog> subcomponent renders an AlertDialog gated
    on a typed-RESTORE Input. Description carries the target username
    and the snapshot timestamp so the admin sees exactly which
    backup they're about to restore. Confirm button stays disabled
    until typed string matches.
  - Restore button sits next to Download in each row's action column.
  - Idempotency-Key built client-side as restore-<rowId>-<uuid> to
    de-duplicate accidental double-clicks.

Tests: 3 new integration tests in
tests/integration/admin-backups-restore.test.ts covering the round-
trip (wipes pre-existing data + recreates from snapshot),
confirmation-token rejection, and 404 on unknown id. 21/21
integration tests pass; 69/69 unit tests on src/lib/validations and
src/components/admin still green.

Note for v1.4.16: the restore test queries `medication_schedules`
with explicit field selection because a sibling branch added
`days_of_week` to the schema without a matching migration; the
default `findMany` would 500 against the deployed test DB. When that
migration lands, the explicit select can be relaxed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:58:33 +02:00
1 parent a5b341b9b6
commit fe85c2c85f
5 files changed
+1011 -19

No files matched your search

+35 -1
View File
@@ -1109,6 +1109,15 @@
"withingsDisconnectDescription": "Die Verbindung zu Withings wird getrennt. Bereits synchronisierte Daten bleiben erhalten.",
"withingsConnect": "Mit Withings verbinden",
"withingsNoCredentials": "Bitte zuerst die API-Zugangsdaten oben eingeben, um Withings zu verbinden.",
"integrationStatus": {
"stateConnected": "Verbunden",
"stateError": "Fehler",
"stateReauthRequired": "Neu verbinden erforderlich",
"stateDisconnected": "Getrennt",
"consecutiveFailures": "{count}/{threshold} aufeinanderfolgende Fehler",
"lastSuccess": "Letzte erfolgreiche Synchronisierung",
"lastAttempt": "Letzter Versuch"
},
"apiTokens": "API-Tokens",
"apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).",
"tokenName": "Token-Name",
@@ -1257,6 +1266,22 @@
"rate_limited_self": "Zu viele Test-Anfragen",
"generic": "Test fehlgeschlagen"
}
},
"notificationStatus": {
"title": "Kanalzuverlässigkeit",
"description": "Live-Status jedes Benachrichtigungskanals — \"Automatisch deaktiviert\" bedeutet, HealthLog hat nach einem dauerhaften Fehler aufgehört, weiterzusenden.",
"emptyDescription": "Noch keine Kanäle konfiguriert. Lege unten einen Kanal an, um die Zustellung zu überwachen.",
"stateActive": "Aktiv",
"stateAutoDisabled": "Automatisch deaktiviert",
"stateSendingPaused": "Versand pausiert",
"stateManuallyDisabled": "Deaktiviert",
"lastSuccess": "Letzter erfolgreicher Versand",
"lastFailure": "Letzter Fehler",
"consecutiveFailures": "Fehler in Folge",
"disabledReason": "Grund",
"nextRetry": "Nächster Versuch",
"reEnable": "Wieder aktivieren",
"sendTest": "Test senden"
}
},
"admin": {
@@ -1562,7 +1587,16 @@
"uploadButton": "Datei hochladen",
"uploadHelp": "JSON-Datei passend zum aktuellen Backup-Schema. Max. 10 MB.",
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
"uploadFailed": "Upload fehlgeschlagen"
"uploadFailed": "Upload fehlgeschlagen",
"restore": "Wiederherstellen",
"restoreAria": "Backup für {username} wiederherstellen",
"restoreTitle": "Aus Backup wiederherstellen",
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.",
"restorePromptLabel": "Tippe RESTORE zum Fortfahren",
"restoreConfirm": "Jetzt wiederherstellen",
"restoreInProgress": "Wiederherstellung läuft...",
"restoreSuccess": "Wiederherstellung abgeschlossen",
"restoreFailed": "Wiederherstellung fehlgeschlagen"
},
"danger-zone": {
"title": "Gefahrenzone",
+35 -1
View File
@@ -1109,6 +1109,15 @@
"withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.",
"withingsConnect": "Connect with Withings",
"withingsNoCredentials": "Please enter your API credentials above to connect Withings.",
"integrationStatus": {
"stateConnected": "Connected",
"stateError": "Error",
"stateReauthRequired": "Reconnect required",
"stateDisconnected": "Disconnected",
"consecutiveFailures": "{count}/{threshold} consecutive failures",
"lastSuccess": "Last successful sync",
"lastAttempt": "Last attempt"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
"tokenName": "Token name",
@@ -1257,6 +1266,22 @@
"rate_limited_self": "Too many test requests",
"generic": "Test failed"
}
},
"notificationStatus": {
"title": "Channel reliability",
"description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.",
"emptyDescription": "No channels configured yet. Add a channel below to start tracking its delivery health.",
"stateActive": "Active",
"stateAutoDisabled": "Auto-disabled",
"stateSendingPaused": "Sending paused",
"stateManuallyDisabled": "Disabled",
"lastSuccess": "Last successful send",
"lastFailure": "Last failure",
"consecutiveFailures": "Consecutive failures",
"disabledReason": "Reason",
"nextRetry": "Next retry",
"reEnable": "Re-enable",
"sendTest": "Send test"
}
},
"admin": {
@@ -1562,7 +1587,16 @@
"uploadButton": "Upload file",
"uploadHelp": "JSON file matching the current backup schema. Max 10 MB.",
"uploadSuccess": "Backup uploaded ({count} records)",
"uploadFailed": "Upload failed"
"uploadFailed": "Upload failed",
"restore": "Restore",
"restoreAria": "Restore from backup for {username}",
"restoreTitle": "Restore from backup",
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.",
"restorePromptLabel": "Type RESTORE to continue",
"restoreConfirm": "Restore now",
"restoreInProgress": "Restoring...",
"restoreSuccess": "Restore complete",
"restoreFailed": "Restore failed"
},
"danger-zone": {
"title": "Danger Zone",
@@ -0,0 +1,431 @@
/**
* POST /api/admin/backups/[id]/restore — admin-only restore from backup.
*
* The riskiest endpoint in the backup family. Mirroring the v1.4.14 wipe
* scope (`DELETE /api/admin/data`), the restore replaces the **target
* user's** personal data with the contents of the snapshot:
*
* 1. Decrypt + parse the `DataBackup.data` blob.
* 2. In a single Prisma transaction:
* a. Delete the user's measurements, medications (cascades to
* schedules + intake events + telegram messages +
* reminder-phase configs), mood entries, notification channels,
* push subscriptions, telegram scheduled deletions.
* b. Re-create measurements, medications + schedules, intake
* events, mood entries from the payload.
* 3. Outside the transaction, write `admin.backups.restore` to the
* AuditLog so the trail outlives the operation. Audit rows are
* intentionally NOT touched by the restore (same contract as the
* wipe).
*
* Triple-confirm: the request body must carry `confirm: "RESTORE"`.
* The client also gates the form on a typed-string match so an
* accidental click cannot fire this.
*
* Idempotency: wrapped in `withIdempotency()` so retries with the same
* `Idempotency-Key` replay the original outcome instead of double-
* restoring (unlikely in practice — the second run would be a no-op
* because the user data already matches the backup — but this still
* prevents two parallel admin clicks from racing through).
*
* Phase B1 / criterion 3 of the v1.4.15 backup-completeness work.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { decrypt } from "@/lib/crypto";
import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import {
parseBackupPayload,
summarizeBackup,
type BackupSummary,
} from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
interface RestoreResponse {
restored: true;
summary: BackupSummary;
cleared: {
measurements: number;
medications: number;
intakeEvents: number;
moodEntries: number;
notificationChannels: number;
pushSubscriptions: number;
telegramScheduledDeletions: number;
};
}
/**
* Maps a measurement-type string from the backup back into the
* Prisma enum literal. Backups are written from `enum MeasurementType`
* (Postgres-mapped to lowercase column type), so the round-trip is
* normally identity. But the schema is a `passthrough` schema so a
* malformed type slips through Zod — guard it explicitly here so the
* restore fails fast instead of erroring deep in `prisma.create()`.
*/
const MEASUREMENT_TYPES = new Set([
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
]);
const MEASUREMENT_SOURCES = new Set(["MANUAL", "WITHINGS", "IMPORT"]);
const INTAKE_SOURCES = new Set(["WEB", "API", "REMINDER", "IMPORT"]);
const handler = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.backups.restore" }, meta: { id } });
let body: { confirm?: string } = {};
try {
body = (await request.json()) as { confirm?: string };
} catch {
return apiError("Invalid JSON body", 400);
}
if (body.confirm !== "RESTORE") {
await auditLog("admin.backups.restore.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "missing_confirmation", backupId: id },
});
return apiError(
"Confirmation token missing — body must include confirm: 'RESTORE'",
422,
);
}
const backup = await prisma.dataBackup.findUnique({
where: { id },
include: { user: { select: { id: true, username: true } } },
});
if (!backup) {
await auditLog("admin.backups.restore.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "not_found", backupId: id },
});
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
try {
plaintext = decrypt(backup.data);
} catch (err) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId: backup.userId,
reason: err instanceof Error ? err.message : "decrypt_failed",
},
});
return apiError("Failed to decrypt backup payload", 500);
}
let payload;
try {
payload = parseBackupPayload(plaintext);
} catch (err) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId: backup.userId,
reason: "schema_invalid",
message: err instanceof Error ? err.message : String(err),
},
});
return apiError("Backup payload failed schema validation", 500);
}
// The target of the restore is whoever the backup is for, NOT the
// admin running the operation. Make sure that user still exists —
// an upload referencing a since-deleted user would otherwise leave
// the operation half-done.
const ownerId = payload.userId;
const owner = await prisma.user.findUnique({
where: { id: ownerId },
select: { id: true, username: true },
});
if (!owner) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
reason: "owner_not_found",
},
});
return apiError(
`Backup owner '${ownerId}' no longer exists in this DB`,
422,
);
}
// Audit the *intent* before the transaction begins so the trail
// describing "an admin is about to restore <user>" survives even
// if the operation crashes midway.
await auditLog("admin.backups.restore.start", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
ownerUsername: owner.username,
snapshotExportedAt: payload.exportedAt,
},
});
// Pre-validate enum values OUTSIDE the transaction so a malformed
// payload doesn't half-wipe the user before failing the create
// step. Cheap up-front guard.
for (const m of payload.measurements) {
if (!MEASUREMENT_TYPES.has(m.type)) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
reason: "unknown_measurement_type",
type: m.type,
},
});
return apiError(`Unknown measurement type in backup: '${m.type}'`, 422);
}
if (m.source && !MEASUREMENT_SOURCES.has(m.source)) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
reason: "unknown_measurement_source",
source: m.source,
},
});
return apiError(
`Unknown measurement source in backup: '${m.source}'`,
422,
);
}
}
for (const e of payload.intakeEvents) {
if (e.source && !INTAKE_SOURCES.has(e.source)) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
reason: "unknown_intake_source",
source: e.source,
},
});
return apiError(`Unknown intake source in backup: '${e.source}'`, 422);
}
}
let cleared: RestoreResponse["cleared"];
try {
cleared = await prisma.$transaction(async (tx) => {
// ── delete-then-recreate, mirroring the wipe scope plus mood ──
// Order matters: child rows first (intake events / schedules
// cascade via FK, but we delete them explicitly to capture the
// count for the audit trail). Telegram-reminder messages cascade
// off `medication`, so the medication delete sweeps them.
const intake = await tx.medicationIntakeEvent.deleteMany({
where: { userId: ownerId },
});
const meds = await tx.medication.deleteMany({
where: { userId: ownerId },
});
const measurements = await tx.measurement.deleteMany({
where: { userId: ownerId },
});
const moods = await tx.moodEntry.deleteMany({
where: { userId: ownerId },
});
const channels = await tx.notificationChannel.deleteMany({
where: { userId: ownerId },
});
const subs = await tx.pushSubscription.deleteMany({
where: { userId: ownerId },
});
const tgDel = await tx.telegramScheduledDeletion.deleteMany({
where: { userId: ownerId },
});
// ── re-create from payload ──
if (payload.measurements.length > 0) {
await tx.measurement.createMany({
data: payload.measurements.map((m) => ({
userId: ownerId,
type: m.type as never, // already enum-validated above
value: m.value,
unit: m.unit,
source: (m.source ?? "MANUAL") as never,
measuredAt: new Date(m.measuredAt),
notes: m.notes ?? null,
})),
// Backups can replay the same data — the unique
// (userId, type, measuredAt, source) constraint catches
// accidental dupes. `skipDuplicates` keeps the restore
// idempotent across retries instead of throwing.
skipDuplicates: true,
});
}
// Medications + schedules — restore one at a time so we can
// wire schedules to their parent's freshly-generated id, AND
// build a name → id map for the intake events that follow.
const medByName = new Map<string, string>();
for (const m of payload.medications) {
const created = await tx.medication.create({
data: {
userId: ownerId,
name: m.name,
dose: m.dose,
active: m.active ?? true,
schedules: {
create: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
})),
},
},
});
// First write wins on duplicate names — keeps the round-trip
// deterministic against the restore-from-our-own-download case.
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
}
// Intake events — only restore the ones whose `medication` name
// resolved against a row we just created. Orphans (medications
// that were deleted before the snapshot but still referenced by
// intake events) are dropped silently — the alternative would
// be an FK-violation crash, which is worse than a slightly
// smaller history.
if (payload.intakeEvents.length > 0) {
const rows = payload.intakeEvents
.map((e) => {
const medId = medByName.get(e.medication);
if (!medId) return null;
return {
userId: ownerId,
medicationId: medId,
scheduledFor: new Date(e.scheduledFor),
takenAt: e.takenAt ? new Date(e.takenAt) : null,
skipped: e.skipped ?? false,
source: (e.source ?? "WEB") as never,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (rows.length > 0) {
await tx.medicationIntakeEvent.createMany({ data: rows });
}
}
if (payload.moodEntries.length > 0) {
await tx.moodEntry.createMany({
data: payload.moodEntries.map((e) => ({
userId: ownerId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags ?? null,
source: e.source ?? "MOODLOG",
moodLoggedAt: new Date(e.loggedAt),
})),
skipDuplicates: true,
});
}
return {
measurements: measurements.count,
medications: meds.count,
intakeEvents: intake.count,
moodEntries: moods.count,
notificationChannels: channels.count,
pushSubscriptions: subs.count,
telegramScheduledDeletions: tgDel.count,
};
});
} catch (err) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
reason: "transaction_failed",
message: err instanceof Error ? err.message : String(err),
},
});
return apiError(
`Restore transaction failed: ${err instanceof Error ? err.message : "unknown"}`,
500,
);
}
const summary = summarizeBackup(payload);
await auditLog("admin.backups.restore", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
ownerUsername: owner.username,
cleared,
restored: {
measurements: summary.measurements,
medications: summary.medications,
intakeEvents: summary.intakeEvents,
moodEntries: summary.moodEntries,
},
},
});
const response: RestoreResponse = {
restored: true,
summary,
cleared,
};
return apiSuccess(response);
},
);
// `withIdempotency` wraps the apiHandler so a duplicate retry with the
// same `Idempotency-Key` replays the original 200 instead of executing
// the destructive transaction twice. The default resolver picks up
// either the cookie session OR a Bearer token; for an admin endpoint
// only cookie sessions ever get past `requireAdmin()` upstream, but
// keeping the default keeps the contract uniform.
export const POST = withIdempotency(handler, async () => {
return defaultUserIdResolver();
});
+183 -17
View File
@@ -12,16 +12,135 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Download, Loader2, PlayCircle, Upload } from "lucide-react";
import {
Database,
Download,
History,
Loader2,
PlayCircle,
Upload,
} from "lucide-react";
import { useRef, useState, type ChangeEvent } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatDateTime } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BackupRow, BackupsList } from "@/app/api/admin/backups/route";
import { getApiErrorMessage } from "./_shared";
/**
* Typed-confirmation dialog for restore. The destructive Restore button
* is gated behind:
* 1. Opening the dialog (one click).
* 2. Reading the warning copy (Title + Description spell out exactly
* what's about to happen + which user it affects).
* 3. Typing the literal string `RESTORE` into the prompt input —
* anything else keeps the confirm button disabled.
*
* Three independent steps before the request fires == "triple confirm".
* Mirrors the wipe dialog's pattern but adds the typed gate because the
* blast radius is bigger (re-creates rows, not just deletes).
*/
function RestoreRowDialog({
row,
pending,
onConfirm,
}: {
row: BackupRow;
pending: boolean;
onConfirm: () => void;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const matched = typed.trim() === "RESTORE";
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setTyped("");
}}
>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={pending}
aria-label={t("admin.section.backups.restoreAria", {
username: row.username,
})}
>
{pending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<History className="mr-1 h-3.5 w-3.5" />
)}
{t("admin.section.backups.restore")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.backups.restoreTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.backups.restoreDescription", {
username: row.username,
when: formatDateTime(row.createdAt),
})}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor={`restore-prompt-${row.id}`}>
{t("admin.section.backups.restorePromptLabel")}
</Label>
<Input
id={`restore-prompt-${row.id}`}
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="RESTORE"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={!matched || pending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (!matched) return;
setOpen(false);
setTyped("");
onConfirm();
}}
>
{pending
? t("admin.section.backups.restoreInProgress")
: t("admin.section.backups.restoreConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function formatBytes(bytes: number, fmt: ReturnType<typeof useFormatters>) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) {
@@ -130,6 +249,44 @@ export function BackupsSection() {
if (file) upload.mutate(file);
}
// 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.
const restore = useMutation({
mutationFn: async (row: BackupRow) => {
// Idempotency-Key prevents a double-click from re-running the
// destructive transaction. Include the row id so two different
// backups can both be restored independently in the same minute.
const idempotencyKey = `restore-${row.id}-${crypto.randomUUID()}`;
const res = await fetch(`/api/admin/backups/${row.id}/restore`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ confirm: "RESTORE" }),
});
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
return (await res.json()).data as { restored: true };
},
onSuccess: () => {
toast.success(t("admin.section.backups.restoreSuccess"));
queryClient.invalidateQueries({ queryKey: ["admin", "backups"] });
// Restore touches every personal-data table; nuke the broader
// cache so dashboards / lists rebuild against the new state.
queryClient.invalidateQueries();
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.restoreFailed"),
);
},
});
async function handleDownload(row: BackupRow) {
setDownloadingId(row.id);
try {
@@ -294,22 +451,31 @@ export function BackupsSection() {
{formatDateTime(row.createdAt)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<Button
size="sm"
variant="outline"
disabled={downloadingId === row.id}
onClick={() => handleDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
>
{downloadingId === row.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={downloadingId === row.id}
onClick={() => handleDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
>
{downloadingId === row.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
<RestoreRowDialog
row={row}
pending={
restore.isPending && restore.variables?.id === row.id
}
onConfirm={() => restore.mutate(row)}
/>
</div>
</td>
</tr>
))}
@@ -0,0 +1,327 @@
/**
* Integration test for `POST /api/admin/backups/[id]/restore`.
*
* Round-trip:
* 1. Seed a user with measurements + medications + intake events +
* mood entries + notification channel + push subscription.
* 2. Snapshot the data into a `DataBackup` row using the same shape
* the worker writes.
* 3. Mutate the user's data (delete some, add others, change some).
* 4. Call the restore endpoint with `confirm: "RESTORE"`.
* 5. Assert the user's state matches the original snapshot.
*
* Plus negative paths:
* - 422 when `confirm` is missing.
* - 404 when the backup id is unknown.
* - audit-log entries for start, success, denial.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { encrypt } from "@/lib/crypto";
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),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedAdminSession() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "restore-admin",
email: "restore-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: {
userId: admin.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return admin;
}
function makeRequest(id: string, body: Record<string, unknown>) {
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/admin/backups/[id]/restore", () => {
it("replaces current user data with the snapshot contents", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
// ── arrange: build a backup of the admin's intended "good" state ──
const payload = {
schemaVersion: "1",
exportedAt: "2026-05-09T10:00:00.000Z",
userId: admin.id,
measurements: [
{
type: "WEIGHT",
value: 80.5,
unit: "kg",
measuredAt: "2026-05-08T07:00:00.000Z",
source: "MANUAL",
notes: null,
},
{
type: "PULSE",
value: 64,
unit: "bpm",
measuredAt: "2026-05-08T07:01:00.000Z",
source: "MANUAL",
notes: null,
},
],
medications: [
{
name: "Ramipril",
dose: "5mg",
active: true,
schedules: [
{
windowStart: "08:00",
windowEnd: "10:00",
label: "Morning",
dose: null,
},
],
},
],
intakeEvents: [
{
medication: "Ramipril",
scheduledFor: "2026-05-08T08:00:00.000Z",
takenAt: "2026-05-08T08:05:00.000Z",
skipped: false,
source: "WEB",
},
],
moodEntries: [
{
date: "2026-05-08",
mood: "GUT",
score: 4,
tags: null,
source: "MOODLOG",
loggedAt: "2026-05-08T20:00:00.000Z",
},
],
};
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "MANUAL_UPLOAD_RESTORE_TEST",
data: encrypt(JSON.stringify(payload)),
},
});
// ── seed *different* current state that should be wiped ──
await prisma.measurement.create({
data: {
userId: admin.id,
type: "WEIGHT",
value: 99.9,
unit: "kg",
measuredAt: new Date("2026-05-01T07:00:00.000Z"),
source: "MANUAL",
},
});
const oldMed = await prisma.medication.create({
data: {
userId: admin.id,
name: "Ibuprofen",
dose: "200mg",
schedules: {
create: { windowStart: "12:00", windowEnd: "13:00" },
},
},
});
await prisma.medicationIntakeEvent.create({
data: {
userId: admin.id,
medicationId: oldMed.id,
scheduledFor: new Date("2026-05-01T12:00:00.000Z"),
},
});
await prisma.moodEntry.create({
data: {
userId: admin.id,
date: "2026-05-01",
mood: "OKAY",
score: 3,
moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
},
});
await prisma.notificationChannel.create({
data: { userId: admin.id, type: "TELEGRAM", config: "encrypted:test" },
});
await prisma.pushSubscription.create({
data: {
userId: admin.id,
endpoint: "https://push.example/abc",
p256dh: "p",
auth: "a",
},
});
// ── act: restore ──
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
const req = makeRequest(backup.id, { confirm: "RESTORE" });
const res = await POST(req as unknown as Parameters<typeof POST>[0], {
params: Promise.resolve({ id: backup.id }),
});
expect(res.status).toBe(200);
// ── assert: state matches the snapshot ──
const measurements = await prisma.measurement.findMany({
where: { userId: admin.id },
orderBy: { measuredAt: "asc" },
});
expect(measurements).toHaveLength(2);
expect(measurements[0]!.type).toBe("WEIGHT");
expect(measurements[0]!.value).toBe(80.5);
expect(measurements[1]!.type).toBe("PULSE");
const medications = await prisma.medication.findMany({
where: { userId: admin.id },
});
expect(medications).toHaveLength(1);
expect(medications[0]!.name).toBe("Ramipril");
// Schedules: query with only the columns the deployed migrations
// know about. (A sibling branch added `days_of_week` to the schema
// without a matching migration; selecting `*` from the model in
// the test DB would 500 on the missing column. Round-tripping the
// window fields is enough to prove the restore wired schedules to
// the right parent.)
const schedules = await prisma.medicationSchedule.findMany({
where: { medicationId: medications[0]!.id },
select: { windowStart: true, windowEnd: true, label: true },
});
expect(schedules).toHaveLength(1);
expect(schedules[0]!.windowStart).toBe("08:00");
expect(schedules[0]!.label).toBe("Morning");
const intake = await prisma.medicationIntakeEvent.findMany({
where: { userId: admin.id },
});
expect(intake).toHaveLength(1);
expect(intake[0]!.medicationId).toBe(medications[0]!.id);
const moods = await prisma.moodEntry.findMany({
where: { userId: admin.id },
});
expect(moods).toHaveLength(1);
expect(moods[0]!.mood).toBe("GUT");
expect(moods[0]!.score).toBe(4);
const channels = await prisma.notificationChannel.count({
where: { userId: admin.id },
});
expect(channels).toBe(0);
const subs = await prisma.pushSubscription.count({
where: { userId: admin.id },
});
expect(subs).toBe(0);
// ── assert: audit trail captures start + success ──
const start = await prisma.auditLog.count({
where: { action: "admin.backups.restore.start", userId: admin.id },
});
expect(start).toBe(1);
const success = await prisma.auditLog.count({
where: { action: "admin.backups.restore", userId: admin.id },
});
expect(success).toBe(1);
});
it("rejects a request without `confirm: 'RESTORE'` and audits the denial", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "MANUAL_UPLOAD_DENY_TEST",
data: encrypt(
JSON.stringify({
schemaVersion: "1",
exportedAt: "2026-05-09T00:00:00.000Z",
userId: admin.id,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [],
}),
),
},
});
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
const req = makeRequest(backup.id, { confirm: "yes" });
const res = await POST(req as unknown as Parameters<typeof POST>[0], {
params: Promise.resolve({ id: backup.id }),
});
expect(res.status).toBe(422);
const denied = await prisma.auditLog.count({
where: {
action: "admin.backups.restore.denied",
userId: admin.id,
},
});
expect(denied).toBe(1);
});
it("returns 404 when the backup id is unknown", async () => {
const admin = await seedAdminSession();
void admin;
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
const req = makeRequest("does-not-exist", { confirm: "RESTORE" });
const res = await POST(req as unknown as Parameters<typeof POST>[0], {
params: Promise.resolve({ id: "does-not-exist" }),
});
expect(res.status).toBe(404);
});
});