mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.
The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.
What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.
- One restore per account: a partial unique index admits one queued or
running job, and a second request gets 409 `backup.restore.active` naming
the running job. A per-account advisory lock inside the transaction holds
even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
limit would run past three quarters of it is refused before anything is
deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
queues a job whose worker stopped again from the start, at most twice,
then fails it as interrupted. The admission check releases an abandoned
job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
rest. It stays whatever the outcome, so a retry needs no new upload. The
job records a digest of the copy and refuses one that was replaced while
it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
after, and after a reload it finds the job again from the server. A poll
that fails on the network is never shown as a failed restore. A web
process separate from the worker evicts the account's caches when it
first reports the restore finished. The success line goes through the
outcome module, so an empty file is not drawn as a tick.
The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
454 lines
14 KiB
TypeScript
454 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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
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: "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);
|
|
});
|
|
});
|