From fe85c2c85f2083ff8c5f3f5649a9fe9877b54e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 20:58:33 +0200 Subject: [PATCH] feat(admin): restore from backup with full data replacement + triple-confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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-- 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) --- messages/de.json | 36 +- messages/en.json | 36 +- .../api/admin/backups/[id]/restore/route.ts | 431 ++++++++++++++++++ src/components/admin/backups-section.tsx | 200 +++++++- .../integration/admin-backups-restore.test.ts | 327 +++++++++++++ 5 files changed, 1011 insertions(+), 19 deletions(-) create mode 100644 src/app/api/admin/backups/[id]/restore/route.ts create mode 100644 tests/integration/admin-backups-restore.test.ts diff --git a/messages/de.json b/messages/de.json index e8f1da4df..ab727c410 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ed2097e4c..1a872b0eb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts new file mode 100644 index 000000000..2c7e17211 --- /dev/null +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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 " 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(); + 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 => 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(); +}); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index d87b906e6..f9554c387 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -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 ( + { + setOpen(next); + if (!next) setTyped(""); + }} + > + + + + + + + {t("admin.section.backups.restoreTitle")} + + + {t("admin.section.backups.restoreDescription", { + username: row.username, + when: formatDateTime(row.createdAt), + })} + + +
+ + setTyped(e.target.value)} + autoComplete="off" + spellCheck={false} + placeholder="RESTORE" + /> +
+ + {t("common.cancel")} + { + if (!matched) return; + setOpen(false); + setTyped(""); + onConfirm(); + }} + > + {pending + ? t("admin.section.backups.restoreInProgress") + : t("admin.section.backups.restoreConfirm")} + + +
+
+ ); +} + function formatBytes(bytes: number, fmt: ReturnType) { 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 `` 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)} - +
+ + restore.mutate(row)} + /> +
))} diff --git a/tests/integration/admin-backups-restore.test.ts b/tests/integration/admin-backups-restore.test.ts new file mode 100644 index 000000000..952f1a188 --- /dev/null +++ b/tests/integration/admin-backups-restore.test.ts @@ -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) { + 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[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[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[0], { + params: Promise.resolve({ id: "does-not-exist" }), + }); + + expect(res.status).toBe(404); + }); +});