mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(e2e): say what the backup journey really touches, and pin it to promises
The file claimed it stayed inside its own account. "Backup now" enqueues the pass for every account on the instance, so the header says that now, and says what is confined instead: the restore's transaction, and the instance-wide settings the dialog's opt-in leaves alone — which the journey asserts is clear before it confirms, because a default that flipped would hand this file the power to rewrite the document size cap for every spec sharing the server. The run's own answer is asserted where it happens. Without it a missing worker cost two minutes of polling and then blamed the backup pass for a 503 about something else; the failure now names the endpoint in two seconds. The rest are the assertions that pinned incidentals rather than promises: the Argon2 cost comes from the constant the writer exports, so a legitimate cost bump is not a red spec; the document is found by id rather than by position; the poll compares the row against the row it saw before the click rather than against the runner's clock; the non-admin bounce is pinned by pathname; the empty-state CTA has its own test id, so nothing has to pick between two buttons that share one; the deliberately corrupted copy is deleted when the control is done with it instead of sitting in the console's list; and the fixture orders by id after the timestamp so a tie cannot present as a change.
This commit is contained in:
1 parent
6e4f3fb4c4
commit
343d6397a0
3 files changed
+113
-13
No files matched your search
@@ -25,6 +25,15 @@
|
||||
* 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
|
||||
@@ -41,6 +50,8 @@ 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,
|
||||
@@ -48,6 +59,7 @@ import {
|
||||
} from "./setup/global-setup";
|
||||
import {
|
||||
backupAccountId,
|
||||
deleteStoredCopy,
|
||||
resetBackupJourney,
|
||||
storedIntakeIds,
|
||||
storedMeasurements,
|
||||
@@ -102,6 +114,8 @@ interface SeededRecord {
|
||||
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");
|
||||
@@ -233,7 +247,10 @@ async function openBackupsConsole(page: Page): Promise<void> {
|
||||
*
|
||||
* 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.
|
||||
* 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`);
|
||||
@@ -247,6 +264,13 @@ async function restoreThroughDialog(page: Page, id: string) {
|
||||
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;
|
||||
@@ -258,9 +282,15 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
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,
|
||||
}) => {
|
||||
@@ -272,7 +302,11 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
const record = seeded;
|
||||
|
||||
// On the list, by the number the row carries rather than by the text the
|
||||
// formatter prints for whoever is looking.
|
||||
// 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
|
||||
@@ -303,17 +337,46 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
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);
|
||||
// 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);
|
||||
const clickedAt = Date.now();
|
||||
await page.getByTestId("backup-run-now").first().click();
|
||||
// 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
|
||||
@@ -326,7 +389,9 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
const row = await readConsoleRow(page);
|
||||
const fresh =
|
||||
row !== null &&
|
||||
new Date(row.createdAt).getTime() >= clickedAt - 1_000;
|
||||
(previous === null ||
|
||||
row.id !== previous.id ||
|
||||
row.createdAt !== previous.createdAt);
|
||||
if (fresh) found.row = row;
|
||||
return fresh;
|
||||
},
|
||||
@@ -372,7 +437,10 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
// 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("");
|
||||
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 ({
|
||||
@@ -380,6 +448,10 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
}) => {
|
||||
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);
|
||||
@@ -572,8 +644,14 @@ test.describe("A read-level delegate inside the record", () => {
|
||||
// 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 });
|
||||
// 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.
|
||||
|
||||
@@ -163,6 +163,19 @@ export async function storeTamperedCopy(backupId: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
@@ -173,8 +186,12 @@ export async function storedMeasurements(): Promise<StoredMeasurement[]> {
|
||||
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`,
|
||||
FROM measurements WHERE user_id = $1
|
||||
ORDER BY measured_at ASC, id ASC`,
|
||||
[userId],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
@@ -194,7 +211,7 @@ export async function storedIntakeIds(): Promise<
|
||||
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`,
|
||||
WHERE user_id = $1 ORDER BY scheduled_for ASC, id ASC`,
|
||||
[userId],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
|
||||
@@ -1017,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" />}
|
||||
@@ -1025,7 +1030,7 @@ export function BackupsSection() {
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="backup-run-now"
|
||||
data-testid="backup-run-now-empty"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
|
||||
Reference in new issue
Block a user