mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
a5b341b9b6
commit
fe85c2c85f
5 files changed
+1011
-19
No files matched your search
+35
-1
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user