Files
HealthLog/tests/integration/admin-backups-restore.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

450 lines
14 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.
* - the singleton `app_settings` row stays where it is unless the request
* asks for it: restoring one account is not a reason to reconfigure the
* installation for everybody on it.
* - 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);
},
})),
};
});
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: "private legacy note",
notesEncrypted: 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("./restore-job-driver");
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");
expect(measurements[0]!.notes).toBeNull();
expect(measurements[0]!.notesEncrypted).not.toBeNull();
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("./restore-job-driver");
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 error = (await res.json()) as { error: string };
expect(error.error).toMatch(/confirm: 'RESTORE'/);
const denied = await prisma.auditLog.count({
where: {
action: "admin.backups.restore.denied",
userId: admin.id,
},
});
expect(denied).toBe(1);
});
it("leaves the instance settings alone unless the restore asks for them", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
// What the host is configured to do today.
const live = await prisma.appSettings.create({
data: {
id: "singleton",
registrationEnabled: false,
documentMaxFileBytes: 1_048_576,
},
});
// What the file says, which is the other answer to both.
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "MANUAL_UPLOAD_SETTINGS_TEST",
data: encrypt(
JSON.stringify({
schemaVersion: "1",
exportedAt: "2026-05-09T00:00:00.000Z",
userId: admin.id,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [],
appSettings: {
...live,
documentQuotaBytes: String(live.documentQuotaBytes),
registrationEnabled: true,
documentMaxFileBytes: 4 * 1_048_576,
},
}),
),
},
});
const { POST } = await import("./restore-job-driver");
const restore = (body: Record<string, unknown>) =>
POST(
makeRequest(backup.id, body) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
// The default answer: this account's data, and nobody else's settings.
expect((await restore({ confirm: "RESTORE" })).status).toBe(200);
const untouched = await prisma.appSettings.findUniqueOrThrow({
where: { id: "singleton" },
});
expect(untouched.registrationEnabled).toBe(false);
expect(untouched.documentMaxFileBytes).toBe(1_048_576);
// And the second decision, when the operator makes it.
expect(
(await restore({ confirm: "RESTORE", restoreInstanceSettings: true }))
.status,
).toBe(200);
const rewritten = await prisma.appSettings.findUniqueOrThrow({
where: { id: "singleton" },
});
expect(rewritten.registrationEnabled).toBe(true);
expect(rewritten.documentMaxFileBytes).toBe(4 * 1_048_576);
});
it("folds a pre-0343 Coach availability key into the Coach switch", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const live = await prisma.appSettings.create({
data: { id: "singleton" },
});
// A file written before the Coach's operator layer moved from module
// availability to the Coach switch: the operator had the Coach off there.
const { assistantDocumentAiEnabled: _dropped, ...beforeTheSwitchExisted } =
live;
void _dropped;
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "MANUAL_UPLOAD_SETTINGS_TEST",
data: encrypt(
JSON.stringify({
schemaVersion: "1",
exportedAt: "2026-05-09T00:00:00.000Z",
userId: admin.id,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [],
appSettings: {
...beforeTheSwitchExisted,
documentQuotaBytes: String(live.documentQuotaBytes),
assistantCoachEnabled: true,
moduleAvailabilityJson: { coach: false, labs: false },
},
}),
),
},
});
const { POST } = await import("./restore-job-driver");
const res = await POST(
makeRequest(backup.id, {
confirm: "RESTORE",
restoreInstanceSettings: true,
}) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(res.status).toBe(200);
const restored = await prisma.appSettings.findUniqueOrThrow({
where: { id: "singleton" },
});
expect(restored.assistantCoachEnabled).toBe(false);
expect(restored.moduleAvailabilityJson).toEqual({ labs: false });
});
it("returns 404 when the backup id is unknown", async () => {
const admin = await seedAdminSession();
void admin;
const { POST } = await import("./restore-job-driver");
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);
});
});