mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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>
328 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
});
|