test(e2e): drive backup and restore end to end through the settings surfaces

One journey across the whole path: an account writes readings in three
domains and files a PDF, downloads its own record as the passphrase-sealed
archive, an admin snapshots it from the backups console, and the snapshot is
restored over an account that has since lost a reading and a dose and gained
one the snapshot never saw.

The assertions address what the documentation promises rather than what the
implementation happens to do. The archive is checked byte by byte against the
HLX1 header in src/lib/export/passphrase-archive.ts — magic, version, KDF id
and the Argon2 cost the file carries — because "a file arrived" is equally
true of plaintext JSON under a new extension. The restored reading and dose
are checked by their ORIGINAL ids, because a restore that re-created them
under fresh ones would have written a different record that reads the same.
The reading created after the snapshot is asserted GONE: the runbook says the
restore replaces the account's data tables, and replacing is not merging.

Two refusing controls. A stored copy with one flipped ciphertext byte is
refused with the route's decrypt failure and leaves the record untouched — the
envelope is AES-256-GCM and its tag covers every byte, so the flip is caught
before the transaction opens. A delegate holding whole-record READ inside the
account gets the not-covered panel instead of the export card, never reaches
the console, and is refused 403 by the list, the manual run and the restore,
the last of them naming the real snapshot so the refusal cannot be mistaken
for a missing row.

Waiting is on the job's own evidence: the console's endpoint is polled until
this account's stored copy is newer than the click, so nothing in the file
guesses at a queue. The journey has its own account, its own delegate and its
own cookie jars; only the reset between repetitions and the deliberate
corruption reach Postgres directly, because a browser cannot express either.

The backups console gains data-testid hooks and a data-backup-id on each row
so the spec addresses controls rather than translated labels.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:53:03 +02:00
1 parent 297395372b
commit 5cb5c7df44
5 files changed
+963 -4

No files matched your search

+619
View File
@@ -0,0 +1,619 @@
/**
* 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.
*
* 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 {
BACKUP_DELEGATE_STORAGE_STATE_PATH,
BACKUP_STORAGE_STATE_PATH,
E2E_BACKUP_ADMIN,
} from "./setup/global-setup";
import {
backupAccountId,
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;
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.
*/
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();
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;
await resetBackupJourney();
});
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.
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);
expect(bytes.readUInt32BE(6), "Argon2 memory cost, KiB").toBe(19456);
expect(bytes.readUInt32BE(10), "Argon2 time cost").toBe(2);
expect(bytes[14], "Argon2 parallelism").toBe(1);
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);
const clickedAt = Date.now();
await page.getByTestId("backup-run-now").first().click();
// 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 &&
new Date(row.createdAt).getTime() >= clickedAt - 1_000;
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[0]?.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());
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. 500 with this sentence
// is what the route answers on a payload it could not decrypt.
expect(response.status()).toBe(500);
expect(await response.json()).toMatchObject({
data: null,
error: "Failed to decrypt backup payload",
});
// 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" });
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
await expect(page.getByTestId("backup-run-now")).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);
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* 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;
});
}
/** 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;
}>(
`SELECT id, type, value, deleted_at
FROM measurements WHERE user_id = $1 ORDER BY measured_at 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`,
[userId],
);
return rows.map((row) => ({
id: row.id,
deletedAt: row.deleted_at ? row.deleted_at.toISOString() : null,
}));
});
}
+120 -2
View File
@@ -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.
*
@@ -250,6 +283,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 banner-geometry spec's jar, and the reason it exists is a release-shaped
* one rather than a load-shaped one.
@@ -647,7 +697,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 TEN times now
// The login bucket, for the same reason. This setup signs in TWELVE 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
@@ -707,6 +757,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
@@ -727,7 +841,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 ten times, so clearing once before the batch is no longer
* signs in twelve 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.
@@ -766,6 +880,10 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_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
+5
View File
@@ -171,6 +171,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
+14 -2
View File
@@ -177,6 +177,7 @@ function RestoreRowDialog({
<Button
size="sm"
variant="destructive"
data-testid="backup-restore-trigger"
disabled={pending}
aria-label={t("admin.section.backups.restoreAria", {
username: row.username,
@@ -260,6 +261,7 @@ function RestoreRowDialog({
</Label>
<Input
id={`restore-prompt-${row.id}`}
data-testid="backup-restore-prompt"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
@@ -270,6 +272,7 @@ function RestoreRowDialog({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
data-testid="backup-restore-confirm"
disabled={!matched || pending}
variant="destructive"
onClick={() => {
@@ -986,6 +989,7 @@ export function BackupsSection() {
action={
<Button
size="sm"
data-testid="backup-run-now"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
@@ -1033,7 +1037,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 +1060,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", {
@@ -1095,7 +1105,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 +1121,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", {
@@ -1149,6 +1160,7 @@ export function BackupsSection() {
<SettingsCardActions>
<Button
size="sm"
data-testid="backup-run-now"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"