Files
HealthLog/tests/integration/admin-backups-restore.test.ts
T
Marc-André BombeckandClaude Opus 4.7 fe85c2c85f 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>
2026-05-09 20:58:33 +02:00

328 lines
9.7 KiB
TypeScript

/**
* 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);
});
});