mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge the backup and restore end-to-end journey, the restore that leaves instance settings alone and the 422 for an unreadable copy
This commit is contained in:
commit
ee73e840e0
27 files changed
+1759
-59
No files matched your search
@@ -19940,6 +19940,119 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/admin/backups/{id}/download:
|
||||
get:
|
||||
tags:
|
||||
- Admin
|
||||
summary: Download one stored backup as JSON
|
||||
description: "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment`
|
||||
and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact
|
||||
an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot
|
||||
reach admin endpoints."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
description: "`DataBackup.id`."
|
||||
required: true
|
||||
description: "`DataBackup.id`."
|
||||
responses:
|
||||
"200":
|
||||
description: The decrypted backup document.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminBackupDocument"
|
||||
"403": &a43
|
||||
description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404": &a44
|
||||
description: No stored copy with this id.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422":
|
||||
description: "The stored copy could not be opened: either the key that wrote it is no longer in `ENCRYPTION_KEYS` (a
|
||||
rotation that dropped the legacy entry too early), or the stored bytes are not the ones that were written.
|
||||
`meta.errorCode` = `backup.payload.undecryptable`. Nothing was changed."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"500":
|
||||
description: The copy decrypted and then failed schema validation — a document this instance wrote and cannot parse.
|
||||
This one genuinely is a server fault, and is reported as one.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/admin/backups/{id}/restore:
|
||||
post:
|
||||
tags:
|
||||
- Admin
|
||||
summary: Restore one stored backup over its owner's record
|
||||
description: "Replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file
|
||||
carries. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the
|
||||
rest of its class. The target is the account the snapshot was taken for, never the admin running it, and a
|
||||
payload declaring a different owner than the stored row is refused. Admin session cookie required; Bearer tokens
|
||||
cannot reach admin endpoints."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
description: "`DataBackup.id`."
|
||||
required: true
|
||||
description: "`DataBackup.id`."
|
||||
- *a6
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminBackupRestoreRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: The restore ran.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminBackupRestoreResponse"
|
||||
"400":
|
||||
description: Body was not JSON.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"403": *a43
|
||||
"404": *a44
|
||||
"409":
|
||||
description: Either the payload declares a different owner than the stored row, or a request under the same
|
||||
`Idempotency-Key` is still in flight. Nothing was changed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413":
|
||||
description: Body exceeds 64 KiB.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422":
|
||||
description: "The request or the file was refused before anything was written: `confirm` missing, the copy undecryptable
|
||||
(`meta.errorCode` = `backup.payload.undecryptable`), the payload failing schema validation, an unsupported
|
||||
`schemaVersion`, a section the file's own manifest says it carries and does not (`meta.errorCode` =
|
||||
`backup.section.missing`), a metadata-only document entry, or an owner who no longer exists here."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/bugreport:
|
||||
post:
|
||||
security: []
|
||||
@@ -29097,6 +29210,21 @@ components:
|
||||
the same plausibility range the interactive create paths use. `idempotencyKey` is an identity, not a windowed
|
||||
replay token: it is stored on the row forever and matched by equality, so a key that rotates per client launch
|
||||
never matches its own earlier row and every re-sync logs the dose again."
|
||||
AdminBackupRestoreRequest:
|
||||
type: object
|
||||
properties:
|
||||
confirm:
|
||||
type: string
|
||||
const: RESTORE
|
||||
restoreInstanceSettings:
|
||||
type: boolean
|
||||
required:
|
||||
- confirm
|
||||
description: "`confirm` is the literal typed gate; anything else is refused with 422 and nothing is read.
|
||||
`restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and
|
||||
leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module
|
||||
availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only
|
||||
when rebuilding a host from a snapshot, because those settings apply to every account on the installation."
|
||||
MedicationTreatmentClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -68903,6 +69031,90 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
AdminBackupDocument:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
description: "The backup payload as the writer serialised it: `schemaVersion`, `exportedAt`, `userId`, and one array or
|
||||
object per serialised class."
|
||||
AdminBackupRestoreResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AdminBackupRestoreResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
AdminBackupRestoreResult:
|
||||
type: object
|
||||
properties:
|
||||
restored:
|
||||
type: boolean
|
||||
const: true
|
||||
summary:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
skipped:
|
||||
type: object
|
||||
properties:
|
||||
catalogueKeys:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AdminBackupRestoreSkipEntry"
|
||||
links:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- catalogueKeys
|
||||
- links
|
||||
additionalProperties: false
|
||||
cleared:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- restored
|
||||
- summary
|
||||
- skipped
|
||||
- cleared
|
||||
additionalProperties: false
|
||||
description: What the transaction wrote. `cleared` counts the rows each class held before it was rebuilt from the file,
|
||||
`summary` counts what the file carried, and `skipped.links` is the number of links dropped because a catalogue
|
||||
key in the file does not exist here — zero is the normal answer.
|
||||
AdminBackupRestoreSkipEntry:
|
||||
type: object
|
||||
properties:
|
||||
catalogue:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
links:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- catalogue
|
||||
- key
|
||||
- links
|
||||
additionalProperties: false
|
||||
description: One catalogue key the file referenced and this instance could not resolve, with the number of links it cost.
|
||||
__schema0:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
@@ -124,6 +124,24 @@ decompresses to a few hundred megabytes, and
|
||||
`NODE_OPTIONS=--max-old-space-size=2048` in front of the command is enough for
|
||||
a 445 000-measurement account.
|
||||
|
||||
### Restoring from the backups console
|
||||
|
||||
An admin can restore a stored snapshot from the browser instead of the shell.
|
||||
`/admin/backups` lists one row per account and per backup type: **Backup now**
|
||||
enqueues the `data-backup` pass for every account on the instance, **Download**
|
||||
decrypts one snapshot and hands back the JSON, and **Restore** opens a dialog
|
||||
that first shows what the file carries and then requires the word `RESTORE` to
|
||||
be typed before it will run. The restore targets the account the snapshot was
|
||||
taken for, never the admin running it, and it refuses a file whose declared
|
||||
owner does not match the stored row. Instance-wide settings ride along only
|
||||
when you tick _Also restore instance settings_ in the dialog; leave it clear to
|
||||
restore the account alone.
|
||||
|
||||
A stored copy this instance can no longer open — the key that wrote it dropped
|
||||
from `ENCRYPTION_KEYS` one rotation too early, or bytes that are not the ones
|
||||
written — is refused by all three buttons with `422` and
|
||||
`meta.errorCode = backup.payload.undecryptable`, before anything is read back.
|
||||
|
||||
### What a backup deliberately does not carry
|
||||
|
||||
Every credential-shaped row is left out, and this is not an oversight to fix:
|
||||
@@ -264,6 +282,16 @@ shape, and worse: a restore parses the whole document, so the read path needs
|
||||
several times the blob in heap. An operator restoring a very large account
|
||||
should give the container more memory for the duration.
|
||||
|
||||
### What a restore replaces
|
||||
|
||||
A restore replaces the account's data tables; it does not merge into them.
|
||||
Every row the file carries is written back under its original id, and every row
|
||||
the account gained after the snapshot was taken — readings, doses, mood
|
||||
entries, documents — is deleted with the rest of the class before the file is
|
||||
read back. Restoring Monday's copy on Wednesday therefore costs the account
|
||||
everything it recorded on Tuesday. If the current state is worth keeping, take
|
||||
a fresh snapshot from the backups console before restoring the old one.
|
||||
|
||||
## Monthly restore drill (automatic)
|
||||
|
||||
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* A record, a snapshot of it, and the snapshot put back — one journey.
|
||||
*
|
||||
* The script this file is checked against:
|
||||
*
|
||||
* 1. an account records readings across three domains — two measurements, a
|
||||
* medication with one dose, a mood entry — and files a small PDF in the
|
||||
* vault;
|
||||
* 2. it downloads its own record as the passphrase-sealed archive the export
|
||||
* card offers, and the bytes are the `HLX1` envelope
|
||||
* `src/lib/export/passphrase-archive.ts` documents, not a JSON file with
|
||||
* a new extension;
|
||||
* 3. an admin takes a snapshot from the backups console, waits for the
|
||||
* pg-boss pass by asking the console's own endpoint what it holds, and
|
||||
* downloads the stored copy;
|
||||
* 4. the account loses a reading and a dose, gains a reading the snapshot
|
||||
* never saw, and the snapshot is restored over it from the same console;
|
||||
* 5. what was deleted is back under its ORIGINAL id, and what arrived after
|
||||
* the snapshot is gone — which is what "the restore replaces the
|
||||
* account's data tables" in `docs/ops/backup-restore.md` means when it is
|
||||
* spelled out.
|
||||
*
|
||||
* Refusing controls, because a restore path that cannot say no is worse than
|
||||
* none: a stored copy with one flipped ciphertext byte is refused and changes
|
||||
* nothing, and a delegate holding whole-record READ inside the account sees no
|
||||
* backup surface at all and is refused by every route behind it.
|
||||
*
|
||||
* One act reaches past this account, and by design: "Backup now" enqueues the
|
||||
* `data-backup` pass, which writes a `WEEKLY_AUTO` row for EVERY account on the
|
||||
* instance. The journey reads only its own row and deletes only its own rows,
|
||||
* but it is not true that it touches nothing else. The restore is the other
|
||||
* direction and is confined: the transaction is scoped to the snapshot's owner,
|
||||
* and the instance-wide settings a disaster-recovery payload also carries are
|
||||
* written only when the dialog's opt-in is ticked, which this file never ticks
|
||||
* and asserts it never ticks.
|
||||
*
|
||||
* Everything the journey does, it does through the app: the readings and the
|
||||
* document ride the page's own `fetch`, the archive and the snapshot ride the
|
||||
* real buttons, the restore rides the typed-confirmation dialog. The only two
|
||||
* things reaching Postgres directly are the ones a browser cannot express —
|
||||
* resetting the account between repetitions, and corrupting one stored copy so
|
||||
* the authenticated envelope has something to refuse. Both live in
|
||||
* `setup/backup-journey-fixture.ts`.
|
||||
*
|
||||
* Serial and single-project: the four tests are four acts of one journey, they
|
||||
* mutate the account destructively, and the last one moves a session's record
|
||||
* selector. Its jar is its own for the reason `global-setup.ts` spells out.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive";
|
||||
|
||||
import {
|
||||
BACKUP_DELEGATE_STORAGE_STATE_PATH,
|
||||
BACKUP_STORAGE_STATE_PATH,
|
||||
E2E_BACKUP_ADMIN,
|
||||
} from "./setup/global-setup";
|
||||
import {
|
||||
backupAccountId,
|
||||
deleteStoredCopy,
|
||||
resetBackupJourney,
|
||||
storedIntakeIds,
|
||||
storedMeasurements,
|
||||
storeTamperedCopy,
|
||||
} from "./setup/backup-journey-fixture";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
/**
|
||||
* The pass backs up every account in the database, the restore rewrites a
|
||||
* dozen tables in one transaction, and the archive runs Argon2id at the OWASP
|
||||
* cost. None of that fits the suite's 30-second default on a loaded runner.
|
||||
*/
|
||||
test.setTimeout(180_000);
|
||||
|
||||
/** Long enough to clear the export card's own 12-character minimum. */
|
||||
const ARCHIVE_PASSPHRASE = "e2e-backup-journey-passphrase";
|
||||
|
||||
/** The reading the journey deletes and expects back, by value and by id. */
|
||||
const RESTORED_WEIGHT_KG = 81.4;
|
||||
/** The reading nothing touches — a restore that wiped it would be a defect. */
|
||||
const UNTOUCHED_PULSE_BPM = 58;
|
||||
/** The reading that arrives AFTER the snapshot and must not survive it. */
|
||||
const POST_SNAPSHOT_PULSE_BPM = 66;
|
||||
|
||||
const MEDICATION_NAME = "E2E Backup Tablet";
|
||||
|
||||
/** The console's row list is painted, or its honest empty state is. */
|
||||
const BACKUPS_SETTLED = '[data-slot="backup-rows"], [data-slot="empty-state"]';
|
||||
/** The measurements list has finished its read, either way. */
|
||||
const MEASUREMENTS_SETTLED =
|
||||
'[data-slot="measurement-rows"], [data-slot="empty-state"]';
|
||||
|
||||
/** One stored snapshot as the console's own endpoint reports it. */
|
||||
interface ConsoleRow {
|
||||
id: string;
|
||||
username: string;
|
||||
type: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SeededRecord {
|
||||
weightId: string;
|
||||
intakeId: string;
|
||||
medicationId: string;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
/** What the journey wrote, once the first act has written it. */
|
||||
let seeded: SeededRecord | null = null;
|
||||
/** The stored snapshot the first act takes; every later act addresses it. */
|
||||
let snapshotId: string | null = null;
|
||||
/** The deliberately corrupted copy, held so the file can take it away again. */
|
||||
let tamperedCopyId: string | null = null;
|
||||
|
||||
function requireSeeded(): SeededRecord {
|
||||
if (!seeded) throw new Error("the record was never seeded");
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function requireSnapshot(): string {
|
||||
if (!snapshotId) throw new Error("no snapshot was taken");
|
||||
return snapshotId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the record through the app's own routes, from the page's origin.
|
||||
*
|
||||
* The page's `fetch` rather than a Playwright request context, for the reason
|
||||
* `glucose-journey.spec.ts` gives: a pooled context can hand a POST to a
|
||||
* keep-alive socket the server has already closed, and the retry lands as a
|
||||
* second row.
|
||||
*/
|
||||
function seedRecord(page: Page): Promise<SeededRecord> {
|
||||
return page.evaluate(
|
||||
async ([weightKg, pulseBpm, medicationName]: [number, number, string]) => {
|
||||
const post = async (path: string, body: unknown): Promise<string> => {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = (await res.json()) as {
|
||||
data: { id: string } | null;
|
||||
error: string | null;
|
||||
};
|
||||
if (!res.ok || !json.data) {
|
||||
throw new Error(`${path} answered ${res.status}: ${json.error}`);
|
||||
}
|
||||
return json.data.id;
|
||||
};
|
||||
const at = (minutesAgo: number) =>
|
||||
new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||
|
||||
const weightId = await post("/api/measurements", {
|
||||
type: "WEIGHT",
|
||||
value: weightKg,
|
||||
measuredAt: at(180),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "PULSE",
|
||||
value: pulseBpm,
|
||||
measuredAt: at(170),
|
||||
});
|
||||
const medicationId = await post("/api/medications", {
|
||||
name: medicationName,
|
||||
dose: "5 mg",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
});
|
||||
const intakeId = await post(`/api/medications/${medicationId}/intake`, {
|
||||
takenAt: at(160),
|
||||
});
|
||||
await post("/api/mood-entries", {
|
||||
mood: "GUT",
|
||||
note: "backup journey",
|
||||
moodLoggedAt: at(150),
|
||||
});
|
||||
|
||||
// The vault, through the same multipart body the upload card sends. A
|
||||
// real minimal PDF: the route classifies by magic bytes and never trusts
|
||||
// the wire content-type, so bytes that only claim to be one are refused.
|
||||
const pdf = new TextEncoder().encode(
|
||||
"%PDF-1.4\n%e2e-backup-journey\n" +
|
||||
"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n" +
|
||||
"xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\n%%EOF\n",
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"file",
|
||||
new File([pdf], "backup-journey.pdf", { type: "application/pdf" }),
|
||||
);
|
||||
form.append("title", "Backup journey probe");
|
||||
const docRes = await fetch("/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const docJson = (await docRes.json()) as {
|
||||
data: { id: string } | null;
|
||||
error: string | null;
|
||||
};
|
||||
if (!docRes.ok || !docJson.data) {
|
||||
throw new Error(
|
||||
`/api/documents/inbound answered ${docRes.status}: ${docJson.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { weightId, medicationId, intakeId, documentId: docJson.data.id };
|
||||
},
|
||||
[RESTORED_WEIGHT_KG, UNTOUCHED_PULSE_BPM, MEDICATION_NAME] as [
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** This account's scheduled snapshot as the console's endpoint reports it. */
|
||||
function readConsoleRow(page: Page): Promise<ConsoleRow | null> {
|
||||
return page.evaluate(async (username: string) => {
|
||||
const res = await fetch("/api/admin/backups", { credentials: "include" });
|
||||
const body = (await res.json()) as {
|
||||
data: { rows: ConsoleRow[] } | null;
|
||||
};
|
||||
const mine = (body.data?.rows ?? []).filter(
|
||||
(row) => row.username === username && row.type === "WEEKLY_AUTO",
|
||||
);
|
||||
return mine[0] ?? null;
|
||||
}, E2E_BACKUP_ADMIN.username);
|
||||
}
|
||||
|
||||
/** Open the backups console and wait for its row list to settle. */
|
||||
async function openBackupsConsole(page: Page): Promise<void> {
|
||||
await page.goto("/admin/backups", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(BACKUPS_SETTLED).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The console's row for one snapshot, at whichever layout is painting.
|
||||
*
|
||||
* Both the wide table and the narrow card list are in the tree — only CSS
|
||||
* hides one — so the `:visible` filter is what keeps this a single element
|
||||
* rather than a pair. This file runs in the desktop project alone (see the
|
||||
* `chromium-mobile` ignore list in `playwright.config.ts`), so the table row is
|
||||
* the one that answers; the card's identical attributes are what let the same
|
||||
* handle hold if it ever runs narrow, and are not exercised here.
|
||||
*/
|
||||
function snapshotRow(page: Page, id: string) {
|
||||
return page.locator(`[data-backup-id="${id}"]:visible`);
|
||||
}
|
||||
|
||||
/** Drive the typed-confirmation dialog on one row and hand back the answer. */
|
||||
async function restoreThroughDialog(page: Page, id: string) {
|
||||
const answered = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/api/admin/backups/${id}/restore`) &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
await snapshotRow(page, id).getByTestId("backup-restore-trigger").click();
|
||||
// The instance-wide opt-in, left exactly where the dialog puts it. Asserted
|
||||
// rather than merely not clicked: a default that flipped would hand this
|
||||
// journey the power to rewrite the host's settings — the document size cap
|
||||
// among them — for every other spec sharing the server.
|
||||
await expect(
|
||||
page.getByTestId("backup-restore-instance-settings"),
|
||||
).not.toBeChecked();
|
||||
await page.getByTestId("backup-restore-prompt").fill("RESTORE");
|
||||
await page.getByTestId("backup-restore-confirm").click();
|
||||
return answered;
|
||||
}
|
||||
|
||||
test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
test.use({ storageState: BACKUP_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
seeded = null;
|
||||
snapshotId = null;
|
||||
tamperedCopyId = null;
|
||||
await resetBackupJourney();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (tamperedCopyId) await deleteStoredCopy(tamperedCopyId);
|
||||
tamperedCopyId = null;
|
||||
});
|
||||
|
||||
test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
seeded = await seedRecord(page);
|
||||
const record = seeded;
|
||||
|
||||
// On the list, by the number the row carries rather than by the text the
|
||||
// formatter prints for whoever is looking. The attribute holds the
|
||||
// DISPLAYED value, after the account's unit preference is applied — the
|
||||
// same number as the stored one under this account's default metric
|
||||
// preference, and deliberately not a claim about the stored row. The
|
||||
// database-side assertions later in the file are that claim.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
|
||||
`[data-measurement-value="${RESTORED_WEIGHT_KG}"]`,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// ── the account's own archive ────────────────────────────────────────
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("export-card-full-backup")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("export-full-backup-encrypt").click();
|
||||
await page
|
||||
.getByTestId("export-full-backup-passphrase")
|
||||
.fill(ARCHIVE_PASSPHRASE);
|
||||
const archiveDownload = page.waitForEvent("download");
|
||||
await page.getByTestId("export-action-full-backup").click();
|
||||
const bytes = await readFile(await (await archiveDownload).path());
|
||||
|
||||
// The header `passphrase-archive.ts` documents, field by field: magic,
|
||||
// version, KDF id, then the Argon2 cost the archive carries so a later
|
||||
// cost bump still opens it. Asserted as bytes, because "a file arrived"
|
||||
// is equally true of plaintext JSON under a new extension.
|
||||
expect(bytes.subarray(0, 4).toString("ascii")).toBe("HLX1");
|
||||
expect(bytes[4], "HLX1 version").toBe(0x01);
|
||||
expect(bytes[5], "KDF id — Argon2id").toBe(0x01);
|
||||
// The cost travels in the file so it may change, so it is asserted against
|
||||
// the constant the writer uses rather than against today's numbers: a
|
||||
// legitimate cost bump is a correct product change, not a red spec.
|
||||
expect(bytes.readUInt32BE(6), "Argon2 memory cost, KiB").toBe(
|
||||
EXPORT_ARGON2_PARAMS.memoryCost,
|
||||
);
|
||||
expect(bytes.readUInt32BE(10), "Argon2 time cost").toBe(
|
||||
EXPORT_ARGON2_PARAMS.timeCost,
|
||||
);
|
||||
expect(bytes[14], "Argon2 parallelism").toBe(
|
||||
EXPORT_ARGON2_PARAMS.parallelism,
|
||||
);
|
||||
expect(bytes[15], "salt length").toBe(16);
|
||||
// A record carrying five sections is not a header and an auth tag.
|
||||
expect(bytes.byteLength).toBeGreaterThan(1024);
|
||||
|
||||
// ── the instance's own snapshot ──────────────────────────────────────
|
||||
await openBackupsConsole(page);
|
||||
// The row this account had before the click, or none. The poll below asks
|
||||
// "is the stored copy a different one", and the server's own clock is the
|
||||
// only one both sides share — comparing a server timestamp against the
|
||||
// runner's would pass on a stale row under any clock skew.
|
||||
const previous = await readConsoleRow(page);
|
||||
const enqueued = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/admin/backups/run") &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
// The list is populated by now, so it is the card action that runs the
|
||||
// pass; the empty state's CTA carries its own id.
|
||||
await page.getByTestId("backup-run-now").click();
|
||||
// Named here rather than left to the poll: with no pg-boss instance in the
|
||||
// process the route answers 503 and nothing is ever enqueued, and the poll
|
||||
// would spend two minutes to report that the pass stored no copy — true,
|
||||
// and about the wrong subsystem. 429 is the same story on a reset that
|
||||
// stopped clearing the run bucket.
|
||||
expect(
|
||||
(await enqueued).status(),
|
||||
"the manual pass was enqueued (503 = no worker in this process, 429 = rate limit)",
|
||||
).toBe(200);
|
||||
|
||||
// The pass runs on pg-boss inside this process, so the console cannot know
|
||||
// when it finished — it can only be asked. Poll the endpoint the page
|
||||
// itself reads until this account's stored copy is newer than the click.
|
||||
// No sleep: a fixed wait would be a guess about a queue.
|
||||
const found: { row: ConsoleRow | null } = { row: null };
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const row = await readConsoleRow(page);
|
||||
const fresh =
|
||||
row !== null &&
|
||||
(previous === null ||
|
||||
row.id !== previous.id ||
|
||||
row.createdAt !== previous.createdAt);
|
||||
if (fresh) found.row = row;
|
||||
return fresh;
|
||||
},
|
||||
{
|
||||
timeout: 120_000,
|
||||
intervals: [500, 1_000, 2_000],
|
||||
message: "the data-backup pass never stored a copy of this account",
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const row = found.row;
|
||||
if (!row) throw new Error("the poll reported a row it did not keep");
|
||||
expect(
|
||||
row.sizeBytes,
|
||||
"a stored copy of a record is not empty",
|
||||
).toBeGreaterThan(512);
|
||||
snapshotId = row.id;
|
||||
|
||||
// The console paints it, and the row's own actions are on screen.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(snapshotRow(page, row.id)).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(
|
||||
snapshotRow(page, row.id).getByTestId("backup-restore-trigger"),
|
||||
).toBeVisible();
|
||||
|
||||
// ── what the stored copy actually carries ────────────────────────────
|
||||
const snapshotDownload = page.waitForEvent("download");
|
||||
await snapshotRow(page, row.id).getByTestId("backup-download").click();
|
||||
const payload = JSON.parse(
|
||||
await readFile(await (await snapshotDownload).path(), "utf8"),
|
||||
) as {
|
||||
measurements: Array<{ id: string }>;
|
||||
intakeEvents: Array<{ id: string }>;
|
||||
moodEntries: unknown[];
|
||||
documents: Array<{ id: string; contentEncrypted?: string }>;
|
||||
};
|
||||
|
||||
expect(payload.measurements.map((m) => m.id)).toContain(record.weightId);
|
||||
expect(payload.intakeEvents.map((e) => e.id)).toContain(record.intakeId);
|
||||
expect(payload.moodEntries).toHaveLength(1);
|
||||
expect(payload.documents.map((d) => d.id)).toContain(record.documentId);
|
||||
// A disaster-recovery copy carries document content as ciphertext, and the
|
||||
// restore refuses a file that brought only metadata — so an empty field
|
||||
// here is a snapshot nobody could restore.
|
||||
expect(
|
||||
payload.documents.find((d) => d.id === record.documentId)
|
||||
?.contentEncrypted ?? "",
|
||||
).not.toBe("");
|
||||
});
|
||||
|
||||
test("a stored copy with one flipped byte is refused and changes nothing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const before = await storedMeasurements();
|
||||
const tamperedId = await storeTamperedCopy(requireSnapshot());
|
||||
// Dropped again as soon as the control is done with it, rather than at the
|
||||
// next repetition's reset: until then it is a broken row in the console's
|
||||
// list for anyone who opens `/admin/backups`.
|
||||
tamperedCopyId = tamperedId;
|
||||
|
||||
await openBackupsConsole(page);
|
||||
const response = await restoreThroughDialog(page, tamperedId);
|
||||
|
||||
// The envelope is AES-256-GCM and its tag covers every ciphertext byte, so
|
||||
// the flip is caught before the transaction opens. 422 and
|
||||
// `backup.payload.undecryptable` are the refusal the route documents —
|
||||
// `docs/api/openapi.yaml`, and `docs/ops/backup-restore.md` for the
|
||||
// operator reading it after a key rotation — rather than whatever the
|
||||
// handler happened to answer. The code is asserted and not the sentence:
|
||||
// the sentence is prose that may be reworded, the code is the contract.
|
||||
expect(response.status()).toBe(422);
|
||||
expect(await response.json()).toMatchObject({
|
||||
data: null,
|
||||
meta: { errorCode: "backup.payload.undecryptable" },
|
||||
});
|
||||
|
||||
// A refusal that still wrote something would be worse than no refusal.
|
||||
expect(await storedMeasurements()).toEqual(before);
|
||||
});
|
||||
|
||||
test("the snapshot puts back what was deleted and drops what came after", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = requireSeeded();
|
||||
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// One reading and one dose go, and a reading the snapshot never saw
|
||||
// arrives. Both deletions are tombstones — the app soft-deletes a reading
|
||||
// and a dose — so "back" has to mean live again, not merely present.
|
||||
const laterId = await page.evaluate(
|
||||
async ([weightId, medicationId, intakeId, pulse]: [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
]) => {
|
||||
const del = async (path: string) => {
|
||||
const res = await fetch(path, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
|
||||
};
|
||||
await del(`/api/measurements/${weightId}`);
|
||||
await del(`/api/medications/${medicationId}/intake/${intakeId}`);
|
||||
|
||||
const res = await fetch("/api/measurements", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "PULSE",
|
||||
value: pulse,
|
||||
measuredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
const body = (await res.json()) as { data: { id: string } | null };
|
||||
if (!res.ok || !body.data) {
|
||||
throw new Error(`/api/measurements answered ${res.status}`);
|
||||
}
|
||||
return body.data.id;
|
||||
},
|
||||
[
|
||||
record.weightId,
|
||||
record.medicationId,
|
||||
record.intakeId,
|
||||
POST_SNAPSHOT_PULSE_BPM,
|
||||
] as [string, string, string, number],
|
||||
);
|
||||
|
||||
const beforeRestore = await storedMeasurements();
|
||||
expect(
|
||||
beforeRestore.find((m) => m.id === record.weightId)?.deletedAt,
|
||||
"the reading really is tombstoned before the restore",
|
||||
).not.toBeNull();
|
||||
expect(beforeRestore.map((m) => m.id)).toContain(laterId);
|
||||
|
||||
await openBackupsConsole(page);
|
||||
const response = await restoreThroughDialog(page, requireSnapshot());
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const afterRestore = await storedMeasurements();
|
||||
|
||||
// Identity, not merely equivalence. The payload carries each row's own id,
|
||||
// so a restore that re-created the reading under a fresh one would have
|
||||
// written a different record that happens to read the same.
|
||||
const weight = afterRestore.find((m) => m.id === record.weightId);
|
||||
expect(
|
||||
weight,
|
||||
"the deleted reading is back under its own id",
|
||||
).toBeDefined();
|
||||
expect(weight?.value).toBe(RESTORED_WEIGHT_KG);
|
||||
expect(
|
||||
weight?.deletedAt,
|
||||
"and it is live, not still tombstoned",
|
||||
).toBeNull();
|
||||
|
||||
const dose = (await storedIntakeIds()).find(
|
||||
(e) => e.id === record.intakeId,
|
||||
);
|
||||
expect(dose, "the deleted dose is back under its own id").toBeDefined();
|
||||
expect(dose?.deletedAt).toBeNull();
|
||||
|
||||
// The reading nothing touched is still there. Without this, an emptied
|
||||
// account would satisfy every assertion above about the tombstones.
|
||||
expect(
|
||||
afterRestore.filter((m) => m.value === UNTOUCHED_PULSE_BPM),
|
||||
).toHaveLength(1);
|
||||
|
||||
// And the reading that arrived after the snapshot is gone.
|
||||
// `docs/ops/backup-restore.md` says the restore "replaces the account's
|
||||
// data tables", and the route's own words are "one transaction replaces
|
||||
// every serialized owner-scoped class". Replacing is not merging: whoever
|
||||
// restores yesterday's copy loses today's readings, and that is the
|
||||
// documented behaviour rather than an accident of the implementation.
|
||||
expect(afterRestore.map((m) => m.id)).not.toContain(laterId);
|
||||
|
||||
// The list agrees with the database.
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
|
||||
`[data-measurement-value="${RESTORED_WEIGHT_KG}"]`,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="measurement-row"][data-measurement-type="PULSE"]' +
|
||||
`[data-measurement-value="${POST_SNAPSHOT_PULSE_BPM}"]`,
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("A read-level delegate inside the record", () => {
|
||||
test.use({ storageState: BACKUP_DELEGATE_STORAGE_STATE_PATH });
|
||||
|
||||
test("sees no backup surface, and every route behind it refuses", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Switched in through the switcher a person uses, not by a stamp written
|
||||
// behind the app's back. The switcher is a slice of the user menu, so the
|
||||
// menu opens first — the same two steps `v137-record-session-fence.spec.ts`
|
||||
// takes, and the trigger's accessible name is the only handle it has.
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const userMenu = page.getByRole("button", { name: "User menu" }).first();
|
||||
await expect(userMenu).toBeVisible({ timeout: 30_000 });
|
||||
await userMenu.click();
|
||||
const trigger = page.locator('[data-slot="account-switcher-trigger"]');
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const entry = page.locator(
|
||||
'[data-slot="account-switcher-entry"]' +
|
||||
`[data-account-username="${E2E_BACKUP_ADMIN.username}"]`,
|
||||
);
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
await entry.click();
|
||||
|
||||
// Wait for the shell to say the switch landed, not merely that the click
|
||||
// did. The stamp is written on the session row by a request the click
|
||||
// fires, so navigating straight afterwards can load the next page under
|
||||
// the delegate's OWN record and find every actor surface exactly where it
|
||||
// belongs — a pass that says nothing. The banner names the record and the
|
||||
// affordance it resolved, so this waits for both; `view` is what
|
||||
// `resolveRecordPresentation` calls a READ grant, and a grant that had
|
||||
// widened would paint `view-and-add` here instead of matching.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="shared-record-banner"]' +
|
||||
`[data-account-id="${await backupAccountId()}"]` +
|
||||
'[data-access-level="view"][data-record-kind="shared"]',
|
||||
),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Settings destinations that belong to the ACTOR do not mount inside
|
||||
// somebody else's record: the shell paints the not-covered panel in place
|
||||
// of the whole section. That panel is the positive half of this check —
|
||||
// without it, an absent export card would only mean the page had not
|
||||
// finished painting.
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("export-card-full-backup")).toHaveCount(0);
|
||||
await expect(page.getByTestId("export-action-full-backup")).toHaveCount(0);
|
||||
|
||||
// The console is not a place a non-admin lands: `AuthShell` sends them to
|
||||
// the dashboard and the admin frame paints nothing on the way.
|
||||
await page.goto("/admin/backups", { waitUntil: "domcontentloaded" });
|
||||
// The dashboard, by path. `/\/$/` also matches an admin URL that merely
|
||||
// ends in a slash, which is the one outcome this assertion exists to rule
|
||||
// out.
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, { timeout: 30_000 })
|
||||
.toBe("/");
|
||||
await expect(page.getByTestId("backup-run-now")).toHaveCount(0);
|
||||
await expect(page.getByTestId("backup-run-now-empty")).toHaveCount(0);
|
||||
await expect(page.getByTestId("backup-restore-trigger")).toHaveCount(0);
|
||||
|
||||
// And the routes underneath, asked from the delegate's own session.
|
||||
// `requireAdmin()` is cookie-only and refuses before the row is read, so
|
||||
// the restore below names the REAL snapshot: a 403 on an id that does not
|
||||
// exist would prove nothing about the one that does.
|
||||
const refusals = await page.evaluate(async (snapshot: string) => {
|
||||
const call = async (path: string, init?: RequestInit) => {
|
||||
const res = await fetch(path, init);
|
||||
const body = (await res.json()) as { error: string | null };
|
||||
return { status: res.status, error: body.error };
|
||||
};
|
||||
return {
|
||||
list: await call("/api/admin/backups"),
|
||||
run: await call("/api/admin/backups/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
}),
|
||||
restore: await call(`/api/admin/backups/${snapshot}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}),
|
||||
};
|
||||
}, requireSnapshot());
|
||||
|
||||
// Each refusal is named, not merely counted. A switched session is also
|
||||
// fenced off non-allowlisted endpoints (`sharing.not_permitted`), and that
|
||||
// fence answers 403 too — so a bare status check would stay green through
|
||||
// an admin gate that had been removed, with the second guard quietly
|
||||
// covering for it. The sentence says which boundary spoke.
|
||||
for (const refusal of [
|
||||
refusals.list,
|
||||
refusals.run,
|
||||
refusals.restore,
|
||||
] as const) {
|
||||
expect(refusal.status).toBe(403);
|
||||
expect(refusal.error).toBe("Admin access required");
|
||||
}
|
||||
|
||||
// The refused restore left the owner's record where the previous act put
|
||||
// it, rather than half-applying and reporting a refusal.
|
||||
const owner = await storedMeasurements();
|
||||
expect(owner.filter((m) => m.value === RESTORED_WEIGHT_KG)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Database-side fixture for the backup/restore journey.
|
||||
*
|
||||
* Everything the journey WRITES it writes through the browser: the readings,
|
||||
* the medication and its dose, the mood entry, the document, the snapshot, the
|
||||
* deletions and the restore all ride the app's own routes. What lives here is
|
||||
* only what a browser has no way to express — clearing the account back to a
|
||||
* known state so the file survives `--repeat-each`, and corrupting one stored
|
||||
* copy so the restore path has an authenticated envelope to refuse.
|
||||
*
|
||||
* Raw SQL through `pg` for the same reason `global-setup.ts` uses it:
|
||||
* Playwright's TS loader does not handle the generated Prisma client's
|
||||
* `import.meta.url` indirection.
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
import { E2E_BACKUP_ADMIN, E2E_BACKUP_DELEGATE } from "./global-setup";
|
||||
|
||||
/** One row of the account's readings, as the journey needs to compare it. */
|
||||
export interface StoredMeasurement {
|
||||
id: string;
|
||||
type: string;
|
||||
value: number;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"DATABASE_URL is unset — the backup journey needs the same database the app is running against.",
|
||||
);
|
||||
}
|
||||
const pool = new pg.Pool({ connectionString });
|
||||
try {
|
||||
return await fn(pool);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** The journey account's id, resolved by username rather than assumed. */
|
||||
export async function backupAccountId(): Promise<string> {
|
||||
return withPool(async (pool) => {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM users WHERE username = $1`,
|
||||
[E2E_BACKUP_ADMIN.username],
|
||||
);
|
||||
if (rows.length !== 1) {
|
||||
throw new Error(
|
||||
`expected exactly one ${E2E_BACKUP_ADMIN.username} row, found ${rows.length}`,
|
||||
);
|
||||
}
|
||||
return rows[0].id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the account back where the journey expects to find it.
|
||||
*
|
||||
* Deletes only what this file's account holds, and only the classes the
|
||||
* journey writes. The stored snapshots go too: a row left behind from the
|
||||
* previous repetition is indistinguishable from the one this repetition is
|
||||
* about to take, and the whole journey turns on picking the right one.
|
||||
*
|
||||
* The rate buckets are cleared for the same reason `global-setup.ts` clears
|
||||
* the login bucket: the manual-run and upload endpoints allow three calls a
|
||||
* minute each and the export bucket ten an hour, which are the right
|
||||
* production numbers and fewer than three repetitions of this file need.
|
||||
*/
|
||||
export async function resetBackupJourney(): Promise<void> {
|
||||
await withPool(async (pool) => {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM users WHERE username = ANY($1)`,
|
||||
[[E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]],
|
||||
);
|
||||
const ids = rows.map((row) => row.id);
|
||||
|
||||
// Intake events before medications: the child rows carry the foreign key.
|
||||
await pool.query(
|
||||
`DELETE FROM medication_intake_events WHERE user_id = ANY($1)`,
|
||||
[ids],
|
||||
);
|
||||
await pool.query(`DELETE FROM medications WHERE user_id = ANY($1)`, [ids]);
|
||||
await pool.query(`DELETE FROM measurements WHERE user_id = ANY($1)`, [ids]);
|
||||
await pool.query(`DELETE FROM mood_entries WHERE user_id = ANY($1)`, [ids]);
|
||||
await pool.query(`DELETE FROM inbound_documents WHERE user_id = ANY($1)`, [
|
||||
ids,
|
||||
]);
|
||||
await pool.query(`DELETE FROM data_backups WHERE user_id = ANY($1)`, [ids]);
|
||||
|
||||
// The delegate is switched back out of the record; the refusal control
|
||||
// switches in and a stamp that outlived a repetition would start the next
|
||||
// one already inside.
|
||||
await pool.query(
|
||||
`UPDATE sessions SET acting_as_user_id = NULL WHERE user_id = ANY($1)`,
|
||||
[ids],
|
||||
);
|
||||
|
||||
for (const id of ids) {
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE key IN ($1, $2, $3)`,
|
||||
[
|
||||
`admin-backups-run:${id}`,
|
||||
`admin-backups-upload:${id}`,
|
||||
`export:${id}`,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy one stored snapshot and flip a single character of its ciphertext.
|
||||
*
|
||||
* `DataBackup.data` is `~hlgcm1.<keyId>.<base64(iv | ciphertext | authTag)>`
|
||||
* (see `src/lib/crypto.ts`), so the flip lands four fifths of the way in —
|
||||
* well past the header, inside the ciphertext the tag covers. The replacement
|
||||
* stays inside the base64 alphabet, so what the restore meets is a
|
||||
* well-formed envelope whose authentication tag no longer matches, and not a
|
||||
* decoder error standing in for one.
|
||||
*
|
||||
* A COPY rather than the row itself: the journey still needs the good
|
||||
* snapshot afterwards, and a control that destroys its own subject can only
|
||||
* be run once.
|
||||
*/
|
||||
export async function storeTamperedCopy(backupId: string): Promise<string> {
|
||||
return withPool(async (pool) => {
|
||||
const { rows } = await pool.query<{
|
||||
user_id: string;
|
||||
data: string;
|
||||
}>(`SELECT user_id, data FROM data_backups WHERE id = $1`, [backupId]);
|
||||
if (rows.length !== 1) {
|
||||
throw new Error(`no stored backup ${backupId} to copy`);
|
||||
}
|
||||
const stored = rows[0].data;
|
||||
const at = Math.floor(stored.length * 0.8);
|
||||
const original = stored[at];
|
||||
if (!/[A-Za-z0-9+/]/.test(original)) {
|
||||
throw new Error(
|
||||
`the byte at ${at} of the stored blob is '${original}', not ciphertext base64`,
|
||||
);
|
||||
}
|
||||
const flipped = original === "A" ? "B" : "A";
|
||||
const tampered = stored.slice(0, at) + flipped + stored.slice(at + 1);
|
||||
if (tampered === stored) {
|
||||
throw new Error("the tamper changed nothing");
|
||||
}
|
||||
|
||||
const { rows: created } = await pool.query<{ id: string }>(
|
||||
`INSERT INTO data_backups (id, user_id, type, data, created_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
RETURNING id`,
|
||||
[
|
||||
`c${Date.now().toString(36)}tampered${Math.floor(Math.random() * 1e6)}`,
|
||||
rows[0].user_id,
|
||||
`MANUAL_UPLOAD_${Date.now()}`,
|
||||
tampered,
|
||||
],
|
||||
);
|
||||
return created[0].id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop one stored copy again.
|
||||
*
|
||||
* The tampered copy the refusal control writes is the journey's own litter: it
|
||||
* is not deleted until the next repetition's `beforeAll`, and until then it
|
||||
* sits in the console's list for anyone who opens `/admin/backups`.
|
||||
*/
|
||||
export async function deleteStoredCopy(backupId: string): Promise<void> {
|
||||
await withPool(async (pool) => {
|
||||
await pool.query(`DELETE FROM data_backups WHERE id = $1`, [backupId]);
|
||||
});
|
||||
}
|
||||
|
||||
/** Every reading the account holds, tombstoned ones included. */
|
||||
export async function storedMeasurements(): Promise<StoredMeasurement[]> {
|
||||
const userId = await backupAccountId();
|
||||
return withPool(async (pool) => {
|
||||
const { rows } = await pool.query<{
|
||||
id: string;
|
||||
type: string;
|
||||
value: string;
|
||||
deleted_at: Date | null;
|
||||
}>(
|
||||
// `id` after the timestamp: the journey compares two whole arrays to
|
||||
// prove a refusal changed nothing, and two readings sharing a timestamp
|
||||
// could otherwise come back in either order and present as a change.
|
||||
`SELECT id, type, value, deleted_at
|
||||
FROM measurements WHERE user_id = $1
|
||||
ORDER BY measured_at ASC, id ASC`,
|
||||
[userId],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
value: Number(row.value),
|
||||
deletedAt: row.deleted_at ? row.deleted_at.toISOString() : null,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/** Every dose the account holds, tombstoned ones included. */
|
||||
export async function storedIntakeIds(): Promise<
|
||||
Array<{ id: string; deletedAt: string | null }>
|
||||
> {
|
||||
const userId = await backupAccountId();
|
||||
return withPool(async (pool) => {
|
||||
const { rows } = await pool.query<{ id: string; deleted_at: Date | null }>(
|
||||
`SELECT id, deleted_at FROM medication_intake_events
|
||||
WHERE user_id = $1 ORDER BY scheduled_for ASC, id ASC`,
|
||||
[userId],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
deletedAt: row.deleted_at ? row.deleted_at.toISOString() : null,
|
||||
}));
|
||||
});
|
||||
}
|
||||
+120
-2
@@ -71,6 +71,39 @@ export const E2E_OWNER = {
|
||||
*/
|
||||
export const E2E_OWNER_FULL_NAME = "Test Full Name";
|
||||
|
||||
/**
|
||||
* The backup/restore journey's own account.
|
||||
*
|
||||
* Its own account, and an administrative one, because the restore path is
|
||||
* admin-only by construction (`requireAdmin()` is cookie-only) and because the
|
||||
* journey is destructive on purpose: it deletes rows, restores an earlier
|
||||
* snapshot over them, and asserts what came back. Pointing that at an account
|
||||
* any other spec reads would make every one of them depend on where this
|
||||
* journey happens to be.
|
||||
*/
|
||||
export const E2E_BACKUP_ADMIN = {
|
||||
email: "e2e-backup-admin@healthlog.test",
|
||||
username: "e2e-backup-admin",
|
||||
password: "Hw3!Tq7vZm2xRb8L",
|
||||
role: "ADMIN",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The read-level delegate on that account's record.
|
||||
*
|
||||
* The refusing half of the journey needs somebody who is inside the record and
|
||||
* still has no business taking or replaying a copy of it. A separate account
|
||||
* rather than one of the existing delegates: this one is granted READ over the
|
||||
* whole record above, and reusing a delegate would change what the scoped
|
||||
* journeys see.
|
||||
*/
|
||||
export const E2E_BACKUP_DELEGATE = {
|
||||
email: "e2e-backup-delegate@healthlog.test",
|
||||
username: "e2e-backup-delegate",
|
||||
password: "Ld6!Yn4kW9pJc3Vt",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.37.0 — the account that creates and administers managed profiles.
|
||||
*
|
||||
@@ -300,6 +333,23 @@ export const CROSS_TAB_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateCrossTab.json",
|
||||
);
|
||||
|
||||
/** The backup/restore journey's jar, for the account it owns outright. */
|
||||
export const BACKUP_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateBackup.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The read-level delegate's jar for that journey. Its own session row, like
|
||||
* every other switching spec's — the refusal control switches into the
|
||||
* journey's record, and the switch is stamped on the row rather than held in
|
||||
* the tab.
|
||||
*/
|
||||
export const BACKUP_DELEGATE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateBackupDelegate.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The doctor-report delegate journey's jar. Its own session row, for the reason
|
||||
* every switching spec has one: it opens somebody else's record to prove the
|
||||
@@ -795,7 +845,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
WHERE key LIKE 'sharing:%'`,
|
||||
);
|
||||
|
||||
// The login bucket, for the same reason. This setup signs in FOURTEEN times now
|
||||
// The login bucket, for the same reason. This setup signs in SIXTEEN times now
|
||||
// (the shared jar, the owner, and one jar apiece for every spec that moves
|
||||
// a session's record selector), and the ceiling is five attempts per IP per
|
||||
// quarter-hour — so two local runs in a row would otherwise end with a 429
|
||||
@@ -855,6 +905,70 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
["e2e-level-write"],
|
||||
);
|
||||
|
||||
// ── the backup/restore journey's pair ────────────────────────────────
|
||||
//
|
||||
// The account the journey owns, with the three modules its readings need
|
||||
// switched on, and the delegate that holds READ over the whole record.
|
||||
// Both are re-seeded here rather than in the spec so the journey never
|
||||
// spends a login on a surface the setup already owns.
|
||||
for (const account of [E2E_BACKUP_ADMIN, E2E_BACKUP_DELEGATE] as const) {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, $6, true, $7::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json,
|
||||
totp_confirmed_at = NULL`,
|
||||
[
|
||||
cuid(),
|
||||
account.username,
|
||||
account.email,
|
||||
await hashPassword(account.password),
|
||||
account.role,
|
||||
now,
|
||||
JSON.stringify({
|
||||
medications: true,
|
||||
mood: true,
|
||||
inboundDocuments: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Grant hygiene before the grant, for the reason the sharing fixture
|
||||
// states above: a grant and a switch stamp both outlive a run.
|
||||
await pool.query(
|
||||
`DELETE FROM account_grants
|
||||
WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1))
|
||||
OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`,
|
||||
[[E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]],
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE sessions SET acting_as_user_id = NULL
|
||||
WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`,
|
||||
[[E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]],
|
||||
);
|
||||
// Whole-record READ: `scope_json` NULL is the un-narrowed grant, which is
|
||||
// the strongest read a delegate can hold and therefore the honest subject
|
||||
// for "and still cannot take or replay a copy".
|
||||
await pool.query(
|
||||
`INSERT INTO account_grants
|
||||
(id, grantor_id, grantee_id, access, scope_json, invited_at,
|
||||
accepted_at, created_at)
|
||||
SELECT $1, owner.id, delegate.id, 'READ', NULL, $2, $2, $2
|
||||
FROM users owner, users delegate
|
||||
WHERE owner.username = $3 AND delegate.username = $4`,
|
||||
[cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username],
|
||||
);
|
||||
|
||||
// Console (instead of structured logging) is intentional here —
|
||||
// global-setup runs outside the app's logging context, and the
|
||||
// line is useful when debugging a CI failure where the seed didn't
|
||||
@@ -875,7 +989,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
* Log one account in, clearing the login bucket first.
|
||||
*
|
||||
* The ceiling is FIVE attempts per IP per quarter-hour and this setup now
|
||||
* signs in fourteen times, so clearing once before the batch is no longer
|
||||
* signs in sixteen times, so clearing once before the batch is no longer
|
||||
* enough — the sixth would be answered by the fixture's own 429 rather
|
||||
* than by the product. Only the auth surfaces' buckets are touched, and
|
||||
* only between logins this setup is itself performing.
|
||||
@@ -923,6 +1037,10 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// E2E_REPORT_OWNER: generating a report writes to the account row.
|
||||
await capture(E2E_REPORT_OWNER, REPORT_OWNER_STORAGE_STATE_PATH);
|
||||
|
||||
// The backup/restore journey's pair.
|
||||
await capture(E2E_BACKUP_ADMIN, BACKUP_STORAGE_STATE_PATH);
|
||||
await capture(E2E_BACKUP_DELEGATE, BACKUP_DELEGATE_STORAGE_STATE_PATH);
|
||||
|
||||
// v1.37.0 — the guardian's jar, and only then its second factor.
|
||||
//
|
||||
// Order is the whole of it. `requireFreshMfa` refuses an account with no
|
||||
|
||||
+4
-2
@@ -6924,7 +6924,7 @@
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"subtitle": "Wöchentliche Daten-Snapshots pro Nutzer in PostgreSQL.",
|
||||
"description": "Jeder Nutzer hat einen rollierenden Snapshot pro Backup-Typ. Größen zeigen den verschlüsselten Blob — die Inhalte sind hier nicht herunterladbar.",
|
||||
"description": "Jeder Nutzer hat einen rollierenden Snapshot pro Backup-Typ. Größen zeigen den verschlüsselten Blob. Download entschlüsselt den Snapshot für den anfragenden Admin; der Chiffretext bleibt in der Datenbank.",
|
||||
"runNow": "Jetzt sichern",
|
||||
"runEnqueued": "Backup-Job eingeplant",
|
||||
"runFailed": "Backup konnte nicht eingeplant werden",
|
||||
@@ -6959,7 +6959,9 @@
|
||||
"restore": "Wiederherstellen",
|
||||
"restoreAria": "Backup für {username} wiederherstellen",
|
||||
"restoreTitle": "Aus Backup wiederherstellen",
|
||||
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.",
|
||||
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.",
|
||||
"restoreInstanceSettingsLabel": "Instanzweite Einstellungen mitwiederherstellen",
|
||||
"restoreInstanceSettingsHint": "Registrierung, MFA-Pflicht, Standardsprache, Modulverfügbarkeit, Benachrichtigungs- und KI-Konfiguration — aus dem Snapshot, gültig für alle auf dieser Installation. Leer lassen, um nur dieses Konto wiederherzustellen.",
|
||||
"restorePromptLabel": "Tippe RESTORE zum Fortfahren",
|
||||
"previewTitle": "Inhalt der Sicherung",
|
||||
"previewLoading": "Inhalt wird gelesen…",
|
||||
|
||||
+4
-2
@@ -6924,7 +6924,7 @@
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"subtitle": "Weekly per-user data snapshots stored in PostgreSQL.",
|
||||
"description": "Each user gets one rolling snapshot per backup type. Sizes show the encrypted blob — payloads are not downloadable from this page.",
|
||||
"description": "Each user gets one rolling snapshot per backup type. Sizes show the encrypted blob. Download decrypts the snapshot for the admin who asks; the ciphertext stays in the database.",
|
||||
"runNow": "Backup now",
|
||||
"runEnqueued": "Backup job enqueued",
|
||||
"runFailed": "Failed to enqueue backup",
|
||||
@@ -6959,7 +6959,9 @@
|
||||
"restore": "Restore",
|
||||
"restoreAria": "Restore from backup for {username}",
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.",
|
||||
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.",
|
||||
"restoreInstanceSettingsLabel": "Also restore instance settings",
|
||||
"restoreInstanceSettingsHint": "Registration, MFA requirement, default locale, module availability, notification and AI configuration — taken from the snapshot and applied to everybody on this installation. Leave it clear to restore this account alone.",
|
||||
"restorePromptLabel": "Type RESTORE to continue",
|
||||
"previewTitle": "What this backup contains",
|
||||
"previewLoading": "Reading contents…",
|
||||
|
||||
+3
-1
@@ -6924,7 +6924,7 @@
|
||||
"backups": {
|
||||
"title": "Copias de seguridad",
|
||||
"subtitle": "Instantáneas de datos semanales por usuario almacenadas en PostgreSQL.",
|
||||
"description": "Cada usuario tiene una instantánea rotativa por tipo de copia. Los tamaños muestran el blob cifrado; los contenidos no se pueden descargar desde esta página.",
|
||||
"description": "Cada usuario tiene una instantánea rotativa por tipo de copia. Los tamaños muestran el blob cifrado. Descargar descifra la instantánea para el administrador que la pide; el texto cifrado permanece en la base de datos.",
|
||||
"runNow": "Hacer copia ahora",
|
||||
"runEnqueued": "Tarea de copia de seguridad en cola",
|
||||
"runFailed": "No se pudo poner en cola la copia de seguridad",
|
||||
@@ -6960,6 +6960,8 @@
|
||||
"restoreAria": "Restaurar desde copia de seguridad de {username}",
|
||||
"restoreTitle": "Restaurar desde copia de seguridad",
|
||||
"restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.",
|
||||
"restoreInstanceSettingsLabel": "Restaurar también los ajustes de la instancia",
|
||||
"restoreInstanceSettingsHint": "Registro, requisito de MFA, idioma predeterminado, disponibilidad de módulos, configuración de notificaciones e IA: se toman de la instantánea y se aplican a todos en esta instalación. Déjalo sin marcar para restaurar solo esta cuenta.",
|
||||
"restorePromptLabel": "Escribe RESTORE para continuar",
|
||||
"previewTitle": "Contenido de la copia",
|
||||
"previewLoading": "Leyendo el contenido…",
|
||||
|
||||
+3
-1
@@ -6924,7 +6924,7 @@
|
||||
"backups": {
|
||||
"title": "Sauvegardes",
|
||||
"subtitle": "Instantanés de données hebdomadaires par utilisateur stockés dans PostgreSQL.",
|
||||
"description": "Chaque utilisateur dispose d'un instantané glissant par type de sauvegarde. Les tailles indiquent le blob chiffré — les contenus ne sont pas téléchargeables depuis cette page.",
|
||||
"description": "Chaque utilisateur dispose d'un instantané glissant par type de sauvegarde. Les tailles indiquent le blob chiffré. Télécharger déchiffre l'instantané pour l'administrateur qui le demande ; le texte chiffré reste dans la base de données.",
|
||||
"runNow": "Sauvegarder maintenant",
|
||||
"runEnqueued": "Tâche de sauvegarde mise en file d'attente",
|
||||
"runFailed": "Échec de la mise en file d'attente de la sauvegarde",
|
||||
@@ -6960,6 +6960,8 @@
|
||||
"restoreAria": "Restaurer à partir de la sauvegarde de {username}",
|
||||
"restoreTitle": "Restaurer à partir d'une sauvegarde",
|
||||
"restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.",
|
||||
"restoreInstanceSettingsLabel": "Restaurer aussi les paramètres de l'instance",
|
||||
"restoreInstanceSettingsHint": "Inscription, exigence MFA, langue par défaut, disponibilité des modules, configuration des notifications et de l'IA — repris de l'instantané et appliqués à tout le monde sur cette installation. Laissez décoché pour ne restaurer que ce compte.",
|
||||
"restorePromptLabel": "Tapez RESTORE pour continuer",
|
||||
"previewTitle": "Contenu de la sauvegarde",
|
||||
"previewLoading": "Lecture du contenu…",
|
||||
|
||||
+3
-1
@@ -6924,7 +6924,7 @@
|
||||
"backups": {
|
||||
"title": "Backup",
|
||||
"subtitle": "Istantanee settimanali dei dati per utente archiviate in PostgreSQL.",
|
||||
"description": "Ogni utente ha un'istantanea a rotazione per tipo di backup. Le dimensioni mostrano il blob cifrato: i contenuti non sono scaricabili da questa pagina.",
|
||||
"description": "Ogni utente ha un'istantanea a rotazione per tipo di backup. Le dimensioni mostrano il blob cifrato. Il download decifra l'istantanea per l'amministratore che la richiede; il testo cifrato resta nel database.",
|
||||
"runNow": "Esegui backup ora",
|
||||
"runEnqueued": "Processo di backup messo in coda",
|
||||
"runFailed": "Impossibile mettere in coda il backup",
|
||||
@@ -6960,6 +6960,8 @@
|
||||
"restoreAria": "Ripristina dal backup di {username}",
|
||||
"restoreTitle": "Ripristina dal backup",
|
||||
"restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.",
|
||||
"restoreInstanceSettingsLabel": "Ripristina anche le impostazioni dell'istanza",
|
||||
"restoreInstanceSettingsHint": "Registrazione, obbligo di MFA, lingua predefinita, disponibilità dei moduli, configurazione di notifiche e IA: presi dall'istantanea e applicati a tutti su questa installazione. Lascia deselezionato per ripristinare solo questo account.",
|
||||
"restorePromptLabel": "Digita RESTORE per continuare",
|
||||
"previewTitle": "Contenuto del backup",
|
||||
"previewLoading": "Lettura del contenuto…",
|
||||
|
||||
+4
-2
@@ -6904,7 +6904,7 @@
|
||||
"backups": {
|
||||
"title": "백업",
|
||||
"subtitle": "PostgreSQL에 저장되는 주간 사용자별 데이터 스냅샷이에요.",
|
||||
"description": "사용자마다 백업 유형별로 최신 스냅샷 하나를 굴려가며 보관해요. 크기는 암호화된 blob 기준이고, 이 페이지에서 내용을 내려받을 수는 없어요.",
|
||||
"description": "사용자마다 백업 유형별로 최신 스냅샷 하나를 굴려가며 보관해요. 크기는 암호화된 blob 기준이에요. 내려받기는 요청한 관리자에게만 스냅샷을 복호화해 주고, 암호문은 데이터베이스에 그대로 남아요.",
|
||||
"runNow": "지금 백업",
|
||||
"runEnqueued": "백업 작업을 큐에 넣었어요",
|
||||
"runFailed": "백업을 큐에 넣지 못했어요",
|
||||
@@ -6939,7 +6939,9 @@
|
||||
"restore": "복원",
|
||||
"restoreAria": "{username}의 백업에서 복원",
|
||||
"restoreTitle": "백업에서 복원",
|
||||
"restoreDescription": "{username}의 현재 데이터 전체를 {when} 스냅샷으로 바꿔요. 기존 측정, 약, 복용 기록, 기분 기록, 알림 채널, 푸시 구독, 대기 중인 Telegram 삭제 항목이 지워져요. 스냅샷에 인스턴스 전체 설정이 들어 있으면 설치본 전체 설정을 덮어써요.",
|
||||
"restoreDescription": "{username}의 현재 데이터 전체를 {when} 스냅샷으로 바꿔요. 기존 측정, 약, 복용 기록, 기분 기록, 알림 채널, 푸시 구독, 대기 중인 Telegram 삭제 항목이 지워져요.",
|
||||
"restoreInstanceSettingsLabel": "인스턴스 설정도 함께 복원",
|
||||
"restoreInstanceSettingsHint": "가입, MFA 필수 여부, 기본 언어, 모듈 사용 여부, 알림과 AI 설정 — 스냅샷에서 가져와 이 설치본의 모든 사용자에게 적용돼요. 이 계정만 복원하려면 선택하지 마세요.",
|
||||
"restorePromptLabel": "계속하려면 RESTORE를 입력해 주세요",
|
||||
"previewTitle": "이 백업에 담긴 내용",
|
||||
"previewLoading": "내용을 읽는 중…",
|
||||
|
||||
+3
-1
@@ -6924,7 +6924,7 @@
|
||||
"backups": {
|
||||
"title": "Kopie zapasowe",
|
||||
"subtitle": "Cotygodniowe migawki danych dla każdego użytkownika przechowywane w PostgreSQL.",
|
||||
"description": "Każdy użytkownik ma jedną przesuwaną migawkę na typ kopii. Rozmiary pokazują zaszyfrowany blob — zawartości nie można pobrać z tej strony.",
|
||||
"description": "Każdy użytkownik ma jedną przesuwaną migawkę na typ kopii. Rozmiary pokazują zaszyfrowany blob. Pobieranie odszyfrowuje migawkę dla proszącego administratora; szyfrogram zostaje w bazie danych.",
|
||||
"runNow": "Utwórz kopię teraz",
|
||||
"runEnqueued": "Zadanie kopii zapasowej dodano do kolejki",
|
||||
"runFailed": "Nie udało się dodać kopii do kolejki",
|
||||
@@ -6960,6 +6960,8 @@
|
||||
"restoreAria": "Przywróć z kopii zapasowej dla {username}",
|
||||
"restoreTitle": "Przywróć z kopii zapasowej",
|
||||
"restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.",
|
||||
"restoreInstanceSettingsLabel": "Przywróć też ustawienia instancji",
|
||||
"restoreInstanceSettingsHint": "Rejestracja, wymóg MFA, domyślny język, dostępność modułów, konfiguracja powiadomień i AI — brane z migawki i stosowane do wszystkich w tej instalacji. Zostaw niezaznaczone, aby przywrócić tylko to konto.",
|
||||
"restorePromptLabel": "Wpisz RESTORE, aby kontynuować",
|
||||
"previewTitle": "Zawartość kopii",
|
||||
"previewLoading": "Odczytywanie zawartości…",
|
||||
|
||||
@@ -191,6 +191,11 @@ export default defineConfig({
|
||||
// through stable data attributes, not a mobile layout, so it runs in
|
||||
// one project.
|
||||
"apple-health-import.spec.ts",
|
||||
// The backup/restore journey, for both reasons already on this list
|
||||
// at once: it restores a snapshot over its own account, so a second
|
||||
// project would be two browsers rewriting one record's tables in
|
||||
// parallel, and its last act switches a session into that record.
|
||||
"backup-restore-journey.spec.ts",
|
||||
],
|
||||
use: {
|
||||
// Pixel 5 — Chromium-based mobile profile so CI only needs
|
||||
|
||||
@@ -124,10 +124,7 @@ function idempotentOperationsFromContract(): Set<string> {
|
||||
* Listed rather than filtered silently, so the exclusion stays visible in the
|
||||
* file that would otherwise claim full coverage.
|
||||
*/
|
||||
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {
|
||||
"/api/admin/backups/{id}/restore":
|
||||
"Admin surface: cookie-only by construction, so arguably out of scope for a client contract.",
|
||||
};
|
||||
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {};
|
||||
|
||||
describe("idempotency — the contract matches the wrapper", () => {
|
||||
it("finds idempotent operations in the source tree at all", () => {
|
||||
|
||||
@@ -217,14 +217,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/api/admin/backups": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/backups/upload": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/backups/{id}/download": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/backups/{id}/restore": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
// `/api/admin/backups/{id}/download` and `/api/admin/backups/{id}/restore`
|
||||
// are published rather than exempted: both open the encrypted stored copy,
|
||||
// and the refusal they answer when it will not open is a promise an operator
|
||||
// needs to be able to look up. See `src/lib/openapi/routes/admin-backups.ts`.
|
||||
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/central-codex": {
|
||||
kind: "adminConsole",
|
||||
|
||||
@@ -19,7 +19,11 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { unpackBackupBlob } from "@/lib/export/backup-blob";
|
||||
import {
|
||||
BACKUP_UNDECRYPTABLE_CODE,
|
||||
BACKUP_UNDECRYPTABLE_ERROR,
|
||||
unpackBackupBlob,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
@@ -54,9 +58,10 @@ export const GET = apiHandler(
|
||||
try {
|
||||
plaintext = unpackBackupBlob(backup.data);
|
||||
} catch (err) {
|
||||
// Decryption failure is rare but real — a rotated/missing key, or a
|
||||
// corrupted blob from a partially-failed write. Surface it as a 500
|
||||
// and audit the event so the admin has something to investigate.
|
||||
// A rotated or dropped key, or a stored copy that is no longer the one
|
||||
// that was written. Both are bad stored input rather than a fault in
|
||||
// this process, so the answer is 422 with a reason the operator can act
|
||||
// on — and the audit row keeps the underlying message.
|
||||
await auditLog("admin.backups.download.failed", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -66,7 +71,9 @@ export const GET = apiHandler(
|
||||
reason: err instanceof Error ? err.message : "decrypt_failed",
|
||||
},
|
||||
});
|
||||
return apiError("Failed to decrypt backup payload", 500);
|
||||
return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, {
|
||||
errorCode: BACKUP_UNDECRYPTABLE_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the payload BEFORE handing it back. This catches the
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
* Metadata-only portable document exports are rejected before mutation; the
|
||||
* importer never fabricates content. Audit rows remain outside the wipe, and
|
||||
* cache invalidation runs only after the complete restore transaction.
|
||||
*
|
||||
* Everything above is scoped to ONE account. The instance-wide settings a
|
||||
* disaster-recovery payload also carries are not, and they are opt-in for that
|
||||
* reason: see the `restoreInstanceSettings` block below.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
@@ -19,7 +23,11 @@ import { prisma, toJson } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { unpackBackupBlob } from "@/lib/export/backup-blob";
|
||||
import {
|
||||
BACKUP_UNDECRYPTABLE_CODE,
|
||||
BACKUP_UNDECRYPTABLE_ERROR,
|
||||
unpackBackupBlob,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
@@ -142,17 +150,26 @@ const handler = apiHandler(
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.backups.restore" }, meta: { id } });
|
||||
|
||||
let body: { confirm?: string } = {};
|
||||
let body: { confirm?: string; restoreInstanceSettings?: boolean } = {};
|
||||
try {
|
||||
const raw = await request.text();
|
||||
if (raw.length > 64 * 1024) {
|
||||
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
||||
}
|
||||
body = JSON.parse(raw) as { confirm?: string };
|
||||
body = JSON.parse(raw) as {
|
||||
confirm?: string;
|
||||
restoreInstanceSettings?: boolean;
|
||||
};
|
||||
} catch {
|
||||
return apiError("Invalid JSON body", 400);
|
||||
}
|
||||
|
||||
// Restoring one account's data and reconfiguring the whole host are two
|
||||
// different decisions, so they are two different answers. Anything but a
|
||||
// literal `true` restores the account alone.
|
||||
const restoreInstanceSettings = body.restoreInstanceSettings === true;
|
||||
annotate({ meta: { restore_instance_settings: restoreInstanceSettings } });
|
||||
|
||||
if (body.confirm !== "RESTORE") {
|
||||
await auditLog("admin.backups.restore.denied", {
|
||||
userId: admin.id,
|
||||
@@ -160,7 +177,7 @@ const handler = apiHandler(
|
||||
details: { reason: "missing_confirmation", backupId: id },
|
||||
});
|
||||
return apiError(
|
||||
"Confirmation token missing — restoring user data and included instance-wide settings requires confirm: 'RESTORE'",
|
||||
"Confirmation token missing — replacing an account's data requires confirm: 'RESTORE'",
|
||||
422,
|
||||
);
|
||||
}
|
||||
@@ -191,7 +208,14 @@ const handler = apiHandler(
|
||||
reason: err instanceof Error ? err.message : "decrypt_failed",
|
||||
},
|
||||
});
|
||||
return apiError("Failed to decrypt backup payload", 500);
|
||||
// Bad stored input, not a broken server: a copy written under a key the
|
||||
// operator has since dropped, or one whose bytes have changed. Every
|
||||
// other bad-input arm on this route answers 4xx, and a 500 here would
|
||||
// also page the error reporter for a rotation mistake. Refused above the
|
||||
// transaction, so nothing was touched.
|
||||
return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, {
|
||||
errorCode: BACKUP_UNDECRYPTABLE_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
// Parsed once and kept, because the schema's per-section `.default([])`
|
||||
@@ -292,6 +316,7 @@ const handler = apiHandler(
|
||||
ownerId,
|
||||
ownerUsername: owner.username,
|
||||
snapshotExportedAt: payload.exportedAt,
|
||||
restoreInstanceSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -441,7 +466,20 @@ const handler = apiHandler(
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
if (payload.appSettings) {
|
||||
// The one section of the file that is not this account's.
|
||||
//
|
||||
// A disaster-recovery payload 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. Writing it back as
|
||||
// a side effect of restoring ONE account reconfigures the host for
|
||||
// everybody on it, silently and hours or weeks out of date: an
|
||||
// operator putting a single record back on Wednesday has no reason to
|
||||
// expect Monday's registration switch and Monday's upload cap to come
|
||||
// with it. Rebuilding a host from a snapshot is a real case, so the
|
||||
// write stays available — as an answer the operator gives, not one
|
||||
// the account restore assumes.
|
||||
if (payload.appSettings && restoreInstanceSettings) {
|
||||
const settings = payload.appSettings;
|
||||
const settingsData = {
|
||||
registrationEnabled: settings.registrationEnabled,
|
||||
@@ -2084,6 +2122,10 @@ const handler = apiHandler(
|
||||
backupId: id,
|
||||
ownerId,
|
||||
ownerUsername: owner.username,
|
||||
// Whether this restore also rewrote the host's own settings. The one
|
||||
// effect of a restore that reaches accounts other than `ownerId`, so
|
||||
// the trail has to be able to answer it later.
|
||||
restoreInstanceSettings,
|
||||
cleared,
|
||||
// The durable half of the report. The response reaches whoever was
|
||||
// looking at the screen; the audit row is still here next week when
|
||||
|
||||
@@ -81,12 +81,18 @@ describe("GET /api/admin/backups/[id]/summary", () => {
|
||||
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("degrades to an error response when the file cannot be decrypted", async () => {
|
||||
it("refuses a file it cannot decrypt with the documented 422", async () => {
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("bad key");
|
||||
});
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(500);
|
||||
// Bad stored input — a key dropped from `ENCRYPTION_KEYS`, or bytes that
|
||||
// are not the ones written — and not a fault in this process, so it does
|
||||
// not answer 500 and does not reach the error reporter as one.
|
||||
expect(res.status).toBe(422);
|
||||
expect(await res.json()).toMatchObject({
|
||||
meta: { errorCode: "backup.payload.undecryptable" },
|
||||
});
|
||||
});
|
||||
|
||||
it("422s a file that fails schema validation", async () => {
|
||||
|
||||
@@ -16,7 +16,11 @@ import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { unpackBackupBlob } from "@/lib/export/backup-blob";
|
||||
import {
|
||||
BACKUP_UNDECRYPTABLE_CODE,
|
||||
BACKUP_UNDECRYPTABLE_ERROR,
|
||||
unpackBackupBlob,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
@@ -46,7 +50,12 @@ export const GET = apiHandler(
|
||||
try {
|
||||
plaintext = unpackBackupBlob(backup.data);
|
||||
} catch {
|
||||
return apiError("Failed to decrypt backup payload", 500);
|
||||
// Same refusal the restore and the download give, for the same reason:
|
||||
// a copy this instance cannot open is bad stored input, and the preview
|
||||
// is the first place an operator meets it.
|
||||
return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, {
|
||||
errorCode: BACKUP_UNDECRYPTABLE_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
let payload;
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* GET /api/admin/backups — admin-only listing of `DataBackup` rows.
|
||||
*
|
||||
* Returns one row per (userId, type) pair. The `data` payload is NOT
|
||||
* shipped — only metadata (id, userId, username, type, size in bytes,
|
||||
* createdAt). The encrypted blob remains server-side; admins can trigger
|
||||
* a re-snapshot but not download another user's payload from the UI.
|
||||
* shipped by THIS route — only metadata (id, userId, username, type, size in
|
||||
* bytes, createdAt), so the listing costs a count and not a decrypt. The
|
||||
* payload has its own route: `/api/admin/backups/<id>/download` decrypts one
|
||||
* snapshot for the admin who asks for it, and the ciphertext stays in the
|
||||
* database either way.
|
||||
*
|
||||
* Alongside the rows, the response says whether the SCHEDULE is still alive:
|
||||
* how old the newest scheduled copy is and how the last scheduled run ended.
|
||||
|
||||
@@ -140,12 +140,21 @@ describe("BackupsSection — empty state", () => {
|
||||
expect(className).not.toMatch(/\b(?:opacity-\d+|text-\S+\/\d+)\b/);
|
||||
});
|
||||
|
||||
it("warns in both catalogs that restore overwrites instance-wide settings", () => {
|
||||
expect(de.admin.section.backups.restoreDescription).toMatch(
|
||||
it("names the instance-wide effect on the opt-in that carries it, in both catalogs", () => {
|
||||
// The restore rewrites the host's own settings only when the operator
|
||||
// ticks the box, so the warning belongs to the box rather than to the
|
||||
// sentence above it — and the box has to say what it turns on.
|
||||
expect(de.admin.section.backups.restoreInstanceSettingsLabel).toMatch(
|
||||
/instanzweite Einstellungen/i,
|
||||
);
|
||||
expect(en.admin.section.backups.restoreDescription).toMatch(
|
||||
/instance-wide settings/i,
|
||||
expect(de.admin.section.backups.restoreInstanceSettingsHint).toMatch(
|
||||
/Installation/,
|
||||
);
|
||||
expect(en.admin.section.backups.restoreInstanceSettingsLabel).toMatch(
|
||||
/instance settings/i,
|
||||
);
|
||||
expect(en.admin.section.backups.restoreInstanceSettingsHint).toMatch(
|
||||
/installation/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -124,13 +125,18 @@ function RestoreRowDialog({
|
||||
}: {
|
||||
row: BackupRow;
|
||||
pending: boolean;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (options: { restoreInstanceSettings: boolean }) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const matched = typed.trim() === "RESTORE";
|
||||
// Off unless the operator says otherwise. A disaster-recovery snapshot
|
||||
// carries the host's own settings alongside the account's data, and putting
|
||||
// one account back is not a reason to reconfigure the installation for
|
||||
// everybody on it — so the second effect is a second decision.
|
||||
const [withInstanceSettings, setWithInstanceSettings] = useState(false);
|
||||
|
||||
// v1.37.20 — restore preview: fetch what the file contains the moment the
|
||||
// dialog opens, so the typed confirmation is an informed one. Derived from
|
||||
@@ -170,13 +176,17 @@ function RestoreRowDialog({
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setTyped("");
|
||||
if (!next) {
|
||||
setTyped("");
|
||||
setWithInstanceSettings(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
data-testid="backup-restore-trigger"
|
||||
disabled={pending}
|
||||
aria-label={t("admin.section.backups.restoreAria", {
|
||||
username: row.username,
|
||||
@@ -254,12 +264,33 @@ function RestoreRowDialog({
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{/* The second decision, before the typed gate rather than after it:
|
||||
the account's data comes back either way, the host's settings only
|
||||
if this is ticked. */}
|
||||
<label
|
||||
className="flex items-start gap-2 text-sm"
|
||||
data-slot="restore-instance-settings"
|
||||
>
|
||||
<Checkbox
|
||||
data-testid="backup-restore-instance-settings"
|
||||
checked={withInstanceSettings}
|
||||
onCheckedChange={(next) => setWithInstanceSettings(next === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t("admin.section.backups.restoreInstanceSettingsLabel")}
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("admin.section.backups.restoreInstanceSettingsHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`restore-prompt-${row.id}`}>
|
||||
{t("admin.section.backups.restorePromptLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`restore-prompt-${row.id}`}
|
||||
data-testid="backup-restore-prompt"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
@@ -270,13 +301,15 @@ function RestoreRowDialog({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="backup-restore-confirm"
|
||||
disabled={!matched || pending}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!matched) return;
|
||||
setOpen(false);
|
||||
setTyped("");
|
||||
onConfirm();
|
||||
onConfirm({ restoreInstanceSettings: withInstanceSettings });
|
||||
setWithInstanceSettings(false);
|
||||
}}
|
||||
>
|
||||
{pending
|
||||
@@ -778,14 +811,20 @@ export function BackupsSection() {
|
||||
// and used inline by `<RestoreRowDialog>` below — keeping the
|
||||
// mutation here lets the parent invalidate the list query on success.
|
||||
const restore = useMutation({
|
||||
mutationFn: async (row: BackupRow) => {
|
||||
mutationFn: async ({
|
||||
row,
|
||||
restoreInstanceSettings,
|
||||
}: {
|
||||
row: BackupRow;
|
||||
restoreInstanceSettings: boolean;
|
||||
}) => {
|
||||
// Idempotency-Key prevents a double-click from re-running the
|
||||
// destructive transaction. Include the row id so two different
|
||||
// backups can both be restored independently in the same minute.
|
||||
const idempotencyKey = `restore-${row.id}-${randomId()}`;
|
||||
return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>(
|
||||
`/api/admin/backups/${row.id}/restore`,
|
||||
{ confirm: "RESTORE" },
|
||||
{ confirm: "RESTORE", restoreInstanceSettings },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
);
|
||||
},
|
||||
@@ -978,6 +1017,11 @@ export function BackupsSection() {
|
||||
// primitive. The header already exposes "Backup now" but a
|
||||
// brand-new admin lands inside the card and benefits from a
|
||||
// duplicate CTA right next to the explanation.
|
||||
//
|
||||
// Its own test id, because both buttons are in the tree while the list
|
||||
// is empty and they do the same thing: one id on two elements forces
|
||||
// every caller to pick one blind, and a third button would join the
|
||||
// pick without anything failing.
|
||||
<div>
|
||||
<EmptyState
|
||||
icon={<Database className="size-6" />}
|
||||
@@ -986,6 +1030,7 @@ export function BackupsSection() {
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="backup-run-now-empty"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
@@ -1033,7 +1078,12 @@ export function BackupsSection() {
|
||||
it holds at any viewport. */}
|
||||
<tbody data-slot="backup-rows" className="divide-border divide-y">
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<tr
|
||||
key={row.id}
|
||||
data-backup-id={row.id}
|
||||
data-backup-username={row.username}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{row.username}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -1051,6 +1101,7 @@ export function BackupsSection() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="backup-download"
|
||||
disabled={downloadingId === row.id}
|
||||
onClick={() => handleDownload(row)}
|
||||
aria-label={t("admin.section.backups.downloadAria", {
|
||||
@@ -1069,9 +1120,11 @@ export function BackupsSection() {
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending &&
|
||||
restore.variables?.id === row.id
|
||||
restore.variables?.row.id === row.id
|
||||
}
|
||||
onConfirm={(options) =>
|
||||
restore.mutate({ row, ...options })
|
||||
}
|
||||
onConfirm={() => restore.mutate(row)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1095,7 +1148,7 @@ export function BackupsSection() {
|
||||
key={row.id}
|
||||
className="bg-muted/30 border-border"
|
||||
>
|
||||
<li>
|
||||
<li data-backup-id={row.id} data-backup-username={row.username}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{row.username}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -1111,6 +1164,7 @@ export function BackupsSection() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="backup-download"
|
||||
disabled={downloadingId === row.id}
|
||||
onClick={() => handleDownload(row)}
|
||||
aria-label={t("admin.section.backups.downloadAria", {
|
||||
@@ -1128,9 +1182,12 @@ export function BackupsSection() {
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending && restore.variables?.id === row.id
|
||||
restore.isPending &&
|
||||
restore.variables?.row.id === row.id
|
||||
}
|
||||
onConfirm={(options) =>
|
||||
restore.mutate({ row, ...options })
|
||||
}
|
||||
onConfirm={() => restore.mutate(row)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1149,6 +1206,7 @@ export function BackupsSection() {
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="backup-run-now"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
|
||||
@@ -223,6 +223,28 @@ export async function packBackupBlobStreaming(
|
||||
return pieces.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal a caller gets when a stored copy will not open, as one string.
|
||||
*
|
||||
* Two causes, one answer, because the envelope cannot tell them apart and the
|
||||
* operator's next step is the same either way: the key that wrote the copy is
|
||||
* no longer in `ENCRYPTION_KEYS` (a rotation that dropped the legacy entry too
|
||||
* early — the case `docs/ops/encryption-key-rotation.md` warns about), or the
|
||||
* stored bytes are not the bytes that were written. Neither is a server fault,
|
||||
* so neither is a 500: it is bad stored input, refused with 422 like every
|
||||
* other bad input on these routes, and it never reaches the error reporter as
|
||||
* if the process had broken.
|
||||
*
|
||||
* Shared rather than retyped, so the three routes that decrypt a stored copy —
|
||||
* restore, download, summary — cannot drift into three different sentences for
|
||||
* the same condition.
|
||||
*/
|
||||
export const BACKUP_UNDECRYPTABLE_ERROR =
|
||||
"Backup payload could not be decrypted — either the key that wrote this copy is no longer in ENCRYPTION_KEYS, or the stored copy is not the one that was written. Nothing was changed.";
|
||||
|
||||
/** The stable machine-readable half of {@link BACKUP_UNDECRYPTABLE_ERROR}. */
|
||||
export const BACKUP_UNDECRYPTABLE_CODE = "backup.payload.undecryptable";
|
||||
|
||||
/**
|
||||
* A stored `DataBackup.data` string → the backup JSON.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* OpenAPI route table — the two backups-console routes that read a stored copy.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*
|
||||
* Most of `/api/admin/backups/*` is deliberately unpublished: it is a
|
||||
* cookie-only console, `requireAdmin()` refuses every Bearer caller, and a
|
||||
* contract nobody can call against is a contract nobody reads. These two are
|
||||
* here for the opposite reason. Both open the encrypted copy in
|
||||
* `data_backups.data`, and both therefore have a refusal an operator meets on
|
||||
* the day a key rotation went one step too far — the case
|
||||
* `docs/ops/encryption-key-rotation.md` warns about. That refusal is a promise
|
||||
* (422, `backup.payload.undecryptable`, nothing changed) rather than whatever
|
||||
* the handler happens to answer, and a promise belongs somewhere it can be
|
||||
* quoted from.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { dataEnvelope, errorEnvelope, idempotencyKeyParameter } from "./shared";
|
||||
|
||||
const restoreRequest = z
|
||||
.object({
|
||||
confirm: z.literal("RESTORE"),
|
||||
restoreInstanceSettings: z.boolean().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "AdminBackupRestoreRequest",
|
||||
description:
|
||||
"`confirm` is the literal typed gate; anything else is refused with 422 and nothing is read. `restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only when rebuilding a host from a snapshot, because those settings apply to every account on the installation.",
|
||||
});
|
||||
|
||||
const restoreSkipEntry = z
|
||||
.object({
|
||||
catalogue: z.string(),
|
||||
key: z.string(),
|
||||
links: z.number().int(),
|
||||
})
|
||||
.meta({
|
||||
id: "AdminBackupRestoreSkipEntry",
|
||||
description:
|
||||
"One catalogue key the file referenced and this instance could not resolve, with the number of links it cost.",
|
||||
});
|
||||
|
||||
const restoreResult = z
|
||||
.object({
|
||||
restored: z.literal(true),
|
||||
summary: z.record(z.string(), z.unknown()),
|
||||
skipped: z.object({
|
||||
catalogueKeys: z.array(restoreSkipEntry),
|
||||
links: z.number().int(),
|
||||
}),
|
||||
cleared: z.record(z.string(), z.number().int()),
|
||||
})
|
||||
.meta({
|
||||
id: "AdminBackupRestoreResult",
|
||||
description:
|
||||
"What the transaction wrote. `cleared` counts the rows each class held before it was rebuilt from the file, `summary` counts what the file carried, and `skipped.links` is the number of links dropped because a catalogue key in the file does not exist here — zero is the normal answer.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The one refusal both routes share, worded once.
|
||||
*
|
||||
* A stored copy that will not open is bad stored input, not a fault in the
|
||||
* server: either the key that wrote it is no longer in `ENCRYPTION_KEYS`, or
|
||||
* the stored bytes are not the bytes that were written. It answers 422 like
|
||||
* every other bad input on these routes, and never as a 500.
|
||||
*/
|
||||
const undecryptableResponse = {
|
||||
description:
|
||||
"The stored copy could not be opened: either the key that wrote it is no longer in `ENCRYPTION_KEYS` (a rotation that dropped the legacy entry too early), or the stored bytes are not the ones that were written. `meta.errorCode` = `backup.payload.undecryptable`. Nothing was changed.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
const adminOnlyResponse = {
|
||||
description:
|
||||
"Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
const notFoundResponse = {
|
||||
description: "No stored copy with this id.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/admin/backups/{id}/download": {
|
||||
get: {
|
||||
tags: ["Admin"],
|
||||
summary: "Download one stored backup as JSON",
|
||||
description:
|
||||
"Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: z.string().meta({ description: "`DataBackup.id`." }),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The decrypted backup document.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.record(z.string(), z.unknown()).meta({
|
||||
id: "AdminBackupDocument",
|
||||
description:
|
||||
"The backup payload as the writer serialised it: `schemaVersion`, `exportedAt`, `userId`, and one array or object per serialised class.",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": adminOnlyResponse,
|
||||
"404": notFoundResponse,
|
||||
"422": undecryptableResponse,
|
||||
"500": {
|
||||
description:
|
||||
"The copy decrypted and then failed schema validation — a document this instance wrote and cannot parse. This one genuinely is a server fault, and is reported as one.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/admin/backups/{id}/restore": {
|
||||
post: {
|
||||
tags: ["Admin"],
|
||||
summary: "Restore one stored backup over its owner's record",
|
||||
description:
|
||||
"Replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it, and a payload declaring a different owner than the stored row is refused. Admin session cookie required; Bearer tokens cannot reach admin endpoints.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: z.string().meta({ description: "`DataBackup.id`." }),
|
||||
}),
|
||||
},
|
||||
// The one write here, and the destructive one: the console sends a
|
||||
// per-row key so a double-click replays the first answer instead of
|
||||
// running the transaction twice.
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: restoreRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The restore ran.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(restoreResult, "AdminBackupRestoreResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Body was not JSON.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": adminOnlyResponse,
|
||||
"404": notFoundResponse,
|
||||
"409": {
|
||||
description:
|
||||
"Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 64 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The request or the file was refused before anything was written: `confirm` missing, the copy undecryptable (`meta.errorCode` = `backup.payload.undecryptable`), the payload failing schema validation, an unsupported `schemaVersion`, a section the file's own manifest says it carries and does not (`meta.errorCode` = `backup.section.missing`), a metadata-only document entry, or an owner who no longer exists here.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
recordScopeParameter,
|
||||
} from "./account-sharing";
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { adminBackupPaths } from "./admin-backups";
|
||||
import { allergyPaths } from "./allergies";
|
||||
import { analyticsPaths } from "./analytics";
|
||||
import { authPaths } from "./auth";
|
||||
@@ -163,6 +164,12 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...awardsPaths,
|
||||
...environmentPaths,
|
||||
...ingestPaths,
|
||||
// The two backups-console routes that open a stored copy. The rest of the
|
||||
// family stays unpublished — it is a cookie-only console — but these two
|
||||
// carry a refusal an operator has to be able to look up rather than
|
||||
// discover: a copy this instance can no longer decrypt (appended, spread
|
||||
// order is load-bearing).
|
||||
...adminBackupPaths,
|
||||
// The environmental-context overview. Its own module because nothing else
|
||||
// owns the surface (appended, spread order is load-bearing).
|
||||
//
|
||||
|
||||
@@ -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