fix(backup): make the console restore leave instance settings alone

A disaster-recovery snapshot carries the singleton `app_settings` row —
registration, the MFA requirement, the default locale and timezone, module
availability, the notification and AI configuration, the document size cap and
quota — and the restore wrote it back as a side effect of restoring one
account. An operator putting a single record back on Wednesday reconfigured the
whole host from Monday's file, silently, with nothing on screen saying so.

Rebuilding a host from a snapshot is a real case, so the write stays: it is now
`restoreInstanceSettings: true` on the request and a checkbox beside the typed
gate, off unless the operator ticks it. Restoring an account and reconfiguring
the installation are two decisions, so they are two answers.

The 422 confirmation sentence and the dialog copy drop the instance-wide claim;
the opt-in carries it instead, in all seven catalogues. Integration coverage
runs both arms: without the flag the settings row keeps its own values, with it
the file's values land.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 05:16:59 +02:00
1 parent 5cb5c7df44
commit 6b366b4f27
12 files changed
+189 -22

No files matched your search

@@ -86,7 +86,11 @@ 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" }),
// The disaster-recovery case, so the instance-wide half of the payload is
// asked for explicitly. Without the flag the restore rebuilds the account
// and leaves the host's own settings alone; that arm is covered in
// `admin-backups-restore.test.ts`.
body: JSON.stringify({ confirm: "RESTORE", restoreInstanceSettings: true }),
});
}
@@ -13,6 +13,9 @@
* 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";
@@ -307,7 +310,7 @@ describe("POST /api/admin/backups/[id]/restore", () => {
expect(res.status).toBe(422);
const error = (await res.json()) as { error: string };
expect(error.error).toMatch(/instance-wide settings/i);
expect(error.error).toMatch(/confirm: 'RESTORE'/);
const denied = await prisma.auditLog.count({
where: {
action: "admin.backups.restore.denied",
@@ -317,6 +320,71 @@ describe("POST /api/admin/backups/[id]/restore", () => {
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("@/app/api/admin/backups/[id]/restore/route");
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("returns 404 when the backup id is unknown", async () => {
const admin = await seedAdminSession();
void admin;