mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32: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.
239 lines
8.7 KiB
TypeScript
239 lines
8.7 KiB
TypeScript
/**
|
|
* A file that claims a section it does not carry is refused whole (#237).
|
|
*
|
|
* The restore's first act is to delete the class of records it is about to
|
|
* rebuild. So a payload whose documents section is simply absent does not
|
|
* "restore everything else": it empties the vault, reports success, and leaves
|
|
* the operator to discover the hole later. The maintainer's decision is to
|
|
* refuse the file instead — above the transaction, like the metadata-only
|
|
* check beside it, so the account survives the refusal.
|
|
*
|
|
* The hard part is not the refusal. It is the boundary, and both halves are
|
|
* asserted here against the real route and real rows:
|
|
*
|
|
* MISSING — the manifest says the file carries documents, and the key is not
|
|
* there. Refused, named, nothing deleted.
|
|
*
|
|
* DECLARED OMITTED — the manifest says the file does NOT carry the
|
|
* mental-health screeners or the consent receipts, and the keys are not
|
|
* there. That is what every portable export writes, on purpose, and it has to
|
|
* restore. The two cases below are the same builder, the same account and the
|
|
* same deleted-key trick; the only difference is what the manifest said, which
|
|
* is exactly the claim being made.
|
|
*
|
|
* Mutation check: drop the `included === "omitted"` skip in
|
|
* `findMissingBackupSections` and the second case goes red. Move the refusal
|
|
* below the `prisma.$transaction(...)` call and the first case's surviving-rows
|
|
* assertions go red while its status assertion stays green — which is why the
|
|
* rows are asserted separately rather than trusted to follow from the status.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { encrypt } from "@/lib/crypto";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { POST } from "./restore-job-driver";
|
|
import { invalidateUserData } from "@/lib/cache/invalidate";
|
|
|
|
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),
|
|
}));
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserData: vi.fn(),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
vi.mocked(invalidateUserData).mockClear();
|
|
});
|
|
|
|
function makeRequest(id: string) {
|
|
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* An admin account with one measurement and nothing else, signed in.
|
|
*
|
|
* The measurement is the witness. It references nothing the refusal is about,
|
|
* so it can only be lost to the wipe — which is what tells a refused restore
|
|
* apart from a destroyed account.
|
|
*/
|
|
async function seedOwner(username: string) {
|
|
const prisma = getPrismaClient();
|
|
const owner = await prisma.user.create({
|
|
data: { username, email: `${username}@example.test`, role: "ADMIN" },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: owner.id,
|
|
type: "WEIGHT",
|
|
value: 79.2,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-07-11T06:15:00.000Z"),
|
|
},
|
|
});
|
|
return owner;
|
|
}
|
|
|
|
/**
|
|
* A real portable export of that account, with the named keys cut out of the
|
|
* JSON before it is sealed.
|
|
*
|
|
* Cutting them from the RAW object rather than from a parsed payload is the
|
|
* whole point: every section in `backupPayloadSchema` carries `.default([])`,
|
|
* so a payload that has been through the schema no longer knows the difference
|
|
* between a section that was absent and one that was empty.
|
|
*/
|
|
async function sealPortableWithout(
|
|
ownerId: string,
|
|
keys: string[],
|
|
): Promise<{ backupId: string; manifest: Record<string, unknown> }> {
|
|
const prisma = getPrismaClient();
|
|
// No `purpose`, so the builder takes the portable branch — the file a person
|
|
// gets from "export my data".
|
|
const { payload } = await buildFullBackupPayload(prisma, ownerId);
|
|
const raw = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
|
|
for (const key of keys) delete raw[key];
|
|
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: ownerId,
|
|
type: "MISSING_SECTION_RESTORE",
|
|
data: encrypt(JSON.stringify(raw)),
|
|
},
|
|
});
|
|
return {
|
|
backupId: backup.id,
|
|
manifest: raw.manifest as Record<string, unknown>,
|
|
};
|
|
}
|
|
|
|
async function restore(backupId: string) {
|
|
return await POST(
|
|
makeRequest(backupId) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backupId }) },
|
|
);
|
|
}
|
|
|
|
describe("a backup whose manifest claims a section the file does not carry", () => {
|
|
it("refuses the whole file, names the section, and changes nothing", async () => {
|
|
const prisma = getPrismaClient();
|
|
const owner = await seedOwner("missing-section-owner");
|
|
const { backupId, manifest } = await sealPortableWithout(owner.id, [
|
|
"documents",
|
|
]);
|
|
|
|
// Prove the premise rather than assume it: the file still SAYS it carries
|
|
// documents. Without this the test would keep passing if the builder ever
|
|
// stopped writing the manifest entry.
|
|
expect(
|
|
(manifest.documents as { included?: string } | undefined)?.included,
|
|
).toBe("metadata-only");
|
|
|
|
const response = await restore(backupId);
|
|
|
|
expect(response.status).toBe(422);
|
|
const body = (await response.json()) as {
|
|
error: string;
|
|
meta?: { errorCode?: string; sections?: string[] };
|
|
};
|
|
expect(
|
|
body.error,
|
|
"the message names the section, so the operator can act on it",
|
|
).toContain("documents");
|
|
expect(
|
|
body.error,
|
|
"and says the account is intact, because that is the decision they need",
|
|
).toContain("Nothing was changed");
|
|
expect(body.meta?.errorCode).toBe("backup.section.missing");
|
|
expect(body.meta?.sections).toEqual(["documents"]);
|
|
|
|
// The refusal is only worth anything above the wipe.
|
|
expect(
|
|
await prisma.measurement.count({ where: { userId: owner.id } }),
|
|
"a refused restore must not have run the wipe",
|
|
).toBe(1);
|
|
// A second, independent witness: the invalidation only fires at the end of
|
|
// a restore that actually happened.
|
|
expect(vi.mocked(invalidateUserData)).not.toHaveBeenCalled();
|
|
|
|
const auditRow = await prisma.auditLog.findFirstOrThrow({
|
|
where: { action: "admin.backups.restore.failed" },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
const details = JSON.parse(auditRow.details ?? "{}") as {
|
|
reason?: string;
|
|
sections?: string[];
|
|
};
|
|
expect(details.reason).toBe("section_missing");
|
|
expect(details.sections).toEqual(["documents"]);
|
|
});
|
|
|
|
it("restores a file whose manifest DECLARES the same keys omitted", async () => {
|
|
const prisma = getPrismaClient();
|
|
const owner = await seedOwner("declared-omission-owner");
|
|
// The two sections a portable export leaves out on purpose, cut from the
|
|
// file exactly the way `documents` was cut above.
|
|
const { backupId, manifest } = await sealPortableWithout(owner.id, [
|
|
"mentalHealthAssessments",
|
|
"consentReceipts",
|
|
]);
|
|
|
|
expect(
|
|
(manifest.mentalHealth as { included?: string } | undefined)?.included,
|
|
).toBe("omitted");
|
|
expect(
|
|
(manifest.consent as { included?: string } | undefined)?.included,
|
|
).toBe("omitted");
|
|
|
|
const response = await restore(backupId);
|
|
|
|
expect(
|
|
response.status,
|
|
"a declared omission is not a missing section",
|
|
).toBe(200);
|
|
const body = (await response.json()) as { data: { restored: boolean } };
|
|
expect(body.data.restored).toBe(true);
|
|
// The restore genuinely ran: the measurement came back out of the file it
|
|
// was exported into, and the cache was evicted.
|
|
expect(
|
|
await prisma.measurement.count({ where: { userId: owner.id } }),
|
|
).toBe(1);
|
|
expect(vi.mocked(invalidateUserData)).toHaveBeenCalledWith(owner.id);
|
|
});
|
|
});
|