mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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;
|
||||
|
||||
Reference in new issue
Block a user