Merge pull request #946 from MBombeck/release/v1.38.15

Release v1.38.15
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-10 08:21:29 +02:00
commit 348d9e0739
176 files changed
+12719 -525

No files matched your search

+162
View File
@@ -1,5 +1,167 @@
# Changelog
## [1.38.15] — 2026-09-10
The rest of #939 for managed profiles, one figure for medication adherence
on every surface, and four more journeys in the gate.
### Added
- **A guardian can edit a managed record, and record its sex (#939).**
`PATCH /api/managed-profiles/{id}` accepts name, date of birth, language,
timezone and sex; the creation form asks for sex as well, so cycle tracking
can derive from it. Both arms require the fresh second factor the create
and delete routes already do, refuse with 404 rather than 403 for a record
the caller does not manage, and the edit is capped at ten an hour per
account. Every field is documented; `rejectedFields` says which value the
server would not take.
- **Modules are switched per record.** `GET`/`PUT /api/record-settings/modules`
read and write the module map of the record the browser is acting on, and
`GET /api/auth/me` under an active switch reports `modules` and
`cycleTrackingEnabled` for that record, not for the caller. Navigation
follows the record's own map, so a guardian sees the doors the child's record
has open. Under a scoped grant the two fields are masked to the sections the
grant names: a delegate scoped to measurements does not learn whether the
owner tracks their cycle, screeners, illness episodes or supplements, and the
cycle flag, which derives from the owner's recorded sex, answers `false`
outside a grant that opens it. Own record and unscoped grants are unchanged.
Documented on the account payload, which now says which fields describe the
caller and which the record.
- **`moduleAccess` says why a module is off.** Beside the boolean map,
`GET /api/auth/me` carries one of `enabled`, `disabled`, `not_granted` or
`unavailable` per module, in that order of precedence from the bottom: the
operator's instance-wide switch, then the grant's sections, then the
record's own choice. The booleans keep their exact meaning (`modules[key]`
is `moduleAccess[key] === "enabled"`, asserted over every grant shape). The
module-off notice on the web names the reason and offers the switch only
when the record itself turned the module off; a native client can show the
same sentence instead of a silently missing row.
### Fixed
- **A skipped dose is a pause, not a miss, on the dashboard too.** The
medication card, the dose history and the doctor report have excluded a
deliberate skip from the denominator since v1.15.9; the dashboard tile's
schedule-anchored engine counted it as expected-and-missed, so the same day
read two different rates. The tile engine now subtracts the day's skips,
capped at the minted slots, and a wholly skipped day drops out of its
buckets exactly as it drops out of the ledger. One test feeds both engines
the same fixture and asserts one figure.
- **Saving the Modules card no longer freezes a record's cycle derivation.**
Every save sent `cycleTrackingEnabled`, which wrote an explicit boolean over
the derivation from sex; a record without a recorded sex that later got one
stayed off with nothing on screen saying why. The flag now rides only when
it moved.
- **A refused second-factor action no longer hides its reason behind the
dialog.** Regenerating recovery codes or disabling the authenticator asked
for confirmation in a dialog whose confirm handler kept it open; when the
server refused (a stale step-up, most often) the message painted on the card
underneath the overlay. Both dialogs are controlled now and close when the
request settles, whichever way it went. Found by the second-factor journey
below.
- **The managed-record edit form keeps what you typed across a refetch.** It
was keyed on the query's timestamp, so any invalidation of the profile list
remounted it and dropped the input; it is keyed on the record now. A save
that only moved the timezone also wrote the actor's language into a record
with none recorded; the language is compared against what the form seeded.
- **The admin status rows read at AA contrast in every state.** With the
worker absent (a split deployment serving the page from the web process,
or a dead worker) the status row rendered its text in the destructive
colour at 3.97:1 on the tile's wash, below the floor, and the
accessibility suite went red in exactly that state. Status text is
foreground now with the tone on the indicator, the pattern the medication
rows already use; the database and telemetry rows moved with it.
- **The "my phone owns the reminders" chip can render now.** The medication
notification settings read `notificationPrefs.medication.clientManaged`
from the account payload, which never carried it, so the chip that tells
a person the server-side switch decides nothing for their paired phone
could not appear; the write path and the dispatch decision were right all
along. `GET /api/auth/me` publishes the resolved `notificationPrefs` now.
The account-payload guard checked only that every published field has a
reader; it checks the other direction too, so a reader of a field the
payload does not publish fails a unit test.
- **The admin reminder sweep can name one account.**
`POST /api/admin/notifications/reminder-check` accepts an optional
`userId`; without it the sweep stays instance-wide as before. Documented.
- **A console restore leaves the instance's settings alone unless asked.**
Restoring an account's snapshot from `/admin/backups` also rewrote the
singleton instance settings row whenever the snapshot carried one, as a
side effect of a per-account action. The write is behind an explicit
checkbox beside the typed confirmation now (`restoreInstanceSettings`,
default off); the disaster-recovery case keeps the capability, the routine
case no longer touches what every other account shares. The runbook says
what a restore replaces (rows created after the snapshot are removed) and
describes the console path, which it did not before.
- **A stored copy that will not open is a 422, not a 500.** A tampered
archive, or one written under a key the instance no longer holds, made the
restore, the download and the preview fail as internal errors. They answer
422 with `meta.errorCode = backup.payload.undecryptable` and are in the
API document, the restore with its `Idempotency-Key`.
- **Every refused request says when to come back.** No 429 the rate limiter
produced carried `Retry-After`, and 129 of the 199 limited routes sent no
rate-limit headers at all, the four batch endpoints, `/api/sync/changes`
and `/api/auth/refresh` among them; an offline queue that tripped the cap
got prose and nothing else. The limiter's refusal is now recorded per
request and `apiHandler` attaches `Retry-After` (whole seconds, at least
one) and `X-RateLimit-Limit`, `-Remaining` and `-Reset` to exactly the
429s the limiter produced; a 429 relayed from an AI provider or raised by
a budget keeps its own shape. The 429 response in the API document lists
the headers.
- **Offset pagination cannot skip or repeat a row any more.** Seven lists
ordered by a timestamp that ties across types (measurements, labs, the
measurement drill-down, custom-metric entries, medication intake in both
arms, mental-health assessments) now break the tie on `id`, so two pages
of a history pull are disjoint and complete. A test seeds ties and pulls
two pages.
- **The published error envelope allows what the server sends.**
`ErrorEnvelope` closed the object and omitted `details.issues`, the field
the multi-issue 422 was built for; `SyncChangesResponse` omitted
`cycleDays` and `cycles`, sent on every pull. Both corrected, and a test
now parses real responses of `/api/sync/changes` and a multi-issue 422
against the schemas that publish them, so a shape drift fails the gate.
- **Generic auth refusals carry a stable `errorCode`.** The session, Bearer,
admin, step-up and MFA-code refusals name their reason in `meta.errorCode`
(`auth.session.missing`, `auth.mfa.code_invalid`, …), documented on the
401 and 403 responses; a route's own credential check may still answer
with prose alone, and the description says so.
### Changed
- **Four more journeys in the gate.** Medication adherence from the wizard
through take and skip to the card, the history and the dashboard tile, on a
two-weekday plan against its daily twin; doctor-report generation with the
PDF's text asserted (period, blood-pressure section, medication, allergy),
an empty window that still names its span, and the manage boundary for a
delegate; the second factor from enrolment through a fresh sign-in, the
step-up window (accepted while fresh, refused once the stamp is aged),
a wrong code, a replayed code, and a recovery code that works exactly
once, with the server's own replay verdict read from the audit log rather
than inferred from a 401, and the login limiter's documented ceiling
asserted with its headers; backup and restore through the settings
surfaces, with the archive header asserted as bytes, a reading and a dose
deleted and restored under their original ids, a row added after the
snapshot gone, a tampered copy refused, and a read delegate kept out;
notification preferences through to the dispatch decision, read off the
account's own delivery ledger (one email attempt against a local mail
responder, none for the switched-off channel, the APNs arm skipped for
the documented reason, and a private-range URL refused with nothing
written). Each on its own account, each with a refusing control, each broken
deliberately once to prove it can fail.
## [1.38.14] — 2026-09-09
Two reported bugs fixed at their class, and the release gate stops crying
+968 -38
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -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 * *` —
+701
View File
@@ -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);
});
});
+271
View File
@@ -0,0 +1,271 @@
/**
* The doctor report is not something a READ-level delegate can generate.
*
* `POST /api/export/health-record` resolves `requireRecordAuth("manage",
* "record")`: a prepared artefact from a declared selection is the one export an
* invited MANAGER reaches, and READ is below that line. That is the statement
* this spec is about, and it is the ROUTE half that carries it: 403 inside the
* record the delegate may only read, 200 inside the one it manages.
*
* The UI half proves something narrower, and the file says so rather than
* implying the route's boundary twice. `/settings/gesundheitsakte` is a
* PERSONAL destination: inside SOMEBODY ELSE's record the shell answers it with
* the "not part of shared access" panel at every adult grant level — READ,
* WRITE and MANAGE alike — so the export card is never rendered for a delegate
* to press. That is a property of the destination, not of the level, and the
* spec asserts it at BOTH levels for exactly that reason: the same two
* absences inside the MANAGE record, where the route says 200, are what stops
* "the control is not offered" from being a check that cannot fail.
*
* Why both halves at all. A missing button proves nothing about the server, and
* a 403 proves nothing about what the person is shown; the failure mode this
* release family keeps rediscovering is a control that renders and then 403s.
*
* The positive control is the same delegate, the same payload and the same
* switched-record plumbing against the record it holds a MANAGE grant on. That
* isolates the one variable the refusal is supposed to be about: if the payload
* were malformed, the session unfenced or the switch broken, the MANAGE arm
* would refuse too and the file would go red instead of passing for the wrong
* reason.
*
* Both requests carry the record-context headers the shipped client sends. A
* raw `fetch` without them is refused by the record-session fence with the SAME
* status and the SAME error code as an insufficient grant — so an unfenced
* request would make the refusal below pass for the wrong reason. The context
* is read from `/api/auth/me`, which is where a browser gets it too.
*
* Its own cookie jar, like every spec that moves a session's record selector:
* the switch is stamped on the session row, so sharing a jar would switch the
* record under whichever spec happened to be mid-navigation.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
E2E_LEVEL_RECORDS,
REPORT_DELEGATE_STORAGE_STATE_PATH,
} from "./setup/test-helpers";
/** A selection the route accepts, so a refusal is about the grant and nothing
* else. One leaf is enough: an empty list is its own 422. */
const SELECTION = { v: 2, leaves: ["WEIGHT"] } as const;
interface ReportAttempt {
status: number;
contentType: string;
errorCode: string | null;
magic: string;
}
async function openSwitcher(page: Page): Promise<void> {
await page.getByRole("button", { name: "User menu" }).first().click();
await page.locator('[data-slot="account-switcher-trigger"]').click();
await expect(
page.locator('[data-slot="account-switcher-menu"]'),
).toBeVisible();
}
/**
* Run `act` and wait for the document it replaces to be gone — the switch is a
* hard navigation, and a banner assertion alone settles nothing. Same helper
* shape the v1.37/v1.38 sharing journeys use, for the reason written there.
*/
async function withDocumentReplacement(
page: Page,
act: () => Promise<void>,
): Promise<void> {
await page.evaluate(() => {
(window as Window & { __hlNavToken?: true }).__hlNavToken = true;
});
await act();
await page.waitForFunction(
() =>
(window as Window & { __hlNavToken?: true }).__hlNavToken === undefined,
undefined,
{ timeout: 30_000 },
);
}
/** Land on the delegate's own record, with the shell live. */
async function openOwnRecord(page: Page): Promise<void> {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("button", { name: "User menu" }).first(),
).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
async function enterRecord(page: Page, username: string): Promise<void> {
await openSwitcher(page);
const entry = page.locator(
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
);
await expect(entry).toHaveCount(1);
await withDocumentReplacement(page, () => entry.click());
await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({
timeout: 30_000,
});
}
async function leaveRecord(page: Page): Promise<void> {
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
/**
* Open the health-record settings page and wait for the shell to decide.
*
* Inside somebody else's record the shell answers this destination with the
* "not part of shared access" panel rather than the section, so the panel is
* the settled state to gate on — and the thing whose presence says the export
* card was never offered.
*/
async function openGesundheitsakte(page: Page): Promise<void> {
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="shared-record-unavailable"]'),
).toBeVisible({ timeout: 30_000 });
}
/**
* Ask the route for a PDF from inside the page, carrying the record context the
* shipped client carries.
*/
async function attemptReport(
page: Page,
selection: { v: number; leaves: readonly string[] },
): Promise<ReportAttempt> {
return page.evaluate(async (chosen) => {
const me = (await (await fetch("/api/auth/me")).json()) as {
data: { recordSession: { epoch: number; scope: string | null } | null };
};
const context = me.data.recordSession;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (context) {
headers["x-healthlog-record-epoch"] = String(context.epoch);
headers["x-healthlog-record-scope"] = context.scope ?? "self";
}
const res = await fetch("/api/export/health-record", {
method: "POST",
headers,
body: JSON.stringify({
format: "pdf",
locale: "en",
range: { days: 30 },
includeCharts: false,
selection: chosen,
}),
});
const contentType = res.headers.get("content-type") ?? "";
const buffer = await res.arrayBuffer();
const head = String.fromCharCode(...new Uint8Array(buffer.slice(0, 5)));
let errorCode: string | null = null;
if (contentType.includes("json")) {
try {
const payload = JSON.parse(new TextDecoder().decode(buffer)) as {
meta?: { errorCode?: string };
};
errorCode = payload.meta?.errorCode ?? null;
} catch {
errorCode = null;
}
}
return { status: res.status, contentType, errorCode, magic: head };
}, selection);
}
test.describe.serial("a read-level delegate and the doctor report", () => {
test.use({ storageState: REPORT_DELEGATE_STORAGE_STATE_PATH });
const readRecord = E2E_LEVEL_RECORDS.find(
(record) => record.access === "READ" && record.recordKind === "shared",
);
if (!readRecord) throw new Error("the READ-level record fixture is missing");
const manageRecord = E2E_LEVEL_RECORDS.find(
(record) => record.access === "MANAGE" && record.recordKind === "shared",
);
if (!manageRecord) {
throw new Error("the MANAGE-level record fixture is missing");
}
test.afterEach(async ({ page }) => {
// Hand the session back to its own record even when an assertion above
// failed: this jar is this spec's, but a switched row left behind would
// make its own next run start somewhere it did not choose.
await page.request
.post("/api/account/switch", { data: { accountId: null } })
.catch(() => {});
});
test("cannot generate on a record it may only read, and is not offered the control", async ({
page,
}) => {
test.setTimeout(120_000);
await openOwnRecord(page);
// Positive control: a record the same delegate MANAGES. Everything except
// the access level is identical to the refusal below.
await enterRecord(page, manageRecord.username);
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toHaveAttribute("data-access-level", "manage");
const managed = await attemptReport(page, SELECTION);
expect(managed.status).toBe(200);
expect(managed.contentType).toContain("application/pdf");
expect(managed.magic).toBe("%PDF-");
// The destination is shut here too, and this is the arm that proves it is
// the DESTINATION doing it: the route answered 200 for this very record a
// line ago, so the two absences below cannot be the grant level talking.
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toHaveCount(0);
await expect(page.getByTestId("health-record-generate")).toHaveCount(0);
await leaveRecord(page);
await enterRecord(page, readRecord.username);
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toHaveAttribute("data-record-kind", "shared");
// The banner carries the PRESENTATION of the grant, and a READ grant
// presents as "view" — one level below the "view-and-add" a WRITE grant
// shows and two below the "manage" this route requires.
await expect(banner).toHaveAttribute("data-access-level", "view");
// The same closed door, one level down. Paired with the MANAGE arm above it
// says the export card is offered inside NOBODY's record but your own; on
// its own it would say nothing about READ.
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toHaveCount(0);
await expect(page.getByTestId("health-record-generate")).toHaveCount(0);
// The level-sensitive statement: the same request, the same headers and the
// same payload that answered 200 inside the managed record are refused
// here, and nothing but the grant changed.
const shared = await attemptReport(page, SELECTION);
expect(shared.status).toBe(403);
expect(shared.errorCode).toBe("sharing.access.denied");
expect(shared.contentType).toContain("json");
await leaveRecord(page);
});
});
+524
View File
@@ -0,0 +1,524 @@
/**
* The doctor report, generated in a browser and read back as a real PDF.
*
* The surface had unit coverage for the renderer and route coverage for the
* envelope, and nothing that pressed the button. What that leaves untested is
* the part a self-hoster actually meets: a person with a few weeks of readings
* opens Settings → Gesundheitsakte, picks a window, presses Generate, and gets
* a document a practice can file. Every link in that chain — the panel's own
* `fetch`, the selection it sends, the aggregator's window, the renderer, the
* download — is proved here once, end to end.
*
* What the assertions are about:
*
* 1. The response IS a PDF: `application/pdf`, a `%PDF` magic, a byte length
* no empty document could reach, and a `Content-Length` that agrees with
* the bytes.
* 2. The document says what it contains. The bytes are parsed back to text,
* so the period line, the blood-pressure section and the medication the
* account carries are read out of the artefact rather than inferred from a
* 200.
* 3. The window travelled. The period line names two dates, and their span is
* the window the select offered — not the server's default.
*
* And the refusing control, in the same file because it is the same button: a
* window with nothing in it still produces a valid, readable PDF that names the
* period and simply carries no blood-pressure section. An empty report is a
* true statement about an empty window; a truncated or unopenable file is not.
*
* Its own account, and not the shared one: generating a report WRITES. The
* route remembers the scope and the practice name on the account row, so the
* panel a later visit opens is the collapsed "repeat run" state — which is
* exactly the state `settings-export.spec.ts` asserts the opposite of, against
* the shared account, in nine places. See `E2E_REPORT_OWNER`.
*
* The fixture is seeded through the PAGE's own `fetch` under the session the
* spec is already authenticated as — not through a second Playwright request
* context — so the rows are written by exactly the credential that later reads
* them back. The ROWS are idempotent: a re-post of the same reading collides on
* the measurement's natural key and answers 409, so `--repeat-each` and a
* second local run write nothing twice. The export QUOTA is not idempotent —
* every export route shares one 10-per-hour bucket keyed on the actor, and it
* outlives the run — so `global-setup` clears this account's bucket before each
* run rather than the file pretending the press is free.
*
* The delegate half of the story — a READ-level delegate cannot generate the
* owner's report — lives in `doctor-report-delegate.spec.ts`, which needs its
* own session row because it moves the record selector.
*/
import { readFile } from "node:fs/promises";
import type { Page, Response } from "@playwright/test";
import { PDFParse } from "pdf-parse";
import { REPORT_OWNER_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
/** The allergy and the drug the report has to name. Both plain ASCII: the
* PDF's built-in Helvetica is WinAnsi-encoded and the assertions read the
* extracted text back, so a fixture that needs transliteration would be
* testing the sanitiser rather than the report. */
const ALLERGY_SUBSTANCE = "Amoxicillin";
const MEDICATION_NAME = "Ramipril";
const MEDICATION_DOSE = "5 mg";
/** Day offsets for the seeded readings — a few weeks of history, inside the
* 30-day window the spec selects and well inside the 90-day default, so a
* window that failed to travel would still find them. */
const READING_DAY_OFFSETS = [2, 5, 9, 13, 17, 21] as const;
/** The window the spec chooses in the panel. Deliberately NOT the 90-day
* fallback the panel opens with, so the period line proves a choice. */
const RANGE_DAYS = 30;
/** The section heading the PDF prints only when both blood-pressure series
* have readings in the window. Quoted without its em dash: the expectation is
* about the section being there, not about how a parser renders punctuation. */
const BP_SECTION = "ESH classification";
/** The refusing control's window: twenty days, starting four hundred days back.
* Far outside every seeded reading and well inside the route's 1..730-day span
* bounds, and named so the period line can be asserted against it rather than
* the report being taken on trust. */
const EMPTY_WINDOW_START_DAYS_BACK = 400;
const EMPTY_WINDOW_DAYS = 20;
/** Byte floor for "a real document". A cover page with charts is far above
* this; a truncated stream or an error body is far below it. */
const MIN_PDF_BYTES = 10_000;
interface SeedOutcome {
measurements: number[];
allergy: number;
medication: number;
emergency: number;
}
/**
* Write the record this report is about, through the page's own `fetch`.
*
* Each per-day POST carries the day's systolic, diastolic and weight as one
* array body — the route's batch arm — so a repeat run collides as a whole day
* and answers 409 rather than half-inserting. The allergy and the medication
* are read before they are written, because neither has a natural key that
* would make a second create a no-op.
*
* The emergency profile matters more than it looks: the drug list reaches the
* PDF through the emergency sheet, and that sheet only renders once the profile
* holds something. One blood type is the smallest honest way to say so.
*/
async function seedReportFixture(page: Page): Promise<SeedOutcome> {
return page.evaluate(
async (fixture) => {
const send = async (
method: string,
path: string,
body: unknown,
): Promise<number> => {
const res = await fetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.status;
};
const readList = async (path: string): Promise<unknown[]> => {
const res = await fetch(path);
if (!res.ok) return [];
const payload = (await res.json()) as { data: unknown };
return Array.isArray(payload.data) ? payload.data : [];
};
/** 09:00 UTC on the day `offset` days back — a fixed instant, so a
* second run inside the same day reproduces the same natural key. */
const instantFor = (offset: number): string => {
const day = new Date();
day.setUTCDate(day.getUTCDate() - offset);
return `${day.toISOString().slice(0, 10)}T09:00:00.000Z`;
};
const measurements: number[] = [];
for (const [index, offset] of fixture.dayOffsets.entries()) {
const measuredAt = instantFor(offset);
measurements.push(
await send("POST", "/api/measurements", [
{
type: "BLOOD_PRESSURE_SYS",
value: 118 + index,
measuredAt,
},
{
type: "BLOOD_PRESSURE_DIA",
value: 74 + index,
measuredAt,
},
{
type: "WEIGHT",
value: 82.4 - index * 0.2,
measuredAt,
},
]),
);
}
const allergies = (await readList("/api/allergies")) as Array<{
substance?: string;
}>;
const allergy = allergies.some(
(row) => row.substance === fixture.allergySubstance,
)
? 200
: await send("POST", "/api/allergies", {
substance: fixture.allergySubstance,
category: "MEDICATION",
type: "ALLERGY",
severity: "MODERATE",
});
const drugs = (await readList("/api/medications")) as Array<{
name?: string;
}>;
const medication = drugs.some(
(row) => row.name === fixture.medicationName,
)
? 200
: await send("POST", "/api/medications", {
name: fixture.medicationName,
dose: fixture.medicationDose,
// No schedule: an as-needed drug is active indefinitely and needs
// no dose ledger, which keeps the fixture about the drug LIST.
asNeeded: true,
});
const emergency = await send("PATCH", "/api/anamnesis/emergency", {
bloodType: "O_POS",
});
return { measurements, allergy, medication, emergency };
},
{
dayOffsets: [...READING_DAY_OFFSETS],
allergySubstance: ALLERGY_SUBSTANCE,
medicationName: MEDICATION_NAME,
medicationDose: MEDICATION_DOSE,
},
);
}
/**
* Open the panel and wait until the client boundary that owns it is live.
*
* `GesundheitsakteSection` carries the export panel and the sharing card in one
* boundary, and the sharing card's query only runs once that boundary has
* hydrated — so its request is the signal that the Generate button is wired
* rather than merely painted. Same gate `settings-export.spec.ts` uses, for the
* same reason.
*/
async function openReportPanel(page: Page): Promise<void> {
const boundaryHydrated = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await boundaryHydrated;
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
timeout: 30_000,
});
}
/**
* Put the panel into a known state: PDF format, the standard scope.
*
* The account carries a saved selection the moment it has generated once, so
* the panel a second run opens is the "repeat run" state with the picker
* collapsed. Both states are handled by opening the disclosure when it is shut
* and then applying the named template, which is also the only thing in the
* product that fills a selection — so the scope this report is generated under
* is a scope somebody pressed a button for, exactly as the panel intends.
*/
async function chooseStandardPdfScope(page: Page): Promise<void> {
const panel = page.getByTestId("health-record-export-panel");
const formats = panel.locator('[role="radiogroup"] [role="radio"]');
// PDF is the first of the three formats the panel offers.
await formats.first().click();
await expect(formats.first()).toHaveAttribute("aria-checked", "true");
const picker = page.getByTestId("health-record-included-data-panel");
if ((await picker.count()) === 0) {
await page.getByTestId("health-record-included-data-toggle").click();
}
await expect(picker).toBeVisible();
await page.getByTestId("report-apply-standard-export").click();
await expect(page.getByTestId("health-record-generate")).toBeEnabled();
}
interface GeneratedReport {
status: number;
contentType: string;
contentLength: string | undefined;
bytes: Buffer;
text: string;
filename: string;
}
/**
* Press Generate and take the artefact apart.
*
* Both ends of the press are observed: the response carries the bytes the
* assertions are about, and the download event is the state the person sees.
* Waiting on the response ALONE would pass on a build that produced a perfect
* PDF and never handed it to the browser.
*/
async function generateReport(page: Page): Promise<GeneratedReport> {
const responseArrived = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === "/api/export/health-record",
{ timeout: 60_000 },
);
const downloadStarted = page.waitForEvent("download", { timeout: 60_000 });
const generate = page.getByTestId("health-record-generate");
await generate.click();
const response = await responseArrived;
const download = await downloadStarted;
// The bytes come off the DOWNLOAD, not off the response, because the saved
// file is the artefact the person ends up with: a build that produced a
// perfect response and saved something else would still be caught.
const saved = await download.path();
expect(saved, "the panel saved the generated report").toBeTruthy();
const bytes = await readFile(saved!);
// The control comes back, and nothing on the panel is complaining. The error
// paragraph is the panel's only failure surface, so its absence is the
// statement that the press succeeded rather than failed quietly.
await expect(generate).toBeEnabled({ timeout: 30_000 });
await expect(
page.getByTestId("health-record-export-panel").getByRole("alert"),
).toHaveCount(0);
// Asserted BEFORE the parse: a parser fed an error envelope reports "not a
// PDF", which is a true statement about the bytes and a useless one about
// what went wrong. The status and the content type say that.
expect(response.status(), await failureDetail(response)).toBe(200);
expect(response.headers()["content-type"] ?? "").toContain("application/pdf");
expect(bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
const parser = new PDFParse({ data: new Uint8Array(bytes) });
let text: string;
try {
text = (await parser.getText()).text;
} finally {
await parser.destroy();
}
return {
status: response.status(),
contentType: response.headers()["content-type"] ?? "",
contentLength: response.headers()["content-length"],
bytes,
text,
filename: download.suggestedFilename(),
};
}
/** What the route said, when it did not say "PDF". */
async function failureDetail(response: Response): Promise<string> {
try {
return (await response.text()).slice(0, 400);
} catch {
return "<no readable body>";
}
}
/** Every assertion that is about "this is a PDF", said once. */
function expectRealPdf(report: GeneratedReport): void {
expect(report.status).toBe(200);
expect(report.contentType).toContain("application/pdf");
expect(report.bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");
expect(report.bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
// The route declares the length it sent; a disagreement means the body was
// truncated on the way out, which a magic-number check alone would miss.
expect(report.contentLength).toBe(String(report.bytes.byteLength));
// The name the CLIENT anchor invents, not the route's `Content-Disposition`
// — the two differ by a word today. This says the panel saved a PDF, and it
// is not contract coverage of the header.
expect(report.filename).toMatch(/\.pdf$/);
}
/** The three orders `DateFormatPreference` pins a rendering to. */
type DateOrder = "DMY" | "MDY" | "YMD";
/**
* Which field of the period line is the month, read off the account.
*
* The rendering is the account's `dateFormat` preference, not a constant: DMY
* prints `10.09.2026`, YMD prints `2026-09-10`, and en-GB's MM/DD-shaped
* `10/09/2026` is a DMY date that an MDY reader silently mis-parses into a
* nonsense span. So the preference is read rather than assumed.
*
* `AUTO` resolves through the active locale and is deliberately refused here:
* the fixture pins `MDY` (see `E2E_REPORT_OWNER` in `global-setup.ts`), so an
* `AUTO` account is a drifted fixture and should say so rather than be guessed
* at.
*/
async function readDateOrder(page: Page): Promise<DateOrder> {
const preference = await page.evaluate(async () => {
const res = await fetch("/api/auth/me");
const payload = (await res.json()) as { data: { dateFormat?: string } };
return payload.data.dateFormat ?? "AUTO";
});
expect(
["DMY", "MDY", "YMD"],
"the account pins an explicit date order for the period line",
).toContain(preference);
return preference as DateOrder;
}
/**
* The period line's two dates, as a span in days.
*
* The line is rendered in the account's date order and its timezone, so the
* dates are read structurally and compared as a SPAN. A span is timezone-proof
* in a way an absolute date is not: both ends move together.
*
* The separator is captured once and back-referenced, so `10.09.2026`,
* `09/10/2026` and `2026-09-10` all parse and none of them can be read as the
* other two.
*/
function periodSpanDays(text: string, order: DateOrder): number {
const match =
/Reporting period:\s*(\d{2,4})([./-])(\d{2})\2(\d{2,4})\s*\D{1,3}\s*(\d{2,4})\2(\d{2})\2(\d{2,4})/.exec(
text,
);
expect(match, "the report prints a reporting period with two dates").not.toBe(
null,
);
const start = utcMidnight(match![1], match![3], match![4], order);
const end = utcMidnight(match![5], match![6], match![7], order);
return Math.round((end - start) / 86_400_000);
}
function utcMidnight(
first: string,
second: string,
third: string,
order: DateOrder,
): number {
const [a, b, c] = [Number(first), Number(second), Number(third)];
const [year, month, day] =
order === "YMD" ? [a, b, c] : order === "DMY" ? [c, b, a] : [c, a, b];
return Date.UTC(year, month - 1, day);
}
test.describe.serial("the doctor report", () => {
// This journey's own account owns the readings, the allergy and the drug, and
// the page's own `fetch` writes them under its session.
test.use({ storageState: REPORT_OWNER_STORAGE_STATE_PATH });
test("generates a PDF that names the period, the vitals and the medication", async ({
page,
}) => {
// A production build rendering a charted PDF over a seeded window is well
// past the 30 s default, and a timeout here would report a failure that
// says nothing about the report.
test.setTimeout(120_000);
await openReportPanel(page);
const seeded = await seedReportFixture(page);
// 201 on a first run, 409 on a repeat — both mean the readings are there.
for (const status of seeded.measurements) {
expect([201, 409]).toContain(status);
}
expect([200, 201]).toContain(seeded.allergy);
expect([200, 201]).toContain(seeded.medication);
expect(seeded.emergency).toBe(200);
// Reload so the panel is looking at the record that now exists.
await openReportPanel(page);
await chooseStandardPdfScope(page);
// The period: a window the panel offers, and not the one it defaults to.
await page.locator("#hr-range").selectOption({ value: String(RANGE_DAYS) });
await expect(page.locator("#hr-range")).toHaveValue(String(RANGE_DAYS));
const report = await generateReport(page);
expectRealPdf(report);
// The document says what it is about.
expect(report.text).toContain("Reporting period");
const span = periodSpanDays(report.text, await readDateOrder(page));
expect(span).toBeGreaterThanOrEqual(RANGE_DAYS - 1);
expect(span).toBeLessThanOrEqual(RANGE_DAYS + 1);
// The blood-pressure section only prints when both series carried readings
// in the window, so its presence is the seeded history arriving.
expect(report.text).toContain(BP_SECTION);
// And the record the clinician reads first: the drug and the allergy.
expect(report.text).toContain(MEDICATION_NAME);
expect(report.text).toContain(ALLERGY_SUBSTANCE);
});
test("a window with no readings is an empty report, not a broken one", async ({
page,
}) => {
test.setTimeout(120_000);
await openReportPanel(page);
await chooseStandardPdfScope(page);
// A custom window a year back: inside the route's span bounds, and holding
// none of this account's readings.
await page.locator("#hr-range").selectOption({ value: "custom" });
const panel = page.getByTestId("health-record-export-panel");
const dateFields = panel.locator(
'[data-slot="date-field"] input[type="text"]',
);
await expect(dateFields).toHaveCount(2);
const dayBack = (offset: number): string => {
const day = new Date();
day.setUTCDate(day.getUTCDate() - offset);
return day.toISOString().slice(0, 10);
};
// The field commits a clean ISO string whatever the locale's field order is.
await dateFields.first().fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK));
await dateFields.first().press("Enter");
await dateFields
.nth(1)
.fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK - EMPTY_WINDOW_DAYS));
await dateFields.nth(1).press("Enter");
const report = await generateReport(page);
// Still a document: the empty window is answered with a report, not with a
// truncated file and not with an error the panel swallows.
expectRealPdf(report);
expect(report.text).toContain("Reporting period");
// And it is the window that was typed. Two silent fallbacks sit under this
// line — the panel sends `{ days }` when either date field is empty, and
// the route falls back to the 90-day default when a bound fails to parse,
// neither of them a 422. Both land on a window that HOLDS the seeded
// readings, so without this the absence below would be the fixture's luck.
const emptySpan = periodSpanDays(report.text, await readDateOrder(page));
expect(emptySpan).toBeGreaterThanOrEqual(EMPTY_WINDOW_DAYS - 1);
expect(emptySpan).toBeLessThanOrEqual(EMPTY_WINDOW_DAYS + 1);
// Nothing was measured in it, so the section that needs readings is gone.
expect(report.text).not.toContain(BP_SECTION);
// The positive control for that absence: the reference data that is NOT
// window-scoped is still in the document. Without this, a renderer that
// produced a blank page would pass the line above.
expect(report.text).toContain(MEDICATION_NAME);
expect(report.text).toContain(ALLERGY_SUBSTANCE);
});
});
+669
View File
@@ -0,0 +1,669 @@
/**
* Medication adherence, proven end to end.
*
* The percentage on a medication card is the number this product is for, and
* until now nothing in the browser suite walked the path that produces it: the
* wizard specs stub `/api/medications` and stop at the POST body, and the
* compliance engine's own tests are unit tests over pure functions. So the one
* thing nobody checked was the join — that a tap on "Genommen" reaches the
* engine, and that the engine's answer reaches every surface that shows it.
*
* Three flows:
*
* 1. Journey — a twice-daily medication created through the real wizard, one
* dose taken and one skipped from the card, and the resulting figure read
* back on the card, in the intake history and on the dashboard tile.
* 2. Refusal — an intake against a medication this account does not own, and
* one against a slot its schedule does not have, are both refused and the
* rate is exactly what it was before.
* 3. Cadence — a medication due on two weekdays does not count the other
* five as missed. Its daily twin, given the identical doses, does.
*
* Every assertion addresses a `data-slot` / `data-*` attribute rather than
* viewport text: the copy is i18n-driven and the wizard runs in German, so
* addressing a control by its label would assert the translation.
*
* The flows write rows and read counts back off them, so they run on their own
* account (`E2E_MEDICATION`) — the rate a card shows is a percentage over one
* cabinet, and a sibling spec mounting `/medications` in the other worker
* would otherwise be spending the same per-actor read allowance and sitting in
* the same dashboard denominator. The file still runs serial and in a single
* project (see `playwright.config.ts`), because its three tests share that one
* cabinet with each other.
*
* Why the medications are aged (`ageMedication`): compliance is reconstructed
* from the medication's creation stamp forward, so a medication created a
* second ago has expected no dose yet and reads the empty-set 100 %. A number
* that cannot move is a number this spec cannot check, so each flow puts a few
* days of unlogged history behind its medication first — which is also the
* only state in which "missed" means anything.
*/
import type { Page } from "@playwright/test";
import { MEDICATION_STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ageMedication,
resetMedications,
type MedicationAgeStamps,
} from "./setup/medication-fixture";
import { expect, test } from "./setup/test";
import {
clickNext,
clickSave,
expectStep,
fillStep1Name,
fillStep3Dose,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
} from "./medications-wizard-helpers";
/** One `{ data, error }` envelope as the browser sees it. */
interface ApiResult<T> {
status: number;
data: T | null;
}
/** The slice of `GET /api/medications/{id}/compliance` these flows read. */
interface CompliancePayload {
compliance7: {
totalExpected: number;
taken: number;
skipped: number;
missed: number;
rate: number;
};
complianceDisplay: {
shortDays: number;
short: { rate: number; taken: number; expected: number; missed: number };
};
}
/**
* Call the API the way the app does: from the page's own `fetch`, on the
* page's origin, with the session cookie the browser already holds. A
* request issued from the test process would carry a different jar and would
* not exercise the same-origin path every client write takes.
*/
async function api<T>(
page: Page,
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<ApiResult<T>> {
return page.evaluate(
async ({ method, path, body }) => {
const res = await fetch(path, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
// Read the body as text first: a refusal is allowed to answer with no
// body at all, and a bare `res.json()` would then throw a parse error
// over the status code the assertion is actually about.
const raw = await res.text();
const envelope = raw ? (JSON.parse(raw) as { data: unknown }) : null;
return { status: res.status, data: (envelope?.data ?? null) as never };
},
{ method, path, body },
);
}
/** One schedule entry as `POST /api/medications` accepts it. */
interface SeedSchedule {
windowStart: string;
windowEnd: string;
timesOfDay: string[];
rrule: string;
}
/** Create a medication through the page's own fetch. Returns its id. */
async function seedMedication(
page: Page,
input: {
name: string;
dose: string;
startsOn: string;
schedule: SeedSchedule;
},
): Promise<string> {
const created = await api<{ id: string }>(page, "POST", "/api/medications", {
name: input.name,
dose: input.dose,
startsOn: input.startsOn,
schedules: [input.schedule],
});
expect(created.status, `create ${input.name}`).toBe(201);
const id = created.data?.id;
expect(id, `created ${input.name} carries an id`).toBeTruthy();
return id as string;
}
/**
* The two stamps `ageMedication` writes, computed in the browser's clock.
*
* Deliberately not in SQL: `CURRENT_DATE` resolves in the database session's
* zone, the plan dates every flow sends are computed here, and between
* midnight and 02:00 Berlin those are different calendar days.
*/
async function ageStamps(
page: Page,
days: number,
): Promise<MedicationAgeStamps> {
return page.evaluate((days) => {
const pad = (n: number) => String(n).padStart(2, "0");
const at = new Date();
at.setDate(at.getDate() - days);
return {
createdAt: at.toISOString(),
startsOn: `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`,
};
}, days);
}
/**
* The write `ageMedication` uses to flush the server's memoised compliance
* cells after it has edited the row underneath them: an ordinary update that
* re-sends the dose the medication already has.
*/
function cacheFlusher(page: Page, medicationId: string, dose: string) {
return async () => {
const saved = await api<{ id: string }>(
page,
"PUT",
`/api/medications/${medicationId}`,
{ dose },
);
expect(saved.status, "cache-flushing update").toBe(200);
};
}
/** The compliance block on a medication card, as the card renders it. */
function complianceBlock(page: Page, medicationId: string) {
return page
.locator(`[data-medication-id="${medicationId}"]`)
.locator('[data-slot="medication-card-compliance"]')
.first();
}
/**
* Read the card's short-window adherence percentage.
*
* The rate's presence is the gate: the skeleton and the quiet error fallback
* reserve the same footprint as the loaded bars and carry no rates, so an
* attribute that is there is a figure the card really painted. Twenty seconds
* is the list's own load-plus-fetch budget and nothing more — the account is
* this spec's own, so a card with no figure on it is a defect rather than a
* neighbour's spent read allowance.
*/
async function readCardRate(page: Page, medicationId: string): Promise<number> {
const block = complianceBlock(page, medicationId);
await expect(
block,
"the card never painted an adherence figure",
).toHaveAttribute("data-rate-short", /^\d+$/, { timeout: 20_000 });
return Number(await block.getAttribute("data-rate-short"));
}
/** The dashboard tile's most recent day, as the tile itself computed it. */
async function readTileLatestRate(page: Page): Promise<number> {
const tile = page.locator('[data-slot="medication-compliance-chart"]');
await expect(tile).toBeVisible({ timeout: 15_000 });
await expect(tile).toHaveAttribute("data-latest-rate", /^\d+$/, {
timeout: 15_000,
});
return Number(await tile.getAttribute("data-latest-rate"));
}
/**
* Open a surface the reset and the seeding calls can run from.
*
* Both go out as the page's own `fetch`, so the page has to be on the app's
* origin first. Deliberately NOT the medications list: the reset that follows
* deletes rows, and a list mount would first fetch compliance for every one of
* them.
*/
async function openSeedingSurface(page: Page): Promise<void> {
await page.goto("/profile");
// The shell's own main region, not `main` — the profile page nests a second
// one inside it and a bare tag selector is ambiguous there.
await expect(page.locator("#main-content")).toBeVisible({ timeout: 15_000 });
}
/**
* The two dose times the journey's medication runs on: now, and twelve hours
* from now.
*
* They are derived from the clock rather than picked from the wizard's
* suggestion chips, and that is the whole reason the flow is stable. The card
* offers exactly one dose — the open window, else the next one due — so with
* fixed times there is always an hour of the day at which the dose it offers
* belongs to yesterday or to tomorrow, and the day the write lands on decides
* what the dashboard tile reads. A slot anchored at "now" is inside its own
* on-time window by construction, so the take is always today's, whenever the
* suite runs. Its twin twelve hours away is the second dose of the day.
*/
async function clockAnchoredDoseTimes(
page: Page,
): Promise<{ open: string; other: string }> {
return page.evaluate(() => {
const pad = (n: number) => String(n).padStart(2, "0");
const now = new Date();
return {
open: `${pad(now.getHours())}:${pad(now.getMinutes())}`,
other: `${pad((now.getHours() + 12) % 24)}:${pad(now.getMinutes())}`,
};
});
}
/**
* Add one `HH:mm` to the wizard's times-of-day chips.
*
* The Enter press is the commit, not a flourish: the field's overlay input
* holds what is being typed and only hands it to the form on Enter or on blur
* (`src/components/ui/time-field.tsx:175-184`). Until then the draft is empty,
* and the add button is `disabled` on exactly that
* (`times-of-day-chips.tsx:267`) — so a fill followed straight by a click
* waits out the actionability timeout on a button that never enables.
*/
async function addDoseTime(page: Page, time: string): Promise<void> {
const chips = page.locator('[data-slot="times-of-day-chips"]');
const draft = chips.getByTestId("times-of-day-draft-input");
await draft.fill(time);
await draft.press("Enter");
await chips.locator('[data-slot="times-of-day-add"]').click();
await expect(
chips.locator(`[data-slot="times-of-day-chip"][data-time="${time}"]`),
).toBeVisible();
}
/**
* Walk the create wizard for a twice-daily medication and return its id.
* Reuses the cadence specs' step helpers; unlike those specs it leaves
* `/api/medications` unstubbed, so the medication is really written.
*/
async function createTwiceDailyMedication(
page: Page,
name: string,
): Promise<{ id: string; times: { open: string; other: string } }> {
await openCreateWizard(page);
const times = await clockAnchoredDoseTimes(page);
await fillStep1Name(page, { name });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "other");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "500" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5, 8);
await pickCadenceRow(page, "daily");
// Picking a daily cadence compresses the path from eight steps to seven.
await expectStep(page, 5, 7);
await clickNext(page);
// Times of day — the step arrives with 08:00 already on it. Add the two the
// journey needs, then drop 08:00 unless it is one of them, so the plan is
// exactly twice daily.
await expectStep(page, 6, 7);
const chips = page.locator('[data-slot="times-of-day-chips"]');
await addDoseTime(page, times.open);
await addDoseTime(page, times.other);
if (times.open !== "08:00" && times.other !== "08:00") {
await chips
.locator('[data-slot="times-of-day-chip"][data-time="08:00"]')
.locator('[data-slot="times-of-day-chip-remove"]')
.click();
}
await expect(chips.locator('[data-slot="times-of-day-chip"]')).toHaveCount(2);
await clickNext(page);
await expectStep(page, 7, 7);
await clickSave(page);
await page.waitForURL(/\/medications\/[^/?]+$/, { timeout: 15_000 });
const id = new URL(page.url()).pathname.split("/").pop();
expect(id, "the create flow lands on the new medication").toBeTruthy();
return { id: id as string, times };
}
// Serial: the three tests share one cabinet, and two of them clearing it at
// once would each undo the other's setup.
test.describe.configure({ mode: "serial" });
test.describe("medication adherence journey", () => {
test.use({ storageState: MEDICATION_STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Own every row the rates count. The dashboard tile sums the expected
// doses of every active medication on the account, so a leftover from a
// previous run would sit in its denominator. The removal goes out as the
// app's own DELETE so the server's memoised medication cells go with it.
await openSeedingSurface(page);
await resetMedications(async (id) => {
const removed = await api(page, "DELETE", `/api/medications/${id}`);
expect(removed.status, "clearing a leftover medication").toBe(200);
});
});
test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({
page,
}) => {
// The wizard is a seven-step dialog with per-step validation gating Next;
// on a loaded runner that settle earns the tripled timeout.
test.slow();
// The tile reads THE LATEST day, and a day boundary crossed mid-flow moves
// that day out from under both tile assertions: a fresh day has two doses
// scheduled, none taken, and answers 0 where 50 is asserted. The flow is a
// couple of minutes long, so declining to start in the last ten of the day
// is enough. The clock is the browser's, which the config pins to the zone
// the compliance engine falls back to.
const minutesLeftInDay = await page.evaluate(
() => 24 * 60 - (new Date().getHours() * 60 + new Date().getMinutes()),
);
test.skip(
minutesLeftInDay < 10,
"starts inside the last ten minutes of the day — the dashboard tile's latest day would change under the flow",
);
const { id: medicationId } = await createTwiceDailyMedication(
page,
"E2E Adhärenz",
);
// Four days of plan behind it, none of it logged: the engine expects two
// doses a day and nobody took any, so the starting rate is a real 0 %.
await ageMedication(
medicationId,
await ageStamps(page, 4),
cacheFlusher(page, medicationId, "500 mg"),
);
// --- before ------------------------------------------------------------
await page.goto("/");
expect(await readTileLatestRate(page)).toBe(0);
await page.goto("/medications");
expect(await readCardRate(page, medicationId)).toBe(0);
// --- the two gestures --------------------------------------------------
const card = page.locator(`[data-medication-id="${medicationId}"]`);
const intakePath = `/api/medications/${medicationId}/intake`;
const takeWritten = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === intakePath &&
res.request().method() === "POST",
);
await card.locator('[data-slot="medication-intake-take"]').click();
expect((await takeWritten).status(), "the take is written").toBe(201);
// The skip must land on the OTHER dose, so it has to wait for the card to
// re-read its schedule: the card binds each write to the slot it is
// currently showing, and a skip fired against a stale render would flip
// the dose just taken instead of recording a second one. The rate leaving
// zero is the card's own confirmation that the refetch landed.
await expect
.poll(
() =>
complianceBlock(page, medicationId).getAttribute("data-rate-short"),
{
timeout: 30_000,
message: "the card never re-read its schedule after the take",
},
)
.toMatch(/^[1-9]\d*$/);
const skipWritten = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === intakePath &&
res.request().method() === "POST",
);
await card.locator('[data-slot="medication-intake-skip"]').click();
expect((await skipWritten).status(), "the skip is written").toBe(201);
// --- the card agrees with the engine -----------------------------------
const payload = await api<CompliancePayload>(
page,
"GET",
`/api/medications/${medicationId}/compliance`,
);
expect(payload.status).toBe(200);
const compliance = payload.data as CompliancePayload;
// One dose taken, one deliberately skipped, and the four days of plan
// nobody logged still counting against the rate.
expect(compliance.compliance7.taken).toBe(1);
expect(compliance.compliance7.missed).toBeGreaterThanOrEqual(6);
expect(compliance.complianceDisplay.shortDays).toBe(7);
expect(compliance.complianceDisplay.short.taken).toBe(1);
// A skip is a decision, not a failure: it never enters the denominator.
// So the rate has moved off zero and is nowhere near a hundred — one dose
// taken against at least six missed.
const rateAfter = await readCardRate(page, medicationId);
expect(rateAfter).toBe(compliance.complianceDisplay.short.rate);
expect(rateAfter).toBeGreaterThan(0);
expect(rateAfter).toBeLessThanOrEqual(20);
// --- the intake history shows both events, each as what it was ----------
// The 90-day ledger beside it is a view of the SCHEDULE and stops at now,
// so a dose skipped ahead of its window is not in it. The intake history
// is the view of what was recorded, and both gestures belong there.
await page.goto(`/medications/${medicationId}?tab=verlauf`);
await page
.locator('[data-slot="medication-verlauf-view-toggle-all"]')
.click();
const history = page.locator('[data-slot="intake-history-row"]');
await expect(history).toHaveCount(2, { timeout: 15_000 });
await expect(
page.locator('[data-slot="intake-history-row"][data-status="taken"]'),
).toHaveCount(1);
await expect(
page.locator('[data-slot="intake-history-row"][data-status="skipped"]'),
).toHaveCount(1);
// --- and the dashboard tile, which answers the skip the same way -------
// Today expected two doses; one was taken and one deliberately skipped.
//
// A skip is a pause, not a miss, on every surface: the card's engine
// (`src/lib/analytics/compliance.ts`, the documented single source of the
// percentage) drops a skip from the denominator outright
// (`src/lib/analytics/compliance/ledger.ts`), and the tile's
// schedule-anchored engine (`src/lib/analytics/schedule-anchored-compliance.ts`)
// subtracts the day's skips from its expected slots for the same reason,
// so the one taken dose over the one remaining slot reads 100 here as it
// does on the card. The two engines are held to one figure by
// `src/lib/analytics/__tests__/compliance-skip-parity.test.ts`; this read
// proves the tile actually paints it.
await page.goto("/");
expect(await readTileLatestRate(page)).toBe(100);
});
test("an intake against an unowned medication, and one against a slot the schedule does not have, are both refused", async ({
page,
}) => {
// Two full list loads either side of the refusals, plus a seed and an age;
// on a loaded runner that settle earns the tripled timeout.
test.slow();
const stamps = await ageStamps(page, 4);
// An instant the schedule below does not have a slot for. Computed here,
// before the first navigation, so the whole test body reads as one setup
// step followed by assertions on it.
const unscheduled = await page.evaluate(() => {
const at = new Date();
at.setHours(3, 17, 0, 0);
return at.toISOString();
});
const medicationId = await seedMedication(page, {
name: "E2E Ablehnung",
dose: "10 mg",
startsOn: stamps.startsOn,
schedule: {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00", "22:00"],
rrule: "FREQ=DAILY",
},
});
await ageMedication(
medicationId,
stamps,
cacheFlusher(page, medicationId, "10 mg"),
);
await page.goto("/medications");
const before = await readCardRate(page, medicationId);
// A medication id nothing on this account owns. The privacy gate answers
// before the body is even read, so the 404 is about ownership and says
// nothing about the medication existing elsewhere.
const unowned = await api(
page,
"POST",
"/api/medications/med_e2e_does_not_exist/intake",
{ skipped: false },
);
expect(unowned.status, "an unowned medication is refused").toBe(404);
// And the schedule's own boundary: this medication doses at 08:00 and
// 22:00, so 03:17 is not one of its slots and the pin is refused rather
// than silently recorded as an ad-hoc dose.
const offSlot = await api(
page,
"POST",
`/api/medications/${medicationId}/intake`,
{
skipped: false,
forceSlotInstant: unscheduled,
},
);
expect(offSlot.status, "an instant that is not a slot is refused").toBe(
422,
);
// Nothing was written, so nothing moved — including on the engine's own
// read, which neither refused call had an opportunity to invalidate.
const payload = await api<CompliancePayload>(
page,
"GET",
`/api/medications/${medicationId}/compliance`,
);
expect(payload.status).toBe(200);
expect((payload.data as CompliancePayload).compliance7.taken).toBe(0);
await page.goto("/medications");
expect(await readCardRate(page, medicationId)).toBe(before);
});
test("a two-weekday plan does not count the other five days as missed", async ({
page,
}) => {
// Two medications seeded, aged and dosed three times apiece before the
// first assertion; on a loaded runner that earns the tripled timeout.
test.slow();
// Both dose days are behind us and neither is today, so the plan's whole
// realised history is closed and the verdicts do not depend on the hour
// the suite runs at. The 08:00 dose eight days back falls on the same
// weekday as yesterday's, which is what keeps the longer window's answer
// the same as the short one's.
const plan = await page.evaluate(() => {
const TOKENS = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
const at8 = (daysAgo: number) => {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
d.setHours(8, 0, 0, 0);
return d;
};
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const doseDays = [at8(8), at8(2), at8(1)];
return {
byday: [TOKENS[at8(2).getDay()], TOKENS[at8(1).getDay()]].join(","),
startsOn: iso(at8(8)),
slots: doseDays.map((d) => d.toISOString()),
};
});
const weekdayId = await seedMedication(page, {
name: "E2E Wochentage",
dose: "5 mg",
startsOn: plan.startsOn,
schedule: {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: `FREQ=WEEKLY;BYDAY=${plan.byday}`,
},
});
// The control: the same doses on the same days, on a plan that expects one
// every day. Without it "100 %" proves nothing — an engine that counted no
// days at all would also read 100 %.
const dailyId = await seedMedication(page, {
name: "E2E Täglich",
dose: "5 mg",
startsOn: plan.startsOn,
schedule: {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
},
});
const stamps = await ageStamps(page, 8);
for (const id of [weekdayId, dailyId]) {
await ageMedication(id, stamps, cacheFlusher(page, id, "5 mg"));
for (const slot of plan.slots) {
const written = await api(
page,
"POST",
`/api/medications/${id}/intake`,
{
skipped: false,
scheduledFor: slot,
takenAt: slot,
},
);
expect(written.status, "a backdated dose is written").toBe(201);
}
}
const read = async (id: string) => {
const res = await api<CompliancePayload>(
page,
"GET",
`/api/medications/${id}/compliance`,
);
expect(res.status).toBe(200);
return (res.data as CompliancePayload).compliance7;
};
const weekday = await read(weekdayId);
const daily = await read(dailyId);
// Two dose days in the trailing week, both taken, none missed. If the five
// non-dose weekdays reached the denominator this would read seven.
expect(weekday.totalExpected).toBe(2);
expect(weekday.taken).toBe(2);
expect(weekday.missed).toBe(0);
expect(weekday.rate).toBe(100);
// The daily twin, given exactly the same doses, is missing the rest.
expect(daily.totalExpected).toBeGreaterThanOrEqual(6);
expect(daily.missed).toBeGreaterThanOrEqual(4);
expect(daily.rate).toBeLessThan(100);
// And the two cards say the same thing side by side.
await page.goto("/medications");
expect(await readCardRate(page, weekdayId)).toBe(100);
expect(await readCardRate(page, dailyId)).toBeLessThan(100);
});
});
+448
View File
@@ -0,0 +1,448 @@
import type {
APIResponse,
BrowserContext,
Page,
Response,
} from "@playwright/test";
import * as OTPAuth from "otpauth";
import {
TOTP_ALGORITHM,
TOTP_DIGITS,
TOTP_PERIOD_SECONDS,
} from "@/lib/auth/mfa/totp";
import { expect, test } from "./setup/test";
import {
clearAuthRateLimits,
dropMfaAccount,
expireStepUp,
latestChallengeAttempts,
latestMfaFailure,
mfaJourneyAccount,
seedMfaAccount,
type MfaJourneyAccount,
} from "./setup/mfa-journey-fixture";
/**
* The whole second-factor path, end to end, in a real browser: enrol TOTP from
* the settings surface, sign out, sign back in through the challenge, and step
* up for an action that demands a recent factor.
*
* ## Why one test and not six
*
* Every stage here is the previous stage's output. The code the sign-in spends
* is the code the next stage replays; the recovery batch that sign-in spends is
* the one the successful regeneration issued; the expired-step-up refusal is
* only meaningful against the session the successful one just used. Split
* across tests, each would have to re-enrol a factor, and enrolment is exactly
* what the first stage is testing. So it is one journey, with `test.step`
* marking the stages in the report, and the account is thrown away afterwards.
*
* ## Controls
*
* Five refusals ride alongside the four positives, because a gate that has
* never been seen to refuse is not known to be a gate:
*
* - a wrong code at the login challenge — 401, no session cookie, and the
* ticket's attempt counter moved (read from the row, since the wire's 401
* is deliberately identical for every reason);
* - the code the previous sign-in just spent, replayed at the next challenge
* — 401. The server implements this as a monotonic `User.totpLastStep`
* floor advanced on every accept (`src/lib/auth/mfa/verify-factor.ts`), so
* a code already spent is refused even inside its own 30-second life;
* - the sensitive action once the step-up stamp has aged out — 401 with
* `meta.errorCode = "auth.stepup.required"`, and the card's prompt;
* - a recovery code presented a second time — 401. The matched row is burned
* on first use;
* - one login past the documented per-IP cap — 429, with the bucket's own
* headers. Measured in one project, for the reason written at that step.
*
* ## Reading the replay refusal, not guessing it
*
* Both TOTP refusals answer with the same generic 401, so the status code
* cannot say WHY. A code that has drifted out of its ±1-step window is refused
* before the replay floor is ever consulted, which means a status-only control
* would stay green with the floor deleted. The route records the distinction —
* `auth.mfa.failed` with `details.replay`, awaited before it answers — so both
* refusals assert that verdict as a pair: false for the wrong code, true for
* the replay.
*
* ## Codes without a clock
*
* Every code is computed from the enrolment secret with the `otpauth`
* dependency the server itself verifies against, at an explicit timestamp. The
* sign-in code is generated one step AHEAD of now: the server accepts ±1 step
* of drift, and a future step is necessarily above the replay floor the
* enrolment left behind, whichever side of a boundary the request lands on.
*
* The one place the boundary matters is that code's second life as the replay:
* it stays in-window for two steps past the one it names, and the sign-out and
* sign-in between the two submissions eat into that. So the journey parks for
* the next step boundary — bounded by a single period, and only when the
* current step has too little left — instead of hoping the wall clock is kind.
*/
const RECOVERY_CODE_COUNT = 10;
const REGENERATE_ENDPOINT = "/api/auth/me/mfa/recovery-codes/regenerate";
/** `/api/auth/login`, per IP per 15 minutes (`src/lib/rate-limit.ts`). */
const LOGIN_ATTEMPT_LIMIT = 5;
/** The project that measures that cap — see the closing step for why one. */
const THROTTLE_PROBE_PROJECT = "chromium-desktop";
/**
* The margin the accept→replay pair needs. A code named for step N verifies
* until the end of step N+1 (drift −1), so generating it one step ahead leaves
* at least two periods; below this the journey waits for the next boundary.
*/
const REPLAY_MARGIN_MS = 75_000;
/** A code for `atMs`, built the way `src/lib/auth/mfa/totp.ts` verifies it. */
function totpCodeAt(secretBase32: string, atMs: number): string {
return new OTPAuth.TOTP({
issuer: "HealthLog",
label: "HealthLog",
algorithm: TOTP_ALGORITHM,
digits: TOTP_DIGITS,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
}).generate({ timestamp: atMs });
}
/**
* Park until the next TOTP step starts, when the current one has too little
* left for the code about to be minted to survive its replay.
*
* Bounded by a single period and usually a no-op: it waits only for the last
* seconds of a step, and a step is 30 of them.
*/
async function alignToStepBoundary(page: Page): Promise<void> {
const periodMs = TOTP_PERIOD_SECONDS * 1000;
const now = Date.now();
const nextBoundary = (Math.floor(now / periodMs) + 1) * periodMs;
// A code minted now names the next step and verifies until two periods
// after that step begins.
if (nextBoundary + 2 * periodMs - now >= REPLAY_MARGIN_MS) return;
await page.waitForTimeout(nextBoundary - now);
}
async function sessionCookie(context: BrowserContext) {
const cookies = await context.cookies();
return cookies.find((c) => c.name === "healthlog_session");
}
/**
* Open the password form and submit the account's credentials.
*
* The login page opens in passkey mode; the password form mounts only after
* the switch. Returns the `/api/auth/login` response so the caller can assert
* what the SERVER said, not only what the page painted — the two answers this
* journey cares about (a session, or a challenge) are both HTTP 200 and only
* the body tells them apart.
*/
async function submitPassword(
page: Page,
account: MfaJourneyAccount,
): Promise<Response> {
await clearAuthRateLimits();
await page.goto("/auth/login");
await page.getByTestId("login-use-password").click();
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill(account.password);
const answer = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/login") &&
res.request().method() === "POST",
);
await page.getByTestId("login-password-submit").click();
return answer;
}
/** Submit a factor at the login challenge and return the verify response. */
async function submitFactor(page: Page, code: string): Promise<Response> {
await page.getByTestId("mfa-code-input").fill(code);
const answer = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/mfa/verify") &&
res.request().method() === "POST",
);
await page.getByTestId("mfa-code-submit").click();
return answer;
}
/**
* Open the regeneration dialog, confirm it, and return the route's answer.
*
* The confirming click calls `preventDefault()` so the dialog survives the
* request in flight; the mutation takes it down again when the request
* settles, whatever the answer was. That matters on a REFUSAL, where the card
* behind the dialog is the only thing that says why — on a success the
* fresh-codes panel replaces the whole branch the dialog lives in and it would
* unmount either way. So the dialog being gone here is a product assertion,
* not housekeeping: nothing in this helper dismisses it.
*/
async function regenerateRecoveryCodes(page: Page): Promise<Response> {
await page.getByTestId("recovery-regenerate").click();
const answer = page.waitForResponse(
(res) =>
res.url().includes(REGENERATE_ENDPOINT) &&
res.request().method() === "POST",
);
await page.getByTestId("recovery-regenerate-confirm").click();
const settled = await answer;
await expect(page.getByTestId("recovery-regenerate-dialog")).toBeHidden();
return settled;
}
/** The codes currently on screen, gated on the panel being painted. */
async function visibleRecoveryCodes(page: Page): Promise<string[]> {
const panel = page.getByTestId("recovery-codes");
await expect(panel).toBeVisible();
const items = panel.locator("li");
await expect(items).toHaveCount(RECOVERY_CODE_COUNT);
return items.allInnerTexts();
}
/**
* Spend the login bucket from empty and hand back the answer that went over.
*
* The bucket is per-IP, and the sibling browser project running this same file
* shares it: its sign-ins clear the bucket, and its own attempts fill it. Both
* directions disturb a round rather than fake one — a disturbed round is
* retried, and a round only counts when the whole documented shape holds, the
* cap's worth of attempts answered normally and the next one refused. Returns
* null when no round came out clean, which fails loudly at the call site.
*/
async function reachLoginCap(
page: Page,
account: MfaJourneyAccount,
): Promise<APIResponse | null> {
const attempt = () =>
page.request.post("/api/auth/login", {
data: { email: account.username, password: "not-this-account-password" },
});
for (let round = 0; round < 3; round += 1) {
await clearAuthRateLimits();
let clean = true;
for (let i = 0; i < LOGIN_ATTEMPT_LIMIT && clean; i += 1) {
clean = (await attempt()).status() === 401;
}
if (!clean) continue;
const capped = await attempt();
if (capped.status() === 429) return capped;
}
return null;
}
test.describe("second factor", () => {
test.afterEach(async () => {
await dropMfaAccount(mfaJourneyAccount(test.info().project.name));
});
test("enrols TOTP, signs in with it, and steps up for a sensitive action", async ({
page,
context,
}) => {
// Five sign-ins, an Argon2id verify on each, an enrolment, two
// regenerations and a bounded park on a step boundary. Comfortably
// outside the suite's 30 s default.
test.setTimeout(240_000);
// One account per project: both browser projects run this file, and the
// journey moves the account's factor state, its replay floor and its
// recovery batch. See the fixture's header.
const account: MfaJourneyAccount = mfaJourneyAccount(
test.info().project.name,
);
await seedMfaAccount(account);
let secret = "";
let spentCode = "";
let liveRecoveryCodes: string[] = [];
await test.step("signs in with a password alone while no factor exists", async () => {
const answer = await submitPassword(page, account);
expect(answer.status()).toBe(200);
const body = (await answer.json()) as {
meta?: { mfaRequired?: boolean };
};
expect(body.meta?.mfaRequired).toBeUndefined();
await page.waitForURL("/");
expect(await sessionCookie(context)).toBeDefined();
});
await test.step("enrols an authenticator from the settings surface", async () => {
await page.goto("/settings/security");
await expect(page.getByTestId("totp-card")).toBeVisible();
const setup = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/me/mfa/totp/setup") &&
res.request().method() === "POST",
);
await page.getByTestId("totp-setup-start").click();
const setupAnswer = await setup;
expect(setupAnswer.status()).toBe(200);
const setupBody = (await setupAnswer.json()) as {
data: { otpauthUri: string; totpSecret: string };
};
secret = setupBody.data.totpSecret;
// The QR the page renders and the manual secret beside it must describe
// the same authenticator, or a user who scans and a user who types end
// up enrolled against different secrets.
expect(setupBody.data.otpauthUri).toContain(secret);
await page
.getByTestId("totp-confirm-code")
.fill(totpCodeAt(secret, Date.now()));
const confirm = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/me/mfa/totp/confirm") &&
res.request().method() === "POST",
);
await page.getByTestId("totp-confirm-submit").click();
expect((await confirm).status()).toBe(200);
liveRecoveryCodes = await visibleRecoveryCodes(page);
await expect(page.getByTestId("totp-error")).toBeHidden();
});
await test.step("rotates the recovery codes while the step-up is fresh", async () => {
// The confirming session is stamped `mfaVerifiedAt` by the confirm route,
// so the freshest possible step-up is the one right after enrolment.
await page.getByTestId("recovery-codes-dismiss").click();
const answer = await regenerateRecoveryCodes(page);
expect(answer.status()).toBe(200);
const rotated = await visibleRecoveryCodes(page);
expect(rotated).not.toEqual(liveRecoveryCodes);
await expect(page.getByTestId("totp-error")).toBeHidden();
liveRecoveryCodes = rotated;
});
await test.step("refuses the same rotation once the step-up has aged out", async () => {
await page.getByTestId("recovery-codes-dismiss").click();
await expireStepUp(account);
const answer = await regenerateRecoveryCodes(page);
expect(answer.status()).toBe(401);
const body = (await answer.json()) as {
data: null;
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("auth.stepup.required");
// And the card says so, on a card the closed dialog no longer covers.
await expect(page.getByTestId("totp-error")).toBeVisible();
});
await test.step("asks for the second factor on the next sign-in", async () => {
expect((await page.request.post("/api/auth/logout")).status()).toBe(200);
const answer = await submitPassword(page, account);
expect(answer.status()).toBe(200);
const body = (await answer.json()) as {
data: null;
meta?: { mfaRequired?: boolean; methods?: string[] };
};
// A correct password is not a session here: the partial state lives in
// the ticket and nothing is minted until the factor passes.
expect(body.data).toBeNull();
expect(body.meta?.mfaRequired).toBe(true);
expect(body.meta?.methods).toEqual(
expect.arrayContaining(["totp", "recovery"]),
);
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
});
await test.step("refuses wrong codes and counts each attempt", async () => {
const answer = await submitFactor(page, "000000");
expect(answer.status()).toBe(401);
await expect(page.getByTestId("mfa-error")).toBeVisible();
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
expect(await latestChallengeAttempts(account)).toBe(1);
// The negative half of the replay pair: a code that never matched the
// secret is not a replay, and the server says so.
expect((await latestMfaFailure(account)).replay).toBe(false);
// A second refusal moves the counter again rather than resetting it.
expect((await submitFactor(page, "000001")).status()).toBe(401);
expect(await latestChallengeAttempts(account)).toBe(2);
});
await test.step("accepts a current code and lands on the dashboard", async () => {
await alignToStepBoundary(page);
// One step ahead of now — inside the ±1 drift window and necessarily
// above the floor the enrolment code left. See the file header.
spentCode = totpCodeAt(secret, Date.now() + TOTP_PERIOD_SECONDS * 1000);
const answer = await submitFactor(page, spentCode);
expect(answer.status()).toBe(200);
await page.waitForURL("/");
expect(await sessionCookie(context)).toBeDefined();
});
await test.step("refuses the code the last sign-in spent", async () => {
expect((await page.request.post("/api/auth/logout")).status()).toBe(200);
await submitPassword(page, account);
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
const answer = await submitFactor(page, spentCode);
expect(answer.status()).toBe(401);
await expect(page.getByTestId("mfa-error")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
expect(await latestChallengeAttempts(account)).toBe(1);
// The code is still inside its own life — the floor is what refused it,
// and this is the assertion that fails if the floor is removed.
expect((await latestMfaFailure(account)).replay).toBe(true);
});
await test.step("spends a recovery code once", async () => {
await page.getByTestId("mfa-recovery-toggle").click();
const answer = await submitFactor(page, liveRecoveryCodes[0]);
expect(answer.status()).toBe(200);
await page.waitForURL("/");
expect(await sessionCookie(context)).toBeDefined();
});
await test.step("refuses the same recovery code a second time", async () => {
expect((await page.request.post("/api/auth/logout")).status()).toBe(200);
await submitPassword(page, account);
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
await page.getByTestId("mfa-recovery-toggle").click();
const answer = await submitFactor(page, liveRecoveryCodes[0]);
expect(answer.status()).toBe(401);
await expect(page.getByTestId("mfa-error")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
expect(await latestChallengeAttempts(account)).toBe(1);
});
// Every sign-in above clears this bucket, which makes the throttle the one
// auth control the journey rides on and never sees. It costs one step to
// see it: the account is about to be dropped and nothing else signs in
// from here.
//
// Measured in ONE project. The bucket is keyed per IP and every spec on
// the machine is that one IP, the sibling browser project running this
// same file included — two probes emptying and filling one bucket beside
// each other measure their own interference, not the limiter, and were
// seen to. The limit is server behaviour with no viewport in it, so the
// second project would only re-measure the first.
if (test.info().project.name === THROTTLE_PROBE_PROJECT) {
await test.step("refuses the login past the per-IP cap", async () => {
const capped = await reachLoginCap(page, account);
expect(
capped,
"no round of login attempts reached the cap",
).not.toBeNull();
expect(capped?.status()).toBe(429);
expect(capped?.headers()["x-ratelimit-remaining"]).toBe("0");
expect(capped?.headers()["x-ratelimit-reset"]).toBeTruthy();
// Hand the bucket back to the machine — it is shared per IP.
await clearAuthRateLimits();
});
}
});
});
+701
View File
@@ -0,0 +1,701 @@
/**
* Notification preferences → dispatch decision, the web arm — one journey.
*
* The flow this file is checked against, in one script:
*
* 1. an account configures two delivery channels on the settings surface —
* email switched ON with a recipient, ntfy saved with a public relay and
* left OFF;
* 2. it pairs a phone, so the APNs arm exists in the cascade;
* 3. a medication whose dose window closed hours ago is created through the
* page's own fetch, and the reminder sweep the admin panel drives runs;
* 4. the dispatch decision is read back off the account's own delivery
* ledger: one email attempt, delivered; nothing for the channel that is
* switched off; the APNs arm attempted and skipped with its reason;
* 5. the account then opts into client-managed medication reminders — the
* iOS contract — and the same sweep skips the APNs arm for THAT reason
* while email still delivers, which is the whole claim of the opt-in.
*
* Refusing controls: a channel switched off is never attempted and its own
* test control is inert; and an ntfy relay URL inside a private range is
* refused by the save, leaves the stored config untouched, and writes no
* delivery attempt.
*
* Why email is the channel that gets exercised for real, and why nothing else
* could be: every account-supplied delivery target crosses the SSRF floor
* (`isPublicUrl` at save time, `safeFetch({ requirePublicHost: true })` with a
* connect-time address pin at send time), so ntfy, the generic webhook and Web
* Push all refuse a local stub by design; Telegram and APNs address hard-coded
* third parties. Email's transport is OPERATOR configuration, so it never
* crosses that floor — see `e2e/setup/smtp-stub.ts`. The APNs arm therefore
* cannot deliver here, and the journey does not pretend it can: what it proves
* about APNs is the DECISION, read from the reason the ledger carries.
*
* Every assertion addresses a stable attribute — `data-slot`, `data-state`,
* a form-control id — or the account's own API responses, never rendered copy:
* the labels are i18n-driven and this account renders them in English only by
* cookie.
*
* What isolates this file, and what does not. `test.describe.configure({ mode:
* "serial" })` orders the tests INSIDE it; the suite runs `fullyParallel` with
* two workers, so every other spec file is live against the same database
* throughout. Three things carry the isolation instead: the account is this
* file's own (`E2E_NOTIFY`), the server is this file's own (mail configured, no
* reminder scheduler — `e2e/setup/notification-server.ts`), and the reminder
* sweep is told which account to walk, so the one trigger here that could
* dispatch for the whole instance no longer does.
*
* What is left standing is a precondition rather than a mechanism: nothing else
* delivers email in this run, because no other e2e account configures an EMAIL
* channel and no other server is pointed at the responder. `smtp.accepted()` is
* asserted by exact equality on the strength of that, and a spec that seeds an
* email channel for another account has to say so here.
*
* `reason: "apns_not_configured"` and `reason: "client_managed"` are internal
* sender / dispatcher constants rather than published contract, and they are
* pinned on purpose: the ledger reason is the only place the DECISION is
* legible, and the whole claim of the client-managed opt-in is that the reason
* changes while everything else does not. A rename that reaches here should
* read as a decision to make, not as a mystery break.
*/
import type { Page } from "@playwright/test";
import { E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH } from "./setup/global-setup";
import { NOTIFICATION_BASE_URL } from "./setup/notification-server";
import {
notificationAccountId,
resetNotificationFixture,
} from "./setup/notification-fixture";
import { startSmtpStub, type SmtpStub } from "./setup/smtp-stub";
import { expect, test } from "./setup/test";
/** The panel has finished its read and the channel cards are mounted. */
const CHANNELS_PANEL = '[data-slot="notification-channels-panel"]';
/** Where the account's email notifications are addressed. */
const EMAIL_RECIPIENT = "notification-journey@healthlog.test";
/**
* A public relay hostname for the channel that stays switched OFF. It is
* never dialled — the dispatcher drops a disabled channel before any I/O, and
* that is precisely the verdict this journey wants — but it has to pass
* `isPublicUrl` for the save to succeed at all.
*/
const NTFY_SERVER = "https://ntfy.example.com";
const NTFY_TOPIC = "healthlog-e2e-notify";
/** RFC1918. The save refuses it; the refusal is the point. */
const PRIVATE_NTFY_SERVER = "http://192.168.31.7";
/**
* A 64-character hex APNs token. Hex only — the registration route refuses
* anything else — and this spec's alone, because a token belongs to exactly
* one account and re-registering it under another is a 409.
*/
const APNS_TOKEN = `e2e0d15c0de${"0".repeat(53)}`;
/** The medication whose window has closed, so the sweep has something to send. */
const MEDICATION_NAME = "E2E Notification Journey";
interface PushAttempt {
channel: string;
eventType: string;
result: string;
reason: string | null;
}
interface DiagnosticPayload {
notificationChannels: Array<{
type: string;
enabled: boolean;
configPresent: boolean;
}>;
recentPushAttempts: PushAttempt[];
}
/**
* Every fetch below rides the PAGE's own `fetch` rather than a shared
* Playwright request context: a pooled context can hand a POST to a keep-alive
* socket the server has already closed, and the retry lands as a second write.
* It also means the cookie, the origin and the CSRF posture are the browser's,
* which is the arm this journey is about.
*/
async function fromPage<T>(
page: Page,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<{ status: number; data: T | null }> {
return page.evaluate(
async (call: { path: string; method: string; body: string | null }) => {
const res = await fetch(call.path, {
method: call.method,
credentials: "include",
...(call.body === null
? {}
: {
headers: { "Content-Type": "application/json" },
body: call.body,
}),
});
const envelope = (await res.json()) as { data: unknown };
return { status: res.status, data: envelope.data };
},
{
path,
method: init.method ?? "GET",
body: init.body === undefined ? null : JSON.stringify(init.body),
},
) as Promise<{ status: number; data: T | null }>;
}
/** The admin notification diagnostic — channels and the trailing ledger. */
async function readDiagnostic(page: Page): Promise<DiagnosticPayload> {
const res = await fromPage<DiagnosticPayload>(
page,
"/api/admin/notifications/diagnostic",
);
expect(res.status, "the diagnostic answered the admin cookie").toBe(200);
if (!res.data) throw new Error("diagnostic returned no data");
return res.data;
}
/**
* The delivery attempts written since `before` rows existed.
*
* The ledger only ever grows and the diagnostic returns it newest-first, so
* the head of the list is exactly what this dispatch produced. The wait is a
* poll rather than a sleep because the ledger write is fire-and-forget: the
* trigger's response can return before the row lands.
*
* `expected` is asserted, not merely awaited — an EXTRA attempt is the failure
* this journey exists to catch (a channel that was switched off delivering
* anyway), and a poll that stops at "at least" would never see it.
*
* The diagnostic returns the trailing TWENTY rows and no more
* (`src/app/api/admin/notifications/diagnostic/route.ts`). The fixture resets
* the ledger to zero before every test and the largest cascade here is two, so
* the ceiling is far away — but a poll that ran past it could never converge
* and would report "the dispatch wrote N delivery attempt(s)" while the real
* answer was "the diagnostic stopped counting". The ceiling is asserted up
* front so that failure names itself.
*/
async function attemptsSince(
page: Page,
before: number,
expected: number,
): Promise<PushAttempt[]> {
expect(
before + expected,
"the diagnostic returns at most 20 attempts, so a larger window could never converge",
).toBeLessThanOrEqual(20);
await expect
.poll(async () => (await readDiagnostic(page)).recentPushAttempts.length, {
message: `the dispatch wrote ${expected} delivery attempt(s)`,
timeout: 20_000,
})
.toBe(before + expected);
const all = (await readDiagnostic(page)).recentPushAttempts;
expect(
all.length,
"no further attempt arrived after the ledger settled",
).toBe(before + expected);
return all.slice(0, expected);
}
/** How many attempts the ledger already holds. */
async function attemptCount(page: Page): Promise<number> {
return (await readDiagnostic(page)).recentPushAttempts.length;
}
/**
* Open the delivery-channels surface and wait until each card has taken its
* seed from the server.
*
* The wait is not cosmetic. Both cards mount BEFORE their query resolves and
* then copy the server's value into the input once it arrives — the React
* sync-from-server shape, no effect involved. A value typed into the input
* before that copy runs is silently replaced by the server's, so the save that
* follows persists an empty field and the next request is refused for a reason
* that points nowhere near the test. Waiting for the two reads is what makes
* the copy already done rather than pending.
*/
async function openChannels(page: Page): Promise<void> {
const seeded = ["/api/settings/email", "/api/settings/ntfy"].map((endpoint) =>
page.waitForResponse(
(res) =>
new URL(res.url()).pathname === endpoint &&
res.request().method() === "GET",
),
);
await page.goto("/settings/integrations", { waitUntil: "domcontentloaded" });
await expect(page.locator(CHANNELS_PANEL)).toBeVisible({ timeout: 30_000 });
await Promise.all(seeded);
}
/**
* Type into a channel card's field and confirm the field kept it. The
* confirmation is the second half of the seed race described on
* `openChannels`: the read has landed, so nothing is left to overwrite it.
*/
async function fillField(
page: Page,
selector: string,
value: string,
): Promise<void> {
await page.locator(selector).fill(value);
await expect(page.locator(selector)).toHaveValue(value);
}
/**
* Save one channel card's form and hand back the status the settings route
* answered with. Gated on the card's own PUT rather than on a repaint, so a
* refusal is read from the wire instead of inferred from what did not change.
*/
async function saveCard(
page: Page,
anchor: string,
endpoint: string,
): Promise<number> {
const settled = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === endpoint &&
res.request().method() === "PUT",
);
await page
.locator(
`${anchor} [data-slot="settings-card-actions"] button[type=submit]`,
)
.click();
return (await settled).status();
}
/** Flip a channel card's enable switch and wait for the route to answer. */
async function toggleCard(
page: Page,
switchId: string,
endpoint: string,
): Promise<number> {
const settled = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === endpoint &&
res.request().method() === "PUT",
);
await page.locator(switchId).click();
return (await settled).status();
}
/**
* Configure both channels the way a person would: email on with a recipient,
* ntfy saved and left off. Everything here happens on the settings surface.
*/
async function configureChannels(page: Page): Promise<void> {
await openChannels(page);
await fillField(page, "#email-recipient", EMAIL_RECIPIENT);
expect(await saveCard(page, "#email", "/api/settings/email")).toBe(200);
expect(await toggleCard(page, "#email-toggle", "/api/settings/email")).toBe(
200,
);
await expect(page.locator("#email-toggle")).toHaveAttribute(
"data-state",
"checked",
);
await fillField(page, "#ntfy-server", NTFY_SERVER);
await fillField(page, "#ntfy-topic", NTFY_TOPIC);
expect(await saveCard(page, "#ntfy", "/api/settings/ntfy")).toBe(200);
await expect(page.locator("#ntfy-toggle")).toHaveAttribute(
"data-state",
"unchecked",
);
}
/**
* Pair a phone through the device-registration route the native client uses.
* That registration — and nothing else in the product — is what puts an APNS
* row in the cascade, so the arm the client-managed opt-in suppresses only
* exists once this has run.
*/
async function pairPhone(page: Page): Promise<void> {
const res = await fromPage(page, "/api/devices", {
method: "POST",
body: {
token: APNS_TOKEN,
bundleId: "dev.healthlog.e2e",
apnsToken: APNS_TOKEN,
apnsEnvironment: "sandbox",
},
});
expect(res.status, "the phone registered").toBe(201);
}
/**
* Pin the account's timezone so its local clock reads midday.
*
* The sweep classifies a dose window against the account's OWN wall clock, so
* a window written as a fixed pair of times is overdue for most of the day and
* not yet open around midnight. Rather than leave a spec that is honest 1439
* minutes out of 1440, the account sets the one preference that decides which
* clock is consulted — through its own profile route, the same PATCH the
* profile form sends — to a whole-hour zone where "now" is early afternoon.
* The morning window below is then unambiguously closed, every run.
*/
async function pinAccountClockToMidday(page: Page): Promise<void> {
const wrapped = (12 - new Date().getUTCHours() + 24) % 24;
const offsetHours = wrapped > 12 ? wrapped - 24 : wrapped;
// `Etc/GMT-5` is UTC+5 — the POSIX sign convention is inverted, which is
// why the zone name is built rather than looked up.
const zone =
offsetHours === 0
? "Etc/GMT"
: offsetHours > 0
? `Etc/GMT-${offsetHours}`
: `Etc/GMT+${-offsetHours}`;
const res = await fromPage<{ timezone: string }>(page, "/api/user/profile", {
method: "PATCH",
body: { timezone: zone },
});
expect(res.status, "the profile accepted the timezone").toBe(200);
expect(res.data?.timezone).toBe(zone);
}
/**
* A medication with a morning window and reminders on. With the account's
* clock pinned to early afternoon the window is hours closed, which is the
* state the sweep dispatches for.
*/
async function createOverdueMedication(page: Page): Promise<string> {
const res = await fromPage<{ id: string }>(page, "/api/medications", {
method: "POST",
body: {
name: MEDICATION_NAME,
dose: "1 tablet",
notificationsEnabled: true,
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
},
});
expect(res.status, "the medication was created").toBe(201);
const id = res.data?.id;
expect(id, "the create returned an id").toBeTruthy();
return id as string;
}
interface SweepResult {
scoped: boolean;
medications: Array<{
name: string;
user: string;
schedules: Array<{ notificationSent: boolean }>;
}>;
}
/**
* Run the reminder sweep the admin notifications panel drives, NAMING this
* account. This is the product's own trigger for the medication-reminder
* dispatch, and it goes through `dispatchNotification` — the cascade, the
* per-channel preference read and the client-managed gate — rather than
* calling a sender directly.
*
* The selector is not a convenience. Without it the sweep walks every active
* medication on the instance and dispatches for each overdue slot, which makes
* this file a writer into every other account's delivery ledger — the exact
* inverse of the interference it is trying to avoid, and one that would start
* sending real mail the moment another account had a channel and an overdue
* dose. Scoped, the run reaches one account: the one it then counts.
*/
async function runReminderSweep(page: Page, userId: string): Promise<void> {
const res = await fromPage<SweepResult>(
page,
"/api/admin/notifications/reminder-check",
{ method: "POST", body: { userId } },
);
expect(res.status, "the sweep ran").toBe(200);
expect(
res.data?.scoped,
"the sweep walked one account, not the instance",
).toBe(true);
expect(
[...new Set(res.data?.medications.map((m) => m.user))],
"every medication the sweep touched belongs to this account",
).toEqual([E2E_NOTIFY.username]);
const mine = res.data?.medications.find((m) => m.name === MEDICATION_NAME);
expect(mine, "the sweep saw this account's overdue medication").toBeTruthy();
expect(
mine?.schedules.some((s) => s.notificationSent),
"the sweep dispatched for the closed window",
).toBe(true);
}
/** Look one channel's attempt out of a dispatch's rows. */
function attemptFor(
attempts: PushAttempt[],
channel: string,
): PushAttempt | undefined {
return attempts.find((a) => a.channel === channel);
}
test.describe("notification preferences drive the dispatch decision", () => {
/**
* Its own server as well as its own account. That process is the one carrying
* the mail configuration, and the one with no reminder scheduler inside it —
* `e2e/setup/notification-server.ts` says why each matters and what the
* shared server keeping the scheduler still leaves standing.
*/
test.use({
storageState: NOTIFY_STORAGE_STATE_PATH,
baseURL: NOTIFICATION_BASE_URL,
});
/**
* Serial, because every test configures the one account's channels and then
* counts rows in its ledger. Two of these in flight would be counting each
* other's deliveries.
*/
test.describe.configure({ mode: "serial" });
/**
* The ledger write that follows a dispatch is fire-and-forget and the tests
* poll for it, so the budget covers a loaded runner rather than the 30 s
* default.
*/
test.setTimeout(120_000);
let smtp: SmtpStub;
let accountId: string;
test.beforeAll(async ({ playwright }) => {
smtp = await startSmtpStub();
accountId = await notificationAccountId();
// The one precondition this file cannot arrange for itself: the server it
// talks to has to have been started with `SMTP_*`. `playwright.config.ts`
// hands them to the server it starts, but `reuseExistingServer` (the local
// default) and `E2E_SKIP_WEB_SERVER=1` both attach to a server started
// without them — and the symptom is a missing `#email-recipient` field
// thirty lines into the first test, which points nowhere near the cause.
const api = await playwright.request.newContext({
baseURL: NOTIFICATION_BASE_URL,
storageState: NOTIFY_STORAGE_STATE_PATH,
});
try {
const res = await api.get("/api/settings/email");
const body = (await res.json()) as { data: { smtpConfigured: boolean } };
expect(
body.data?.smtpConfigured,
`the server on ${NOTIFICATION_BASE_URL} was started without SMTP_* — a reused ` +
`dev server or E2E_SKIP_WEB_SERVER=1 does not carry them. Start it with ` +
`SMTP_HOST=127.0.0.1 SMTP_PORT=${smtp.port} SMTP_FROM=<addr>.`,
).toBe(true);
} finally {
await api.dispose();
}
});
test.afterAll(async () => {
// Optional-chained: a `beforeAll` that threw before the stub resolved
// leaves this undefined, and a teardown TypeError buries the real cause
// under its own.
await smtp?.close();
});
test.beforeEach(async () => {
await resetNotificationFixture();
smtp.reset();
});
test("the card's own test control delivers on the enabled channel only", async ({
page,
}) => {
await configureChannels(page);
// The diagnostic agrees with the surface about which channel is on. Both
// reads are the account's own; neither is the switch it just clicked.
const channels = (await readDiagnostic(page)).notificationChannels;
expect(
channels.find((c) => c.type === "EMAIL"),
"the email channel is configured and on",
).toMatchObject({ enabled: true, configPresent: true });
expect(
channels.find((c) => c.type === "NTFY"),
"the ntfy channel is configured and off",
).toMatchObject({ enabled: false, configPresent: true });
// A channel that is off offers no way to probe it — the refusal is on the
// surface, before any request is made.
await expect(
page.locator(
'#ntfy [data-slot="settings-card-actions"] button[type=button]',
),
).toBeDisabled();
const before = await attemptCount(page);
const probe = page.waitForResponse(
(res) => new URL(res.url()).pathname === "/api/settings/email/test",
);
await page
.locator('#email [data-slot="settings-card-actions"] button[type=button]')
.click();
expect((await probe).status(), "the probe reached the transport").toBe(200);
const attempts = await attemptsSince(page, before, 1);
expect(attempts[0]).toMatchObject({
channel: "EMAIL",
result: "ok",
reason: null,
});
// And the transport is a real one: the message left the process and was
// accepted for the address the settings surface saved.
expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]);
});
test("a due reminder delivers on the enabled channel and skips the rest", async ({
page,
}) => {
await configureChannels(page);
await pairPhone(page);
await pinAccountClockToMidday(page);
await createOverdueMedication(page);
const before = await attemptCount(page);
await runReminderSweep(page, accountId);
// Two arms, and exactly two: the paired phone and the enabled email
// channel. A third row would mean the switched-off relay was dialled.
const attempts = await attemptsSince(page, before, 2);
expect(
attempts.map((a) => a.channel).sort(),
"only the channels in the cascade were attempted",
).toEqual(["APNS", "EMAIL"]);
expect(attemptFor(attempts, "EMAIL")).toMatchObject({
eventType: "MEDICATION_REMINDER",
result: "ok",
reason: null,
});
expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]);
// The APNs arm was reached and refused by the TRANSPORT, not by a
// preference — this instance has no APNs credentials. That distinction is
// the baseline the next test moves.
expect(attemptFor(attempts, "APNS")).toMatchObject({
eventType: "MEDICATION_REMINDER",
result: "skipped",
reason: "apns_not_configured",
});
});
test("the client-managed opt-in suppresses the APNs arm and nothing else", async ({
page,
}) => {
await configureChannels(page);
await pairPhone(page);
await pinAccountClockToMidday(page);
await createOverdueMedication(page);
// The opt-in the native client sets when it owns the local reminder.
const patched = await fromPage<{ medication: { clientManaged: boolean } }>(
page,
"/api/auth/me/notification-prefs",
{ method: "PATCH", body: { medication: { clientManaged: true } } },
);
expect(patched.status, "the preference was accepted").toBe(200);
expect(patched.data?.medication.clientManaged).toBe(true);
// Read back through the account's own resolved preferences, not through
// the value just echoed: the deep-merge is what the dispatcher consults.
const resolved = await fromPage<{ medication: { clientManaged: boolean } }>(
page,
"/api/auth/me/notification-prefs",
);
expect(resolved.data?.medication.clientManaged).toBe(true);
const before = await attemptCount(page);
await runReminderSweep(page, accountId);
const attempts = await attemptsSince(page, before, 2);
// Suppressed BEFORE the sender: the reason is the gate's, not the
// transport's. Same instance, same missing credentials as the previous
// test — only the preference moved, and the ledger says so.
expect(attemptFor(attempts, "APNS")).toMatchObject({
result: "skipped",
reason: "client_managed",
});
// "The APNs leg only" is the published contract, so email still has to
// deliver. An opt-in that silenced the whole dispatch would pass every
// APNs assertion above and be the bug.
expect(attemptFor(attempts, "EMAIL")).toMatchObject({
eventType: "MEDICATION_REMINDER",
result: "ok",
reason: null,
});
expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]);
});
/**
* The web reflection of the opt-in, and the reason it is a test rather than a
* screenshot: the section reads `notificationPrefs.medication.clientManaged`
* off `/api/auth/me`, and that payload did not publish it until this branch.
* The flag was `undefined` for every account, so the chip never rendered and
* a person whose phone owned the reminders was shown a server-side switch
* that decided nothing. Both halves are asserted — the chip appears AND the
* switch row is gone — because either one alone passes on the wrong render.
*/
test("the client-managed opt-in is visible on the medication it silences", async ({
page,
}) => {
await configureChannels(page);
await pinAccountClockToMidday(page);
const medicationId = await createOverdueMedication(page);
const patched = await fromPage(page, "/api/auth/me/notification-prefs", {
method: "PATCH",
body: { medication: { clientManaged: true } },
});
expect(patched.status).toBe(200);
await page.goto(`/medications/${medicationId}`, {
waitUntil: "domcontentloaded",
});
await page.locator('[data-slot="medication-tab-zeitplan"]').click();
await expect(
page.locator('[data-slot="notifications-client-managed-chip"]'),
).toBeVisible({ timeout: 30_000 });
await expect(
page.locator('[data-slot="notifications-switch-row"]'),
).toHaveCount(0);
});
test("a relay URL inside a private range is refused and delivers nothing", async ({
page,
}) => {
await configureChannels(page);
const before = await attemptCount(page);
await fillField(page, "#ntfy-server", PRIVATE_NTFY_SERVER);
expect(
await saveCard(page, "#ntfy", "/api/settings/ntfy"),
"the save refused the private host",
).toBe(422);
// A refusal that still wrote something would be worse than no refusal:
// the stored relay is the one the account saved before.
const stored = await fromPage<{ serverUrl: string; enabled: boolean }>(
page,
"/api/settings/ntfy",
);
expect(stored.status).toBe(200);
expect(stored.data).toMatchObject({
serverUrl: NTFY_SERVER,
enabled: false,
});
// And nothing was dispatched on the way to being refused.
expect(await attemptCount(page)).toBe(before);
expect(smtp.accepted()).toEqual([]);
});
});
+222
View File
@@ -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,
}));
});
}
+341 -4
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.
*
@@ -93,6 +126,56 @@ export const E2E_GUARDIAN = {
role: "USER",
} as const;
/**
* The medication-adherence journey's own account.
*
* The batched compliance read is capped at thirty calls a minute per ACTOR,
* and the journey spends that allowance twice over: it writes doses and then
* reads the resulting rates back off the same cabinet. On the shared account
* the allowance is also being spent by whichever sibling happens to be
* mounting `/medications` in the other worker, and the cabinet itself is
* shared with them — so the journey's `beforeEach` clear would take rows
* somebody else seeded with it. One account of its own makes the rate-limit
* bucket, the cabinet and the dashboard tile's denominator private at once.
*/
export const E2E_MEDICATION = {
email: "e2e-medication@healthlog.test",
username: "e2e-medication",
password: "Hj6!Tw2pV9sQ4nDx",
role: "USER",
} as const;
/**
* The notification-dispatch journey's own account.
*
* It has one, rather than borrowing the shared fixture, because the journey's
* verdicts are counts over ONE account's `push_attempts` ledger — "one email
* attempt, no ntfy attempt, the APNs arm skipped and for which reason".
*
* The direction that matters is the one that is easy to state backwards. The
* exposure is not mainly that another spec writes into this account's window;
* it is that this journey's own trigger — the admin reminder sweep — dispatches
* for whatever accounts it is pointed at, and pointed at the instance it
* reaches all of them. A dedicated account gives the sweep something to name,
* so the run stays a writer for exactly one ledger and a reader of exactly the
* same one.
*
* ADMIN because the two surfaces the journey reads the dispatch decision
* through — the reminder trigger and the notification diagnostic — are
* `requireAdmin()`, which is cookie-only by construction.
*/
export const E2E_NOTIFY = {
email: "e2e-notify@healthlog.test",
username: "e2e-notify",
password: "Xd6!Ct3pW9qBz5Fh",
role: "ADMIN",
} as const;
export const NOTIFY_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateNotify.json",
);
/** A separate delegate session for the eight scoped-record browser journeys. */
export const E2E_SCOPE_DELEGATE = {
email: "e2e-scope-delegate@healthlog.test",
@@ -101,6 +184,31 @@ export const E2E_SCOPE_DELEGATE = {
role: "USER",
} as const;
/**
* The doctor-report journey's own account.
*
* Generating a report WRITES: the route remembers the scope and the practice
* name on the account row, and the panel a later visit opens is therefore the
* "repeat run" state with the picker collapsed. `settings-export.spec.ts`
* asserts the opposite — an expanded picker, an empty summary, a disabled
* button — against the shared account, so a journey that generated there would
* turn nine of its assertions red the moment the two files happened to run in
* the wrong order, and keep them red on every later run against the same
* database.
*
* Its own account also keeps the seeded readings, the emergency profile and
* the hourly export bucket off the account sixty other specs read.
*
* `date_format` is pinned rather than left on AUTO: the spec reads the period
* line out of the rendered PDF and has to know which field is the month.
*/
export const E2E_REPORT_OWNER = {
email: "e2e-report-owner@healthlog.test",
username: "e2e-report-owner",
password: "Hd6!Tw3pV9nQ2xLb",
role: "USER",
} as const;
/** One non-personal target record per closed sharing domain. */
export const E2E_SCOPE_RECORDS = [
{
@@ -209,6 +317,12 @@ export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateScopeCapabilities.json",
);
/** The adherence journey's jar — see `E2E_MEDICATION` for why it is its own. */
export const MEDICATION_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateMedication.json",
);
/**
* v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at
* `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as
@@ -250,6 +364,43 @@ 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
* report refuses a READ-level grant, and two specs driving one row from two
* workers would test their interference rather than the refusal.
*/
export const REPORT_DELEGATE_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateReportDelegate.json",
);
/**
* The doctor-report journey's jar. Its own account, so what generating a report
* stamps on the account row cannot reach the shared one — see `E2E_REPORT_OWNER`.
*/
export const REPORT_OWNER_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateReportOwner.json",
);
/**
* The banner-geometry spec's jar, and the reason it exists is a release-shaped
* one rather than a load-shaped one.
@@ -357,7 +508,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
insights_cached_text = NULL,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json`,
module_preferences_json = EXCLUDED.module_preferences_json,
report_selection_json = NULL,
last_report_practice_name = NULL`,
[
cuid(),
E2E_USER.username,
@@ -408,6 +561,93 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[E2E_GUARDIAN.username],
);
// The adherence journey's account. Modules are pinned rather than left to
// the defaults: the dashboard's medication tile is one of the surfaces the
// journey reads, and it only paints while the module is on.
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, 'USER', $5, $5, $5, true, $6::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
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`,
[
cuid(),
E2E_MEDICATION.username,
E2E_MEDICATION.email,
await hashPassword(E2E_MEDICATION.password),
now,
JSON.stringify({ medications: true }),
],
);
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
// journey has to be re-runnable and `--repeat-each` is exactly the case a
// once-per-suite reset does not cover.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, $5, $6, $6, $6, true)
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`,
[
cuid(),
E2E_NOTIFY.username,
E2E_NOTIFY.email,
await hashPassword(E2E_NOTIFY.password),
E2E_NOTIFY.role,
now,
],
);
// The doctor-report journey's account. Seeded like the shared one — a
// record with a height, a date of birth and a gender, so the report has a
// patient header to print — and with the two report columns cleared, which
// is what puts the panel back into its first-run state on every run.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
height_cm, date_of_birth, gender, date_format,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', $5, $5,
180, $6, 'MALE', 'MDY',
$5, true)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
height_cm = EXCLUDED.height_cm,
date_of_birth = EXCLUDED.date_of_birth,
gender = EXCLUDED.gender,
date_format = EXCLUDED.date_format,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
report_selection_json = NULL,
last_report_practice_name = NULL`,
[
cuid(),
E2E_REPORT_OWNER.username,
E2E_REPORT_OWNER.email,
await hashPassword(E2E_REPORT_OWNER.password),
now,
dob,
],
);
// The scoped-record journey uses a dedicated delegate so its preseeded
// grants cannot affect the invitation lifecycle exercised by the sharing
// journey above. Its own module preferences are explicitly off: a target
@@ -518,7 +758,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
managed_profile_at = EXCLUDED.managed_profile_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json`,
module_preferences_json = EXCLUDED.module_preferences_json,
report_selection_json = NULL,
last_report_practice_name = NULL`,
[
cuid(),
record.username,
@@ -578,6 +820,20 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[E2E_USER.username],
);
// The export bucket, for the same reason and with the same shape. Every
// export route shares one 10-per-hour bucket keyed on the ACTOR, the
// counter outlives a run by a full hour, and the doctor-report journey
// spends two of it per pass — so a fourth run inside the hour would meet a
// 429 and report a missing artefact rather than a spent quota. Only the two
// accounts that generate a report are cleared.
await pool.query(
`DELETE FROM rate_limits
WHERE key IN (
SELECT 'export:' || id::text FROM users WHERE username = ANY($1)
)`,
[[E2E_REPORT_OWNER.username, E2E_SCOPE_DELEGATE.username]],
);
// Opt the seed account into cycle tracking. The user is seeded as
// `MALE`, and v1.18.0 resolves the `cycle` module through
// `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled`
@@ -647,7 +903,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 SEVENTEEN 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 +963,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 +1047,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 seventeen 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.
@@ -753,6 +1073,14 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// for what happens when the journey switches the shared row instead.
await capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH);
// The adherence journey's jar. Its own account, so nothing it writes and
// nothing it clears is visible to the shared one.
await capture(E2E_MEDICATION, MEDICATION_STORAGE_STATE_PATH);
// The notification-dispatch journey's jar. Its own account, so its own
// login — see `E2E_NOTIFY` for why the ledger counts need one.
await capture(E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
@@ -765,6 +1093,15 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, REPORT_DELEGATE_STORAGE_STATE_PATH);
// The doctor-report journey's own account, for the reason written on
// 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.
//
+130
View File
@@ -0,0 +1,130 @@
/**
* Account hygiene and row-ageing for the medication-adherence journey.
*
* `medication-compliance-journey.spec.ts` asserts RATES — a percentage over
* one account's own doses — and the dashboard tile sums the expected doses of
* every active medication that account holds. Both are counts, so the spec has
* to own the rows it counts against: a medication left by an earlier run or a
* Playwright retry would sit in the denominator of every verdict. Everything
* here is keyed on `E2E_MEDICATION`, the journey's own account, so neither the
* clearing nor the ageing can be felt by a spec running in the other worker.
*
* The ageing is the other half. Compliance is reconstructed over
* `[max(medication.createdAt, now − window), now]` — a medication created a
* second ago has expected no dose yet, so its rate is the empty-set 100 % and
* no take can move it. A journey that wants to watch a number change needs a
* medication that has been in the cabinet for a few days, and the only honest
* way to have one inside a test is to age the row.
*/
import pg from "pg";
import { E2E_MEDICATION } from "./global-setup";
function pool(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[medication-fixture] DATABASE_URL is not set");
return new pg.Pool({ connectionString: url });
}
async function getUserId(db: pg.Pool): Promise<string> {
const res = await db.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[E2E_MEDICATION.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
"[medication-fixture] medication account not seeded — global-setup must run first",
);
}
return id;
}
/**
* Clear the journey account's cabinet — through the app, not around it.
*
* The rows are enumerated in SQL because the list route serves a memoised
* shape and a row that cache has not caught up with would hide from it. Every
* removal then goes out as the caller's own `DELETE /api/medications/{id}`,
* which is the only path that evicts the three 15-minute per-user cells
* medication state lives in (`caches.medications`,
* `caches.medicationCompliance`, `caches.medicationsIntake`). A raw
* `DELETE FROM medications` reaches none of them, and the next read would
* answer out of a cache still naming rows that no longer exist — which is
* exactly the read this helper exists to make trustworthy. Schedules, schedule
* revisions, pause eras and intake events cascade off the row either way.
*
* Safe to call from every test's `beforeEach`.
*/
export async function resetMedications(
deleteMedication: (id: string) => Promise<void>,
): Promise<void> {
const db = pool();
let ids: string[];
try {
const userId = await getUserId(db);
const res = await db.query<{ id: string }>(
"SELECT id FROM medications WHERE user_id = $1",
[userId],
);
ids = res.rows.map((row) => row.id);
} finally {
await db.end();
}
for (const id of ids) await deleteMedication(id);
}
/** The two stamps {@link ageMedication} writes onto the row. */
export interface MedicationAgeStamps {
/** The instant the row should claim as its creation, as an ISO string. */
createdAt: string;
/** The `YYYY-MM-DD` the course should claim as its start. */
startsOn: string;
}
/**
* Move a medication's creation stamp and its course start into the past, so
* the compliance engine expands the schedule over those days and the doses
* nobody logged read as missed.
*
* Both stamps come from the caller rather than from the database clock, and
* that is deliberate: `CURRENT_DATE` resolves in the database session's zone,
* which on CI is the Postgres container's UTC, while the plan dates the spec
* sends are computed in the browser's pinned `Europe/Berlin`. Between midnight
* and 02:00 Berlin the two name different calendar days, and the row would
* quietly start one day earlier than its caller asked for. `starts_on` is
* OVERWRITTEN, not adjusted — whatever the create call sent is replaced by
* what is passed here.
*
* The row is edited directly, which no cache invalidation can see: the
* per-medication compliance payload and the dashboard's daily buckets are
* both memoised server-side for 15 minutes, and the detail page the create
* flow lands on has already warmed them. The caller therefore passes a
* writer — one ordinary API write against the medication — and this helper
* uses it to flush those cells through the app's own invalidation path.
*/
export async function ageMedication(
medicationId: string,
stamps: MedicationAgeStamps,
flushCaches: () => Promise<void>,
): Promise<void> {
const db = pool();
try {
const userId = await getUserId(db);
const res = await db.query(
`UPDATE medications
SET created_at = $1::timestamptz,
starts_on = $2::date
WHERE id = $3 AND user_id = $4`,
[stamps.createdAt, stamps.startsOn, medicationId, userId],
);
if (res.rowCount !== 1) {
throw new Error(
`[medication-fixture] expected to age exactly one medication, updated ${res.rowCount}`,
);
}
} finally {
await db.end();
}
await flushCaches();
}
+268
View File
@@ -0,0 +1,268 @@
/**
* The second-factor journey's account, and nothing else.
*
* ## Why a throwaway account
*
* Enrolling TOTP changes how an account logs in for every spec that shares it:
* `/api/auth/login` stops minting a session and answers with a challenge
* instead. `global-setup.ts` already learned that once — it has to log the
* guardian in BEFORE stamping its factor, because the password-only capture
* cannot complete a TOTP login. So this journey, which enrols a factor from a
* clean slate and then signs in through it four times, owns an account nobody
* else touches and re-seeds it from scratch on every run.
*
* The username carries the Playwright PROJECT name. Both browser projects run
* this file, `fullyParallel` is on, and the journey moves one account's factor
* state, its `totpLastStep` replay floor, and its recovery codes. Two projects
* driving one account would be testing which browser got there first. A second
* account costs one INSERT and removes the question.
*
* ## Why Postgres rather than the product
*
* Two of the things this journey needs have no endpoint, by design:
*
* - an EXPIRED step-up. `requireFreshMfa` reads `Session.mfaVerifiedAt` and
* compares it against a five-minute window. Nothing clears that stamp on
* request, and waiting five minutes inside a 30-second test is not a test.
* Ageing the stamp is not a bypass and does not weaken the route: the route
* still runs its gate, and what the fixture simulates is time passing.
* - the anonymous auth buckets. `/api/auth/login` allows five attempts per IP
* per fifteen minutes and `/api/auth/mfa/verify` ten; this journey spends
* four and five of them, and every other spec on the machine shares the
* same localhost bucket. `global-setup.ts` clears the same keys between its
* own logins for the same reason.
*
* Seeded through `pg`, the transport `global-setup.ts`, `vault-fixture.ts` and
* `managed-profile-fixture.ts` all use.
*/
import { hash } from "@node-rs/argon2";
import pg from "pg";
export interface MfaJourneyAccount {
username: string;
email: string;
password: string;
}
/** One account per Playwright project — see the file header. */
export function mfaJourneyAccount(projectName: string): MfaJourneyAccount {
const slug = projectName.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
return {
username: `e2e-mfa-${slug}`,
email: `e2e-mfa-${slug}@healthlog.test`,
// 16 chars, same shape as the fixture passwords in `global-setup.ts`.
password: "Hw6!Tq2pB9nZ4vLd",
};
}
function connect(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("[mfa-journey-fixture] DATABASE_URL is not set");
}
return new pg.Pool({ connectionString: url });
}
/** Prisma's `cuid()` shape, close enough for a hand-written row. */
function cuid(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "c";
for (let i = 0; i < 24; i += 1) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
/** Argon2id at the app's own parameters (`src/lib/auth/argon2-params.mjs`). */
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
}
/**
* Drop whatever a previous run left and insert the account fresh.
*
* DELETE rather than upsert: the journey's whole subject is the transition from
* no second factor to one, and an account carrying a confirmed secret, a
* `totpLastStep` replay floor, or a half-spent recovery batch from an earlier
* run would start it in the middle. The row cascades to its sessions, its
* recovery codes and its MFA challenges, so one statement clears all of it.
*
* `onboarding_completed_at` and `onboarding_tour_completed` are stamped for the
* reason `global-setup.ts` documents: without them the proxy bounces the new
* session to `/onboarding` and the tour overlay swallows every click.
*/
export async function seedMfaAccount(
account: MfaJourneyAccount,
): Promise<void> {
const pool = connect();
try {
await pool.query("DELETE FROM users WHERE username = $1", [
account.username,
]);
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)`,
[
cuid(),
account.username,
account.email,
await hashPassword(account.password),
new Date(),
],
);
} finally {
await pool.end();
}
}
/** Remove the account and everything that cascades off it. */
export async function dropMfaAccount(
account: MfaJourneyAccount,
): Promise<void> {
const pool = connect();
try {
await pool.query("DELETE FROM users WHERE username = $1", [
account.username,
]);
} finally {
await pool.end();
}
}
/**
* Empty the two anonymous auth buckets this journey spends.
*
* Both keys are `${prefix}:${ip}` and every spec on the machine is the same IP,
* so this is the clear `global-setup.ts` runs between its own logins — narrowed
* to the surfaces the journey actually touches. The wider `auth:%` it used to
* run also swept register, passkey and reset buckets, which this file has no
* business emptying: a future spec measuring one of those throttles would find
* its evidence deleted by an unrelated sign-in and never know.
*
* Call it before each sign-in; the product's 429 is a real answer, but it is
* not the answer the journey's stages are about — the closing stage asks for it
* deliberately, and clears afterwards.
*/
export async function clearAuthRateLimits(): Promise<void> {
const pool = connect();
try {
await pool.query(
`DELETE FROM rate_limits
WHERE key LIKE 'auth:login:%' OR key LIKE 'auth:mfa-verify:%'`,
);
} finally {
await pool.end();
}
}
/**
* The attempt counter on the account's most recent login challenge.
*
* `/api/auth/mfa/verify` answers a wrong factor with the same generic 401 it
* gives an unknown ticket, so the throttle it applies is invisible from the
* wire. The counter is the only place the refusal is recorded, and a refusal
* the server does not count is a refusal an attacker can repeat for free — so
* the journey reads it rather than taking the 401 as proof on its own.
*/
export async function latestChallengeAttempts(
account: MfaJourneyAccount,
): Promise<number> {
const pool = connect();
try {
const res = await pool.query<{ attempts: number }>(
`SELECT attempts FROM mfa_challenges
WHERE user_id = (SELECT id FROM users WHERE username = $1)
ORDER BY created_at DESC
LIMIT 1`,
[account.username],
);
const row = res.rows[0];
if (!row) {
throw new Error(
`[mfa-journey-fixture] no login challenge for ${account.username}`,
);
}
return row.attempts;
} finally {
await pool.end();
}
}
/**
* The server's own verdict on the account's most recent refused factor.
*
* `/api/auth/mfa/verify` answers every refusal with the same 401, so the wire
* cannot tell a replayed code from a wrong one — and that distinction is the
* whole subject of the replay control. The route does record it: it writes
* `auth.mfa.failed` with `details.replay` and AWAITS that write before
* responding, so the row is on disk by the time the response lands.
*
* Read it rather than inferring it. A replayed code that has drifted out of its
* ±1-step window is refused as out-of-window — same 401, `replay: false` — and
* a control that only reads the status code cannot tell the two apart, which
* means it would stay green with the replay floor deleted.
*/
export async function latestMfaFailure(
account: MfaJourneyAccount,
): Promise<{ replay: boolean }> {
const pool = connect();
try {
const res = await pool.query<{ details: string | null }>(
`SELECT details FROM audit_logs
WHERE user_id = (SELECT id FROM users WHERE username = $1)
AND action = 'auth.mfa.failed'
ORDER BY created_at DESC
LIMIT 1`,
[account.username],
);
const row = res.rows[0];
if (!row?.details) {
throw new Error(
`[mfa-journey-fixture] no refused factor recorded for ${account.username}`,
);
}
const details = JSON.parse(row.details) as { replay?: unknown };
if (typeof details.replay !== "boolean") {
throw new Error(
`[mfa-journey-fixture] refusal for ${account.username} carries no replay verdict`,
);
}
return { replay: details.replay };
} finally {
await pool.end();
}
}
/**
* Age the session's step-up stamp past the five-minute window.
*
* A zero-row update means the jar the journey is driving points at no session
* at all, and the refusal that follows would have nothing to do with freshness.
* Fail loudly here instead, the way `managed-profile-fixture.ts` does.
*/
export async function expireStepUp(account: MfaJourneyAccount): Promise<void> {
const pool = connect();
try {
const aged = await pool.query(
`UPDATE sessions SET mfa_verified_at = NOW() - INTERVAL '10 minutes'
WHERE user_id = (SELECT id FROM users WHERE username = $1)
AND mfa_verified_at IS NOT NULL`,
[account.username],
);
if ((aged.rowCount ?? 0) === 0) {
throw new Error(
`[mfa-journey-fixture] no step-up-stamped session for ${account.username}`,
);
}
} finally {
await pool.end();
}
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Account hygiene for the notification-dispatch journey.
*
* Every verdict in that journey is a COUNT over one account's delivery ledger
* inside a window the test opens itself — one email attempt, no ntfy attempt,
* one APNs skip carrying a named reason. A channel row, a paired device, a
* medication or a preference blob left behind by an earlier run would put a
* second dispatch decision inside that window, and the count would be reading
* the previous run rather than this one. `--repeat-each` is the case that
* makes it certain rather than likely.
*
* So the account is put back to "nothing configured" before every test:
* no channels, no devices, no ledger, no medications, default preferences and
* the default timezone. Deletes rather than disables — a disabled channel row
* is itself one of the journey's subjects, so leaving one behind would seed
* the answer to a question the test asks.
*
* Reaching for Postgres directly is the channel `global-setup.ts` already
* uses to seed and to clear buckets, and every statement here is parameter
* bound on the account's own id.
*/
import pg from "pg";
import { E2E_NOTIFY } from "./global-setup";
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[notification-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
return await fn(pool);
} finally {
await pool.end();
}
}
/** The notification account's id, or a loud failure if the seed never ran. */
export async function notificationAccountId(): Promise<string> {
return withPool(async (pool) => {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[E2E_NOTIFY.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
"[notification-fixture] e2e-notify is not seeded — global-setup must run first",
);
}
return id;
});
}
/**
* Put the notification account back to a freshly-registered state.
*
* The rate-limit buckets go too. The card's own test control allows five
* probes per five minutes and device registration twenty per quarter-hour —
* production numbers, and both below what three repeats of this journey
* spend. A run that ended on the fixture's 429 would say nothing about the
* dispatcher.
*/
export async function resetNotificationFixture(): Promise<void> {
await withPool(async (pool) => {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[E2E_NOTIFY.username],
);
const userId = res.rows[0]?.id;
if (!userId) {
throw new Error(
"[notification-fixture] e2e-notify is not seeded — global-setup must run first",
);
}
await pool.query("DELETE FROM notification_channels WHERE user_id = $1", [
userId,
]);
await pool.query("DELETE FROM devices WHERE user_id = $1", [userId]);
// `user_id` and `recipient_user_id`, and deliberately NOT
// `record_user_id`: that column is the health-record SUBJECT, so a
// delivery ABOUT this account sent TO a guardian carries it while the
// recipient is somebody else. Deleting on it would take a row out of
// another account's ledger. The diagnostic this journey reads filters on
// the recipient, so the two agree.
await pool.query(
`DELETE FROM push_attempts
WHERE user_id = $1 OR recipient_user_id = $1`,
[userId],
);
// The medication delete cascades to its intake events, but a stale event
// is what makes the sweep decide a dose was already taken and skip its
// dispatch, so the statement is written out rather than inherited from a
// foreign key a future fixture might stop relying on.
await pool.query(
"DELETE FROM medication_intake_events WHERE user_id = $1",
[userId],
);
await pool.query("DELETE FROM medications WHERE user_id = $1", [userId]);
await pool.query(
`UPDATE users
SET notification_prefs = NULL,
timezone = DEFAULT
WHERE id = $1`,
[userId],
);
await pool.query("DELETE FROM rate_limits WHERE key = ANY($1)", [
[
`email-test:${userId}`,
`ntfy-test:${userId}`,
`devices:register:${userId}`,
`notification-prefs:patch:${userId}`,
],
]);
});
}
+43
View File
@@ -0,0 +1,43 @@
/**
* The third E2E server: the one configured for mail, and the one with no
* scheduler.
*
* Two things the notification-dispatch journey needs are wrong for every other
* spec, so it gets its own process rather than bending the shared one.
*
* **Mail.** `SMTP_HOST` / `SMTP_PORT` / `SMTP_FROM` are operator config, and
* `isEmailConfigured()` is what decides whether the Email card renders at all
* (`src/components/settings/email-card.tsx`). Putting them on the shared server
* would put that card on `/settings/integrations` for every spec that opens the
* route — the axe scan and the card-anatomy geometry sweep included — and make
* this branch responsible for a surface it does not touch. Here, the shipped
* "no SMTP, no card" state stays what every other spec observes.
*
* **No scheduler.** Unset, `HEALTHLOG_PROCESS_TYPE` reads "all" and
* `src/instrumentation.ts` starts the pg-boss reminder worker inside the
* process serving requests, which schedules `medication-reminder-check` every
* fifteen minutes. The journey keeps a medication overdue for the length of a
* test, and a tick inside that window either mints the missed-dose row that
* makes the sweep skip its own dispatch or dispatches beside it and adds a
* ledger row the count was not expecting. `web` is the shipped, documented
* value for HTTP-without-scheduler (docs/self-hosting/scaling.md); the pg-boss
* PRODUCER still starts, so a route that enqueues work keeps working, and the
* journey's own trigger is a route rather than a job.
*
* The shared server keeps "all" deliberately, and the reason is measured
* rather than assumed: `getWorkerStatus()` is per-process in-memory, so a `web`
* process reports the worker stopped, `/admin` renders that row
* `text-destructive`, and axe fails it at 3.97:1 against the muted tile. That
* is a real contrast defect, visible to any operator running the documented
* web/worker split, and it is not this branch's to fix. What it means here is
* that the scheduler still exists on the suite's shared process: it is off in
* the process the journey drives, not off in the run.
*
* With `E2E_SKIP_WEB_SERVER=1` no server is started for you and all three have
* to be running — the same contract the other two already carry.
*/
export const NOTIFICATION_PORT = 3200;
export const NOTIFICATION_BASE_URL =
process.env.E2E_NOTIFICATION_BASE_URL ??
`http://localhost:${NOTIFICATION_PORT}`;
+184
View File
@@ -0,0 +1,184 @@
/**
* A local SMTP responder, and the reason the notification journey needs one.
*
* Every user-configurable DELIVERY channel in this product refuses a local
* destination on purpose. ntfy and the generic webhook take their URL from the
* account, so the URL passes `isPublicUrl()` at save time and the send goes
* through `safeFetch({ requirePublicHost: true })`, which pins the resolved
* address at connect time; loopback, RFC1918 and every alternate notation are
* refused at both ends. Web Push is the same shape one layer down — the
* subscription endpoint is checked before the dial. Telegram and APNs address
* hard-coded third parties. So none of them can be pointed at a stub, and
* faking one would prove the fixture rather than the dispatcher.
*
* Email is the exception, and not by oversight: its transport is OPERATOR
* configuration (`SMTP_HOST` / `SMTP_PORT` / `SMTP_FROM`), never account
* input, so it never crosses the SSRF floor that guards the account-supplied
* hosts. That makes it the one channel a self-hoster — and this suite — can
* exercise end to end on one machine, through the real sender, the real
* transport and the real `push_attempts` ledger write.
*
* The responder below speaks the minimum of RFC 5321 that nodemailer needs:
* a greeting, EHLO with no advertised extensions (so nodemailer neither
* pipelines nor tries STARTTLS), the envelope commands, and DATA. It stores
* nothing but the envelope recipients, which is all the journey reads back.
*/
import { createServer, type Server, type Socket } from "node:net";
/**
* Port the stub listens on, and the port `playwright.config.ts` hands the app
* server as `SMTP_PORT`.
*
* It cannot be an OS-assigned port-0 binding: the config has to put `SMTP_PORT`
* into the web server's environment before any worker process exists, and the
* stub itself does not start until the spec's `beforeAll` — so by the time a
* kernel-assigned port would be known, the server it has to reach is already
* running. The port is therefore CHOSEN once per run, at config load, from the
* IANA dynamic range, and published through `SMTP_STUB_PORT` in the
* environment so the config, the server and the spec all read the one value.
* Two suites on one machine draw different numbers instead of colliding on a
* constant, and a collision that does happen fails by name in `listen` below
* rather than as a missing form field thirty lines into a test.
*
* An operator pinning it by hand (`SMTP_STUB_PORT=3925 pnpm e2e`) wins, which
* is what makes an externally started server — `E2E_SKIP_WEB_SERVER=1` — able
* to carry the same value.
*/
export function resolveSmtpStubPort(): number {
const pinned = Number(process.env.SMTP_STUB_PORT);
if (Number.isInteger(pinned) && pinned > 0 && pinned < 65_536) return pinned;
// 49152–65535, the dynamic/private range no registered service claims.
const chosen = 49_152 + Math.floor(Math.random() * (65_536 - 49_152));
process.env.SMTP_STUB_PORT = String(chosen);
return chosen;
}
export const SMTP_STUB_HOST = "127.0.0.1";
/** The envelope sender the app is configured with (`SMTP_FROM`). */
export const SMTP_STUB_FROM = "healthlog-e2e@healthlog.test";
export interface SmtpStub {
/** The port this responder bound, which is the one the app was handed. */
readonly port: number;
/** Envelope recipients of every message the stub accepted, oldest first. */
accepted(): readonly string[];
/** Forget everything accepted so far. */
reset(): void;
close(): Promise<void>;
}
const CRLF = "\r\n";
/** Start the responder and resolve once it is accepting connections. */
export async function startSmtpStub(): Promise<SmtpStub> {
const accepted: string[] = [];
const port = resolveSmtpStubPort();
// Every established socket, so `close()` can end them. `server.close()`
// resolves only once the last connection is gone and never ends one itself,
// so a socket abandoned mid-DATA by a timed-out test would otherwise hold
// teardown open until Playwright's own timeout fires over it.
const open = new Set<Socket>();
const server: Server = createServer((socket: Socket) => {
open.add(socket);
socket.on("close", () => open.delete(socket));
let buffer = "";
let inData = false;
let envelope: string[] = [];
const say = (line: string): void => {
socket.write(`${line}${CRLF}`);
};
socket.setEncoding("utf8");
say("220 healthlog-e2e-smtp ready");
socket.on("data", (chunk: string) => {
buffer += chunk;
let index = buffer.indexOf(CRLF);
while (index !== -1) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + CRLF.length);
if (inData) {
// A bare "." on its own line ends the message body.
if (line === ".") {
inData = false;
accepted.push(...envelope);
envelope = [];
say("250 2.0.0 Ok: queued");
}
} else {
const verb = line.slice(0, 4).toUpperCase();
if (verb === "EHLO" || verb === "HELO") {
// Single-line, no extensions: nodemailer then sends one command
// at a time and never attempts STARTTLS or AUTH.
say("250 healthlog-e2e-smtp");
} else if (verb === "MAIL") {
say("250 2.1.0 Ok");
} else if (verb === "RCPT") {
const match = /<([^>]*)>/.exec(line);
if (match) envelope.push(match[1]);
say("250 2.1.5 Ok");
} else if (verb === "DATA") {
inData = true;
say("354 End data with <CR><LF>.<CR><LF>");
} else if (verb === "QUIT") {
say("221 2.0.0 Bye");
socket.end();
} else if (verb === "RSET") {
envelope = [];
say("250 2.0.0 Ok");
} else {
say("250 2.0.0 Ok");
}
}
index = buffer.indexOf(CRLF);
}
});
// A client that vanishes mid-exchange is not this stub's problem, and an
// unhandled 'error' on a socket would take the whole runner down.
socket.on("error", () => socket.destroy());
});
await new Promise<void>((resolve, reject) => {
server.once("error", (err: NodeJS.ErrnoException) => {
// The one failure worth naming. A crashed prior run, a second suite, or
// a hand-pinned port already in use all arrive here, and the bare
// `EADDRINUSE` that used to propagate took the whole `beforeAll` down
// with a message that named neither the port nor the stub.
reject(
err.code === "EADDRINUSE"
? new Error(
`[smtp-stub] port ${port} on ${SMTP_STUB_HOST} is already in use — ` +
"another run's responder is still listening, or SMTP_STUB_PORT " +
"was pinned to a port something else holds. Free it, or unset " +
"SMTP_STUB_PORT to draw a fresh one.",
{ cause: err },
)
: err,
);
});
server.listen(port, SMTP_STUB_HOST, () => {
server.removeAllListeners("error");
resolve();
});
});
return {
port,
accepted: () => accepted,
reset: () => {
accepted.length = 0;
},
close: () =>
new Promise<void>((resolve) => {
for (const socket of open) socket.destroy();
open.clear();
server.close(() => resolve());
}),
};
}
+2
View File
@@ -25,5 +25,7 @@ export {
FENCE_STORAGE_STATE_PATH,
FENCE_OFFLINE_STORAGE_STATE_PATH,
CROSS_TAB_STORAGE_STATE_PATH,
REPORT_DELEGATE_STORAGE_STATE_PATH,
REPORT_OWNER_STORAGE_STATE_PATH,
GUARDIAN_STORAGE_STATE_PATH,
} from "./global-setup";
+30 -3
View File
@@ -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…",
@@ -8693,6 +8695,20 @@
"description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung."
}
},
"moduleOff": {
"disabled": {
"description": "{module} ist für diese Akte ausgeschaltet, deshalb bleiben die Seiten verborgen. Schalte das Modul wieder ein, um sie zurückzuholen.",
"title": "{module} ist ausgeschaltet"
},
"notGranted": {
"description": "{module} liegt außerhalb der Bereiche, die mit dir geteilt wurden. Was ein geteilter Zugriff umfasst, legt die Person fest, der die Akte gehört.",
"title": "Gehört nicht zum geteilten Zugriff"
},
"unavailable": {
"description": "Der Betreiber hat {module} für alle Konten auf diesem Server ausgeschaltet. Keine eigene Einstellung holt es zurück.",
"title": "Auf diesem Server nicht verfügbar"
}
},
"vaccinations": {
"title": "Impfungen",
"subtitle": "Dein Impfpass, Dosis für Dosis.",
@@ -9931,7 +9947,6 @@
"page": {
"title": "Nährstoffaufnahme",
"description": "Flüssigkeits- und Vitamin-/Mineralstoffaufnahme aus der Gesundheits-App deines Telefons, plus manuelle Wassereinträge.",
"moduleOffTitle": "Nährstoffaufnahme ist aus",
"moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.",
"moduleOffCta": "Nährstoffe aktivieren",
"emptyTitle": "Noch keine Nährstoffdaten",
@@ -10280,6 +10295,12 @@
"dobHint": "Lass das Feld leer, wenn du keines hinterlegen willst. Aus einem Jahrgang wird nichts abgeleitet.",
"localeLabel": "Sprache dieser Akte",
"localeHint": "Die Sprache für Texte, die in diese Akte geschrieben werden. Deine eigenen Ansichten bleiben in deiner Sprache.",
"genderLabel": "Geschlecht (optional)",
"genderHint": "Entscheidet, ob die Zyklus-Funktion für diese Akte zunächst an ist. Jederzeit änderbar.",
"genderNone": "Keine Angabe",
"genderMale": "Männlich",
"genderFemale": "Weiblich",
"genderOther": "Divers",
"timezoneHint": "Die Zeitzone, in der die Tage dieser Akte gezählt werden.",
"timezoneInvalid": "Diese Zeitzone kennt der Browser nicht.",
"create": "Profil anlegen",
@@ -10289,6 +10310,12 @@
"errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.",
"errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.",
"errorFailed": "Das Profil konnte nicht angelegt werden.",
"edit": "Bearbeiten",
"editSave": "Änderungen speichern",
"editSaving": "Wird gespeichert…",
"editLoadError": "Diese Angaben konnten nicht geladen werden.",
"editErrorGone": "Diese Akte gibt es nicht mehr, oder du betreust sie nicht mehr.",
"editErrorFailed": "Die Änderungen konnten nicht gespeichert werden.",
"delete": "Profil löschen",
"deleteTitle": "{name} löschen?",
"deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil — Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.",
+30 -3
View File
@@ -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…",
@@ -8693,6 +8695,20 @@
"description": "The vaccination record, structured: dose, series, next booster."
}
},
"moduleOff": {
"disabled": {
"description": "{module} is off for this record, so its pages stay hidden. Turn it back on to bring them back.",
"title": "{module} is switched off"
},
"notGranted": {
"description": "{module} sits outside the sections shared with you. What a shared record covers is decided by the person who owns it.",
"title": "Not part of the shared access"
},
"unavailable": {
"description": "The operator switched {module} off for every account on this server. No setting of your own can bring it back.",
"title": "Not available on this server"
}
},
"vaccinations": {
"title": "Vaccinations",
"subtitle": "Your immunization record, dose by dose.",
@@ -9931,7 +9947,6 @@
"page": {
"title": "Nutrient intake",
"description": "Hydration and vitamin/mineral intake synced from your phone's health app, plus manual water entries.",
"moduleOffTitle": "Nutrient intake is off",
"moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.",
"moduleOffCta": "Turn on nutrients",
"emptyTitle": "No nutrient data yet",
@@ -10280,6 +10295,12 @@
"dobHint": "Leave it empty if you would rather not record one. Nothing is guessed from a year.",
"localeLabel": "Language of this record",
"localeHint": "The language used for text written into this record. Your own screens stay in your language.",
"genderLabel": "Gender (optional)",
"genderHint": "Decides whether cycle tracking starts on for this record. Change it any time.",
"genderNone": "Not specified",
"genderMale": "Male",
"genderFemale": "Female",
"genderOther": "Other",
"timezoneHint": "The zone this record's days are measured in.",
"timezoneInvalid": "This browser does not know that timezone.",
"create": "Create profile",
@@ -10289,6 +10310,12 @@
"errorOffline": "That did not reach the server. Nothing has changed — check your connection and try again.",
"errorInvalid": "The server refused those details. Check the name, the date and the timezone.",
"errorFailed": "The profile could not be created.",
"edit": "Edit",
"editSave": "Save changes",
"editSaving": "Saving…",
"editLoadError": "Those details could not be loaded.",
"editErrorGone": "That record is gone, or you no longer look after it.",
"editErrorFailed": "The changes could not be saved.",
"delete": "Delete profile",
"deleteTitle": "Delete {name}?",
"deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.",
+29 -2
View File
@@ -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…",
@@ -8693,6 +8695,20 @@
"description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo."
}
},
"moduleOff": {
"disabled": {
"description": "{module} está desactivado en este registro, así que sus páginas quedan ocultas. Vuelve a activarlo para recuperarlas.",
"title": "{module} está desactivado"
},
"notGranted": {
"description": "{module} queda fuera de las secciones que se han compartido contigo. Lo que abarca un registro compartido lo decide la persona propietaria.",
"title": "No forma parte del acceso compartido"
},
"unavailable": {
"description": "El operador ha desactivado {module} para todas las cuentas de este servidor. Ningún ajuste tuyo puede recuperarlo.",
"title": "No disponible en este servidor"
}
},
"vaccinations": {
"title": "Vacunas",
"subtitle": "Tu registro de vacunación, dosis a dosis.",
@@ -9931,7 +9947,6 @@
"page": {
"title": "Ingesta de nutrientes",
"description": "Hidratación e ingesta de vitaminas/minerales sincronizadas desde la app de salud de tu teléfono, más entradas manuales de agua.",
"moduleOffTitle": "La ingesta de nutrientes está desactivada",
"moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.",
"moduleOffCta": "Activar nutrientes",
"emptyTitle": "Aún no hay datos de nutrientes",
@@ -10280,6 +10295,12 @@
"dobHint": "Déjala vacía si prefieres no registrarla. No se deduce nada a partir de un año.",
"localeLabel": "Idioma de este historial",
"localeHint": "El idioma de los textos que se escriben en este historial. Tus propias pantallas siguen en tu idioma.",
"genderLabel": "Sexo (opcional)",
"genderHint": "Determina si el seguimiento del ciclo empieza activado en este registro. Se puede cambiar cuando quieras.",
"genderNone": "Sin especificar",
"genderMale": "Hombre",
"genderFemale": "Mujer",
"genderOther": "Otro",
"timezoneHint": "La zona en la que se miden los días de este historial.",
"timezoneInvalid": "Este navegador no conoce esa zona horaria.",
"create": "Crear perfil",
@@ -10289,6 +10310,12 @@
"errorOffline": "Esto no llegó al servidor. No ha cambiado nada — revisa tu conexión y vuelve a intentarlo.",
"errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.",
"errorFailed": "No se pudo crear el perfil.",
"edit": "Editar",
"editSave": "Guardar cambios",
"editSaving": "Guardando…",
"editLoadError": "No se han podido cargar esos datos.",
"editErrorGone": "Ese registro ya no existe, o ya no te ocupas de él.",
"editErrorFailed": "No se han podido guardar los cambios.",
"delete": "Eliminar perfil",
"deleteTitle": "¿Eliminar {name}?",
"deleteBody": "Todo lo registrado para {name} desaparece con el perfil — mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.",
+29 -2
View File
@@ -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…",
@@ -8693,6 +8695,20 @@
"description": "Le carnet de vaccination structuré : dose, série, prochain rappel."
}
},
"moduleOff": {
"disabled": {
"description": "{module} est désactivé pour ce dossier, ses pages restent donc masquées. Réactivez le module pour les retrouver.",
"title": "{module} est désactivé"
},
"notGranted": {
"description": "{module} se trouve en dehors des sections partagées avec vous. Ce que couvre un dossier partagé est décidé par la personne à qui il appartient.",
"title": "Ne fait pas partie de l'accès partagé"
},
"unavailable": {
"description": "L'exploitant a désactivé {module} pour tous les comptes de ce serveur. Aucun réglage de votre côté ne peut le rétablir.",
"title": "Non disponible sur ce serveur"
}
},
"vaccinations": {
"title": "Vaccinations",
"subtitle": "Votre carnet de vaccination, dose par dose.",
@@ -9931,7 +9947,6 @@
"page": {
"title": "Apports en nutriments",
"description": "Hydratation et apports en vitamines/minéraux synchronisés depuis l'appli santé de votre téléphone, plus les entrées manuelles d'eau.",
"moduleOffTitle": "Le suivi des nutriments est désactivé",
"moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.",
"moduleOffCta": "Activer les nutriments",
"emptyTitle": "Pas encore de données nutritionnelles",
@@ -10280,6 +10295,12 @@
"dobHint": "Laissez le champ vide si vous préférez ne pas en enregistrer. Rien n'est déduit d'une année.",
"localeLabel": "Langue de ce dossier",
"localeHint": "La langue des textes écrits dans ce dossier. Vos propres écrans restent dans votre langue.",
"genderLabel": "Sexe (facultatif)",
"genderHint": "Détermine si le suivi du cycle est activé au départ pour ce dossier. Modifiable à tout moment.",
"genderNone": "Non précisé",
"genderMale": "Homme",
"genderFemale": "Femme",
"genderOther": "Autre",
"timezoneHint": "Le fuseau dans lequel les journées de ce dossier sont comptées.",
"timezoneInvalid": "Ce navigateur ne connaît pas ce fuseau horaire.",
"create": "Créer le profil",
@@ -10289,6 +10310,12 @@
"errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.",
"errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.",
"errorFailed": "Le profil n'a pas pu être créé.",
"edit": "Modifier",
"editSave": "Enregistrer les modifications",
"editSaving": "Enregistrement…",
"editLoadError": "Ces informations n'ont pas pu être chargées.",
"editErrorGone": "Ce dossier n'existe plus, ou vous ne vous en occupez plus.",
"editErrorFailed": "Les modifications n'ont pas pu être enregistrées.",
"delete": "Supprimer le profil",
"deleteTitle": "Supprimer {name} ?",
"deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil — mesures, médicaments, documents et notes. Cette action est irréversible.",
+29 -2
View File
@@ -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…",
@@ -8693,6 +8695,20 @@
"description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo."
}
},
"moduleOff": {
"disabled": {
"description": "{module} è disattivato per questi dati, quindi le sue pagine restano nascoste. Riattivalo per ritrovarle.",
"title": "{module} è disattivato"
},
"notGranted": {
"description": "{module} è fuori dalle sezioni condivise con te. Cosa comprende un accesso condiviso lo decide la persona a cui appartengono i dati.",
"title": "Non fa parte dell'accesso condiviso"
},
"unavailable": {
"description": "Chi gestisce il server ha disattivato {module} per tutti gli account. Nessuna impostazione tua può riportarlo indietro.",
"title": "Non disponibile su questo server"
}
},
"vaccinations": {
"title": "Vaccinazioni",
"subtitle": "Il tuo libretto vaccinale, dose per dose.",
@@ -9931,7 +9947,6 @@
"page": {
"title": "Apporto di nutrienti",
"description": "Idratazione e apporto di vitamine/minerali sincronizzati dall'app salute del telefono, più le voci di acqua inserite manualmente.",
"moduleOffTitle": "L'apporto di nutrienti è disattivato",
"moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.",
"moduleOffCta": "Attiva nutrienti",
"emptyTitle": "Ancora nessun dato sui nutrienti",
@@ -10280,6 +10295,12 @@
"dobHint": "Lascia il campo vuoto se preferisci non registrarla. Da un anno di nascita non si deduce nulla.",
"localeLabel": "Lingua di questa cartella",
"localeHint": "La lingua dei testi scritti in questa cartella. Le tue schermate restano nella tua lingua.",
"genderLabel": "Sesso (facoltativo)",
"genderHint": "Determina se il monitoraggio del ciclo parte attivo per questa scheda. Modificabile in qualsiasi momento.",
"genderNone": "Non specificato",
"genderMale": "Uomo",
"genderFemale": "Donna",
"genderOther": "Altro",
"timezoneHint": "Il fuso in cui si contano le giornate di questa cartella.",
"timezoneInvalid": "Questo browser non conosce quel fuso orario.",
"create": "Crea profilo",
@@ -10289,6 +10310,12 @@
"errorOffline": "Non è arrivato al server. Non è cambiato nulla — controlla la connessione e riprova.",
"errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.",
"errorFailed": "Non è stato possibile creare il profilo.",
"edit": "Modifica",
"editSave": "Salva le modifiche",
"editSaving": "Salvataggio…",
"editLoadError": "Non è stato possibile caricare questi dati.",
"editErrorGone": "Questa scheda non esiste più, oppure non te ne occupi più.",
"editErrorFailed": "Non è stato possibile salvare le modifiche.",
"delete": "Elimina profilo",
"deleteTitle": "Eliminare {name}?",
"deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo — misurazioni, farmaci, documenti e note. Non si può annullare.",
+30 -3
View File
@@ -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": "내용을 읽는 중…",
@@ -8693,6 +8695,20 @@
"description": "예방접종 기록을 구조화해서: 용량, 차수, 다음 추가 접종."
}
},
"moduleOff": {
"disabled": {
"description": "이 기록에서 {module} 모듈이 꺼져 있어서 관련 페이지가 보이지 않아요. 다시 켜면 돌아와요.",
"title": "{module} 모듈이 꺼져 있어요"
},
"notGranted": {
"description": "{module} 모듈은 공유된 영역 밖에 있어요. 공유된 기록이 어디까지 포함되는지는 기록의 주인이 정해요.",
"title": "공유 범위에 없는 항목"
},
"unavailable": {
"description": "운영자가 이 서버의 모든 계정에서 {module} 모듈을 껐어요. 개인 설정으로는 되돌릴 수 없어요.",
"title": "이 서버에서는 사용할 수 없어요"
}
},
"vaccinations": {
"title": "예방접종",
"subtitle": "접종 한 건씩 남기는 예방접종 기록이에요.",
@@ -9931,7 +9947,6 @@
"page": {
"title": "영양소 섭취",
"description": "휴대폰 건강 앱에서 동기화된 수분·비타민/미네랄 섭취량과 직접 입력한 수분 기록이에요.",
"moduleOffTitle": "영양소 섭취가 꺼져 있어요",
"moduleOffDescription": "영양소 모듈을 켜면 수분과 비타민/미네랄 섭취량을 여기서 볼 수 있어요. 동기화된 섭취량은 코치가 읽는 기록에 포함돼요.",
"moduleOffCta": "영양소 켜기",
"emptyTitle": "아직 영양소 데이터가 없어요",
@@ -10280,6 +10295,12 @@
"dobHint": "적고 싶지 않으면 비워 두세요. 연도로 뭔가를 추측하지는 않아요.",
"localeLabel": "이 기록의 언어",
"localeHint": "이 기록에 적히는 글의 언어예요. 내 화면은 내 언어 그대로예요.",
"genderLabel": "성별(선택)",
"genderHint": "이 기록에서 주기 추적을 처음에 켜둘지 결정합니다. 언제든 바꿀 수 있습니다.",
"genderNone": "지정 안 함",
"genderMale": "남성",
"genderFemale": "여성",
"genderOther": "기타",
"timezoneHint": "이 기록의 하루를 재는 기준 시간대예요.",
"timezoneInvalid": "이 브라우저가 모르는 시간대예요.",
"create": "프로필 만들기",
@@ -10289,6 +10310,12 @@
"errorOffline": "서버에 닿지 못했어요. 바뀐 것은 없으니 연결을 확인하고 다시 시도해 주세요.",
"errorInvalid": "서버가 그 내용을 거절했어요. 이름과 날짜, 시간대를 확인해 주세요.",
"errorFailed": "프로필을 만들지 못했어요.",
"edit": "편집",
"editSave": "변경 사항 저장",
"editSaving": "저장 중…",
"editLoadError": "해당 정보를 불러오지 못했습니다.",
"editErrorGone": "그 기록이 없거나 더 이상 관리하지 않습니다.",
"editErrorFailed": "변경 사항을 저장하지 못했습니다.",
"delete": "프로필 삭제",
"deleteTitle": "{name} 님 프로필을 삭제할까요?",
"deleteBody": "{name} 님에 대해 기록한 모든 것이 프로필과 함께 사라져요. 측정값, 약, 문서, 메모 전부요. 되돌릴 수 없어요.",
+29 -2
View File
@@ -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…",
@@ -8693,6 +8695,20 @@
"description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie."
}
},
"moduleOff": {
"disabled": {
"description": "{module} jest wyłączone dla tego zapisu, więc jego strony pozostają ukryte. Włącz moduł ponownie, aby je odzyskać.",
"title": "{module} jest wyłączone"
},
"notGranted": {
"description": "{module} jest poza sekcjami udostępnionymi tobie. O tym, co obejmuje udostępniony zapis, decyduje osoba, do której należy.",
"title": "Nie należy do udostępnionego dostępu"
},
"unavailable": {
"description": "Operator wyłączył {module} dla wszystkich kont na tym serwerze. Żadne twoje ustawienie tego nie przywróci.",
"title": "Niedostępne na tym serwerze"
}
},
"vaccinations": {
"title": "Szczepienia",
"subtitle": "Twoja karta szczepień, dawka po dawce.",
@@ -9931,7 +9947,6 @@
"page": {
"title": "Spożycie składników odżywczych",
"description": "Nawodnienie oraz spożycie witamin/minerałów zsynchronizowane z aplikacji zdrowia telefonu, plus ręczne wpisy wody.",
"moduleOffTitle": "Śledzenie składników odżywczych jest wyłączone",
"moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.",
"moduleOffCta": "Włącz składniki odżywcze",
"emptyTitle": "Brak jeszcze danych o składnikach odżywczych",
@@ -10280,6 +10295,12 @@
"dobHint": "Zostaw puste, jeśli wolisz jej nie zapisywać. Z samego rocznika nic nie jest wyliczane.",
"localeLabel": "Język tej karty",
"localeHint": "Język tekstów zapisywanych w tej karcie. Twoje własne widoki pozostają w Twoim języku.",
"genderLabel": "Płeć (opcjonalnie)",
"genderHint": "Decyduje, czy śledzenie cyklu jest na start włączone dla tego profilu. Możesz to zmienić w każdej chwili.",
"genderNone": "Nie podano",
"genderMale": "Mężczyzna",
"genderFemale": "Kobieta",
"genderOther": "Inna",
"timezoneHint": "Strefa, w której liczone są dni tej karty.",
"timezoneInvalid": "Ta przeglądarka nie zna takiej strefy czasowej.",
"create": "Utwórz profil",
@@ -10289,6 +10310,12 @@
"errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.",
"errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.",
"errorFailed": "Nie udało się utworzyć profilu.",
"edit": "Edytuj",
"editSave": "Zapisz zmiany",
"editSaving": "Zapisywanie…",
"editLoadError": "Nie udało się wczytać tych danych.",
"editErrorGone": "Tego profilu już nie ma albo już się nim nie opiekujesz.",
"editErrorFailed": "Nie udało się zapisać zmian.",
"delete": "Usuń profil",
"deleteTitle": "Usunąć {name}?",
"deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem — pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.38.14",
"version": "1.38.15",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+83
View File
@@ -1,9 +1,18 @@
import { defineConfig, devices } from "@playwright/test";
import {
NOTIFICATION_BASE_URL,
NOTIFICATION_PORT,
} from "./e2e/setup/notification-server";
import {
SSR_PREFETCH_BASE_URL,
SSR_PREFETCH_PORT,
} from "./e2e/setup/ssr-prefetch-server";
import {
resolveSmtpStubPort,
SMTP_STUB_FROM,
SMTP_STUB_HOST,
} from "./e2e/setup/smtp-stub";
/**
* Playwright configuration for the HealthLog E2E suite.
@@ -19,6 +28,14 @@ import {
* To run locally: `pnpm dlx playwright install --with-deps chromium`
* once, then `pnpm e2e`.
*/
/**
* Drawn once here, before any server or worker exists, and published through
* `process.env.SMTP_STUB_PORT` — which the web server inherits and which every
* worker process re-reads when it loads this config, so all three agree on one
* number without a constant to collide on.
*/
const smtpStubPort = resolveSmtpStubPort();
export default defineConfig({
testDir: "./e2e",
testIgnore: ["setup/**"],
@@ -160,6 +177,26 @@ export default defineConfig({
// other one mid-navigation. It proves a flow through stable
// data-slots, not a mobile layout, so it runs in one project.
"glucose-journey.spec.ts",
// The adherence journey walks the wizard, records doses on its own
// account and then reads RATES back off it — and the dashboard tile
// sums the expected doses of every active medication that account
// holds. The account keeps siblings out; two PROJECTS running this
// same file in parallel would still write the one cabinet and each
// sit in the other's denominator, so the percentages would be answers
// to a question neither test asked. It proves flows through stable
// data-slots, not a mobile layout, so it runs in one project.
"medication-compliance-journey.spec.ts",
// The doctor-report journey seeds a few weeks of readings into its own
// account, presses Generate and reads the PDF back off the download.
// Two projects doing that in parallel would leave a second set of rows
// in the window one of them is asserting an empty report for, and would
// spend the shared hourly export bucket twice over. The panel's mobile
// layout is covered by `settings-export.spec.ts`, which measures its
// tap targets there; this journey is about the artefact.
"doctor-report.spec.ts",
// Its delegate half MOVES the session's record selector, so it runs in
// exactly one project for the reason written above the fence specs.
"doctor-report-delegate.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
// The Apple Health import journey uploads an archive into the one
@@ -171,6 +208,20 @@ 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",
// The notification-dispatch journey binds a local SMTP responder and
// reads verdicts off one account's delivery ledger — "one email
// attempt, no ntfy attempt, the APNs arm skipped for this reason". A
// second project would run the file in a second worker process, whose
// stub would find the port already bound, and would add attempts to
// the window the first one counts. It proves a dispatch decision
// through stable attributes and the account's own API, not a mobile
// layout, so it runs in one project.
"notification-dispatch-journey.spec.ts",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
@@ -240,5 +291,37 @@ export default defineConfig({
DASHBOARD_SSR_PREFETCH: "true",
},
},
// The mail-configured, scheduler-free server, on its own port.
//
// Only `notification-dispatch-journey.spec.ts` talks to it, and
// `e2e/setup/notification-server.ts` carries the whole reasoning: why
// the SMTP env must not reach the shared server (it would render the
// Email card for every spec that opens `/settings/integrations`), why
// this process must not run the reminder scheduler (a tick inside the
// journey's window is a writer no spec can see), and what the shared
// server keeping "all" still leaves standing.
{
command: `PORT=${NOTIFICATION_PORT} HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
url: `${NOTIFICATION_BASE_URL}/api/version`,
timeout: 60_000,
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
env: {
...process.env,
NATIVE_CANVAS: "off",
DASHBOARD_SSR_PREFETCH: "false",
HEALTHLOG_PROCESS_TYPE: "web",
// The one delivery channel a notification journey can exercise for
// real on one machine. Its transport is operator config, not
// account input, so it never crosses the SSRF floor that refuses a
// local host for ntfy / webhook / Web Push. The responder these
// point at is started by the journey itself; see
// `e2e/setup/smtp-stub.ts` for why no other channel can be.
SMTP_HOST: SMTP_STUB_HOST,
SMTP_PORT: String(smtpStubPort),
SMTP_FROM: SMTP_STUB_FROM,
},
},
],
});
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.38.14";
/* @sw-version-fallback */ "v1.38.15";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+12 -3
View File
@@ -45,16 +45,19 @@ describe("Playwright CI determinism", () => {
expect(config.retries).toBe(2);
expect(config.failOnFlakyTests).toBe(true);
expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]);
// Two servers, and which is which matters. The suite's own server runs
// Three servers, and which is which matters. The suite's own server runs
// with the dashboard's RSC prefetch OFF so `page.route` fixtures govern
// what the dashboard paints; the second runs it ON, which is the default
// and therefore the only configuration self-hosters ever see. A React #418
// hydration bailout shipped on `/` precisely because nothing in CI ran the
// shipped one. Pin both so that gap cannot reopen by deletion.
// shipped one. The third is the mail-configured, scheduler-free process the
// notification journey drives, kept apart so the SMTP env never renders
// the Email card for every other spec. Pin all three so no gap can reopen
// by deletion.
const servers = config.webServer;
expect(Array.isArray(servers)).toBe(true);
const webServers = servers as Array<Record<string, unknown>>;
expect(webServers).toHaveLength(2);
expect(webServers).toHaveLength(3);
for (const server of webServers) {
expect(server).toMatchObject({
command: expect.stringContaining(
@@ -71,6 +74,12 @@ describe("Playwright CI determinism", () => {
expect(webServers[1]!.env).toMatchObject({
DASHBOARD_SSR_PREFETCH: "true",
});
expect(webServers[2]!.env).toMatchObject({
HEALTHLOG_PROCESS_TYPE: "web",
SMTP_HOST: expect.any(String),
});
expect(webServers[0]!.env).not.toHaveProperty("SMTP_HOST");
expect(webServers[1]!.env).not.toHaveProperty("SMTP_HOST");
});
it("retains the Playwright report after every workflow outcome", () => {
@@ -1,5 +1,6 @@
/**
* v1.32.36 — pair guard over the account payload (`GET /api/auth/me`).
* v1.32.36 — pair guard over the account payload (`GET /api/auth/me`), in both
* directions.
*
* The recurring failure this exists to prevent: a two-ended feature whose ends
* ship in different releases. The column lands with the schema change, the
@@ -33,12 +34,39 @@
* be allowlisted with that reason written down, and the reason is an
* assumption about another repository until an audit crosses over.
*
* ## The other direction: a reader with no field
*
* Everything above asks that a published field has a reader. It cannot see the
* inverse — a client that reads a field the payload never published — and that
* blind spot cost a shipped feature: the medication surface decided between
* the server-side reminder switch and the client-managed chip on
* `notificationPrefs.medication.clientManaged`, read off this payload, which
* did not carry it. The flag was `undefined` for every account, so the chip
* never rendered and a person whose phone owned the reminders was shown a
* switch that decided nothing. Every guard was green.
*
* The last case closes it, and the shape it takes is deliberate. A typed guard
* — one `AccountPayload` interface, `tsc` refusing an absent property — was the
* first idea and does not work here: the consumers declare their own local
* response interfaces (`interface UserPrefsResponse { … }`), so `tsc` checks
* each file against its own private claim about the payload and agrees with
* every one of them. So the check reads those local claims instead, out of the
* file that makes them, and holds them against what the route publishes. It
* asserts a non-zero match count, because a textual guard that silently
* matches nothing is the failure mode this repository already has on record.
*
* Its limit, written down: it sees the response type a `/api/auth/me` read
* names, so a consumer that types the read as `unknown` and indexes into it, or
* imports its response type from another module, is invisible to it.
*
* Mutation check: append a field to the response literal in
* `src/app/api/auth/me/route.ts` and this goes red; delete the practice-name
* read in `health-record-export-panel.tsx` and it goes red too.
* read in `health-record-export-panel.tsx` and it goes red too; delete
* `notificationPrefs` from the response literal and the last case goes red
* naming the medication notification section.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { join, relative } from "node:path";
import { describe, it, expect } from "vitest";
@@ -189,6 +217,31 @@ function readsField(text: string, field: string): boolean {
return re.test(text);
}
/**
* Top-level member names of a locally declared `interface Name { … }`, or null
* when the file does not declare one under that name (the type is imported, or
* is a shared transport type this file only references).
*
* Members are read at one indent level, the same way the route's response
* literal is read above — the tree is prettier-formatted, so a nested object's
* keys sit deeper and are not mistaken for top-level fields.
*/
function declaredMembers(text: string, name: string): string[] | null {
const open = new RegExp(`\\binterface\\s+${name}\\s*\\{`).exec(text);
if (!open) return null;
const start = open.index + open[0].length;
const end = text.indexOf("\n}", start);
if (end === -1) return null;
const body = text.slice(start, end);
return [
...new Set(
[...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*)\??\s*[?:]/gm)].map(
(m) => m[1],
),
),
];
}
describe("account payload consumer guard", () => {
const source = readFileSync(ME_ROUTE, "utf8");
const fields = payloadFields(source);
@@ -294,4 +347,40 @@ describe("account payload consumer guard", () => {
).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
}
});
it("every field a client types onto the account payload is published", () => {
const published = new Set(fields);
const offenders: string[] = [];
let claimsChecked = 0;
for (const file of consumerFiles) {
const text = stripComments(readFileSync(file, "utf8"));
// The type argument of a read of THIS endpoint. `/api/auth/me/...`
// siblings are different payloads, so the literal is anchored closed.
for (const call of text.matchAll(
/<\s*([A-Za-z_$][\w$]*)\s*>\s*\(\s*"\/api\/auth\/me"\s*\)/g,
)) {
const declared = declaredMembers(text, call[1]);
if (declared === null) continue; // an imported / shared type
claimsChecked += 1;
for (const member of declared) {
if (!published.has(member)) {
offenders.push(
`${relative(process.cwd(), file)} reads \`${member}\``,
);
}
}
}
}
expect(
claimsChecked,
"no client names a response type for GET /api/auth/me, so this case matched nothing rather than proving anything",
).toBeGreaterThan(0);
expect(
offenders,
"these clients type a field onto the account payload that the route does not publish, so it is `undefined` for every account and whatever it decides never happens",
).toEqual([]);
});
});
@@ -27,6 +27,7 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
}),
@@ -34,6 +35,7 @@ vi.mock("@/lib/rate-limit", () => ({
// wrapper. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
ip: "203.0.113.1",
@@ -84,11 +86,13 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
ip: "203.0.113.1",
@@ -175,6 +179,7 @@ describe("POST /api/auth/check-user", () => {
it("returns 429 when the per-IP rate-limit is exhausted", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 15 * 60 * 1000,
ip: "203.0.113.1",
@@ -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",
@@ -260,10 +256,6 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
},
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/reminder-check": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
@@ -23,7 +23,7 @@ describe("managed record settings configuration contract", () => {
"timeFormat",
"dateFormat",
],
modules: ["modulePreferences"],
modules: ["modulePreferences", "cycleTrackingEnabled"],
notifications: ["moodReminderEnabled", "notificationPreferences"],
thresholds: ["overrides"],
coach: ["disableCoach", "preferences"],
@@ -34,6 +34,13 @@ describe("managed record settings configuration contract", () => {
it.each([
["profile", { email: "not-allowed@example.test" }],
["modules", { role: "ADMIN" }],
// The delegated key by its module name, which is the mistake a stale
// client makes. It has to stay a refusal: accepting it would persist an
// inert `false` behind a green "saved" and change nothing.
["modules", { modulePreferences: { cycle: false } }],
// An empty patch. It parses as a strict object and would audit a change
// nobody made.
["modules", {}],
[
"notifications",
{
@@ -63,6 +70,11 @@ describe("managed record settings configuration contract", () => {
},
],
["modules", { modulePreferences: { mood: false } }],
// v1.38.14 (#939) — the delegated key, sent on its own. `cycle` is not a
// writable module preference and never will be: its user-layer state is
// the record's own cycle profile, so the family names the real column
// rather than a blob entry the gate ignores.
["modules", { cycleTrackingEnabled: false }],
[
"notifications",
{
+35 -11
View File
@@ -88,19 +88,43 @@ describe("shared-record navigation", () => {
).toEqual(["/checkups"]);
});
it("uses the active record scope instead of the actor's module flags", () => {
const actorModules = {
medications: false,
labs: false,
mood: false,
mentalHealth: false,
cycle: false,
illness: false,
inboundDocuments: false,
};
/**
* Scope and modules are two filters and they answer different questions.
*
* This case used to assert that the module map was IGNORED inside a shared
* record, and the reason was sound at the time: the map `GET /api/auth/me`
* published was the ACTOR's, which describes their own dashboard, so letting
* it hide a domain the record had granted would have been the actor's
* preference deciding somebody else's navigation.
*
* v1.38.14 (#939) removed the premise rather than the filter. The payload
* now resolves the map for the RECORD the session is inside, so the map is
* the record's own answer to "what does this record track" — and honouring
* it is what makes a guardian's Modules toggle visible inside the profile it
* was set for. The scope still decides which doors the grant opens; the map
* decides which of them the record uses at all.
*/
it("applies the record's own module map on top of its scope", () => {
const scope = ["medications", "labs"] as const;
expect(
visibleNavDestinations(actorModules, true, true, ["medications"]).map(
visibleNavDestinations({}, true, true, [...scope]).map(
(destination) => destination.href,
),
).toEqual(["/medications", "/labs"]);
// The record tracks medications and not labs. Both are in scope; one door
// is offered.
expect(
visibleNavDestinations({ labs: false }, true, true, [...scope]).map(
(destination) => destination.href,
),
).toEqual(["/medications"]);
// And the scope still binds: a module the record tracks is not a door the
// grant opens.
expect(
visibleNavDestinations({}, true, true, ["medications"]).map(
(destination) => destination.href,
),
).toEqual(["/medications"]);
@@ -78,7 +78,10 @@ const PINNED_AFFORDANCES: Record<
string,
Partial<Record<SuccessMarker, number>>
> = {
"src/components/admin/_shared.tsx": { "text-success": 2, "toast.success": 1 },
// The third `text-success` is the success arm of StatusItem's tone table:
// the two status grids used to spell the tint at every call site and now
// name a tone instead, so the marker lives here once.
"src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 },
"src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 },
"src/components/admin/api-token-overview-section.tsx": { "text-success": 1 },
"src/components/admin/app-log-preview-section.tsx": {
@@ -115,8 +118,6 @@ const PINNED_AFFORDANCES: Record<
"text-success": 4,
"toast.success": 2,
},
"src/components/admin/system-status-section.tsx": { "text-success": 6 },
"src/components/admin/system-status-summary.tsx": { "text-success": 3 },
"src/components/admin/umami-section.tsx": { "toast.success": 1 },
"src/components/admin/user-management-section.tsx": {
"text-success": 2,
@@ -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;
+5 -3
View File
@@ -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.
@@ -0,0 +1,120 @@
/**
* The admin reminder sweep's SCOPE, which is the half of this route that is
* easy to state backwards.
*
* The route dispatches. Every overdue slot it finds enters the channel cascade
* and reaches whoever owns the medication, so an unscoped run is a writer into
* every account on the instance at once — not a reader that somebody else might
* disturb. These cases pin both arms: no body means the long-standing
* instance-wide sweep, and a named account means a `where` that cannot reach
* anyone else.
*
* Mutation check: drop the `userId` spread from the route's `where` and the
* second case fails on the filter it no longer carries.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { count: vi.fn() },
},
}));
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(async () => undefined),
}));
import { POST } from "../route";
import { requireAdmin } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
const URL_ = "http://localhost/api/admin/notifications/reminder-check";
/** The admin console's own call: no body, no content-type. */
function bare(): NextRequest {
return new NextRequest(URL_, { method: "POST" });
}
function withBody(body: unknown): NextRequest {
return new NextRequest(URL_, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function whereOfLastFindMany(): Record<string, unknown> {
const call = vi.mocked(prisma.medication.findMany).mock.calls.at(-1);
return (call?.[0] as { where: Record<string, unknown> }).where;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
reminderMissedMinutes: 240,
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0 as never);
});
describe("POST /api/admin/notifications/reminder-check", () => {
it("sweeps the whole instance when no account is named", async () => {
const res = await POST(bare());
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { scoped: boolean } };
expect(json.data.scoped).toBe(false);
expect(whereOfLastFindMany()).toEqual({ active: true });
});
it("narrows the sweep to the named account", async () => {
const res = await POST(withBody({ userId: "cku0mock0account0id" }));
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { scoped: boolean } };
expect(json.data.scoped).toBe(true);
expect(whereOfLastFindMany()).toEqual({
active: true,
userId: "cku0mock0account0id",
});
});
it("refuses an unknown body key rather than sweeping everything", async () => {
const res = await POST(withBody({ userid: "cku0mock0account0id" }));
expect(res.status).toBe(422);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
it("refuses an account selector that is not an id", async () => {
const res = await POST(withBody({ userId: "../../etc/passwd" }));
expect(res.status).toBe(422);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,10 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
@@ -40,13 +43,50 @@ interface MedicationResult {
}
/**
* POST: Execute the reminder check — analyzes all medications AND sends
* POST: Execute the reminder check — analyses medications AND sends
* notifications for overdue schedules (late + missed). Returns detailed
* results for display in the admin panel.
*
* ## Scope
*
* The sweep is instance-wide by default: no body, every active medication on
* the instance, one dispatch per overdue slot. That is the operator button's
* long-standing behaviour and it stays.
*
* An optional `userId` in the body narrows it to ONE account. The direction
* that matters is easy to state backwards, so it is written here: the hazard a
* caller of this route creates is not that somebody else writes into its
* window, it is that THIS call dispatches into everybody else's — a run reaches
* every account that has an overdue dose and a configured channel. A caller
* that only means to sweep one account names it and stops being a second
* writer for every other one.
*
* Naming an account cannot widen anything. `requireAdmin()` is cookie-only by
* construction, so a Bearer token — wildcard scope included — never reaches
* this handler at all, and the field only ever removes rows from the `where`.
*/
export const POST = apiHandler(async () => {
export const POST = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.notifications.reminder-check" } });
// A bodyless POST is the admin console's own call (`apiPost(path)` sends no
// body and no content-type), so the parse only runs when a JSON body is
// actually presented. An empty object is the same instance-wide sweep.
let input: unknown = {};
if (
(request.headers.get("content-type") ?? "").includes("application/json")
) {
const { data, error } = await safeJson(request, { maxBytes: 4 * 1024 });
if (error) return error;
input = data ?? {};
}
const parsed = adminReminderCheckSchema.safeParse(input);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const scopedUserId = parsed.data.userId ?? null;
annotate({
action: { name: "admin.notifications.reminder-check" },
meta: { scope: scopedUserId ? "account" : "instance" },
});
const now = new Date();
@@ -57,7 +97,11 @@ export const POST = apiHandler(async () => {
const missedMinutes = appSettings?.reminderMissedMinutes ?? 240;
const medications = await prisma.medication.findMany({
where: { active: true },
where: {
active: true,
// Absent selector ⇒ no `userId` key ⇒ the instance-wide sweep.
...(scopedUserId ? { userId: scopedUserId } : {}),
},
include: {
schedules: true,
user: { select: { id: true, username: true, timezone: true } },
@@ -211,6 +255,7 @@ export const POST = apiHandler(async () => {
return apiSuccess({
timestamp: now.toISOString(),
missedThresholdMinutes: missedMinutes,
scoped: scopedUserId !== null,
medications: results,
notificationsSent,
message:
@@ -32,6 +32,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -192,6 +193,7 @@ describe("PATCH /api/auth/me/diabetes", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -24,6 +24,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -247,6 +248,7 @@ describe("PATCH /api/auth/me/disable-coach", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -36,6 +36,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -197,6 +198,7 @@ describe("PATCH /api/auth/me/glucose-unit", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -35,6 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -397,6 +398,7 @@ describe("PATCH — envelope and transport", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -147,6 +147,10 @@ describe("POST /api/auth/me/mfa/disable", () => {
} as never);
const res = await POST(req());
expect(res.status).toBe(401);
// The code is what separates "re-prompt for a digit" from "this session is
// gone"; a client that branches on prose here signs the user out on a typo.
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("auth.mfa.code_invalid");
expect(destroyOtherSessions).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -13,6 +13,7 @@
*/
import {
apiHandler,
AUTH_ERROR_CODES,
requireMfaManagementAuth,
HttpError,
} from "@/lib/api-handler";
@@ -81,7 +82,7 @@ export const POST = apiHandler(async (req: Request) => {
},
});
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
throw new HttpError(401, "Invalid code");
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
}
// Every cheap check has passed and the teardown is next, so spend the
@@ -18,6 +18,7 @@
*/
import {
apiHandler,
AUTH_ERROR_CODES,
requireMfaManagementAuth,
HttpError,
} from "@/lib/api-handler";
@@ -99,7 +100,7 @@ export const POST = apiHandler(async (req: Request) => {
action: { name: "auth.mfa.totp.confirm.invalid" },
meta: { replay: result.replay },
});
throw new HttpError(401, "Invalid code");
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
}
const recoveryCodes = generateRecoveryCodes();
@@ -35,6 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -300,6 +301,7 @@ describe("PATCH /api/auth/me/modules", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -26,6 +26,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -587,6 +588,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
+136 -31
View File
@@ -9,12 +9,37 @@
* shell would 403 the moment a switch was on.
*
* So every field below is still the CALLER's own — their preferences, their
* modules, their identity — and the one field about the switch,
* `accountAccess`, says so explicitly. Nothing here ever renders the owner's
* data; a delegate reading their own display preferences while inside
* somebody else's record is the correct behaviour and the reason the mode
* exists (design §4: locale, theme and layout belong to the person, not the
* record).
* identity — and the one field about the switch, `accountAccess`, says so
* explicitly. Nothing here ever renders the owner's health data; a delegate
* reading their own display preferences while inside somebody else's record is
* the correct behaviour and the reason the mode exists (design §4: locale,
* theme and layout belong to the person, not the record).
*
* TWO fields are the exception, and the exception is the rule applied rather
* than a hole in it: `modules` and `cycleTrackingEnabled` resolve for the
* RECORD the session is inside, not for the actor (#939). They are not display
* preferences. They say what this record tracks — and every consumer of them
* gates a surface that shows the RECORD's data: the navigation, the dashboard
* tiles, the insights sections, quick-add. Publishing the actor's map while
* switched meant a Guardian who turned Cycle off for the child still saw a
* Cycle destination in the child's record, and a Guardian who tracks it saw
* one there whether or not the child did — the toggle in the record's own
* settings wrote a column nothing on screen was reading. Design §4 is about
* whose eyes are on the screen; this is about whose record is on it.
*
* Those two fields are then MASKED to the sections the active grant names.
* `accountAccess.active` is set for any live grant, level and scope included,
* so the record scoping on its own would hand a delegate scoped to
* `measurements` the owner's cycle, screener, illness and nutrient
* configuration — and the cycle flag is a derivation of the owner's recorded
* sex. A section the grant does not open answers `false`, which is the answer
* a module that is off already gives and the answer the browser's own route
* inventory has always acted on. An unscoped grant names every section and is
* unaffected.
*
* The own-record case is byte-identical: with no switch the active record IS
* the caller, there is no grant to narrow to, and every value below is
* resolved from the same row it always was.
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
@@ -30,7 +55,12 @@ import {
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
import {
buildModuleDisclosure,
sectionsOpen,
} from "@/lib/sharing/module-disclosure";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
export const dynamic = "force-dynamic";
@@ -41,26 +71,54 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.me" } });
// Two independent awaits, overlapped: the onboarding-cookie resync
// (v1.4.22 W5 Sr-H1 — fall-back for legacy sessions that predate the
// cookie; new sessions anchor it inside `createSession`) touches only
// the cookie store, while the cycle-profile read (v1.15.0 — resolved
// cycle-tracking gate; no row is forced, a NULL toggle derives from
// gender) is a Postgres round-trip. Running them sequentially added
// the cookie hop to every /me — and /me sits on every app boot path.
const [, , cycleProfile, modules, moduleAvailability, accountAccess] =
// Overlapped, not sequential: the two cookie resyncs (v1.4.22 W5 Sr-H1 —
// the onboarding fall-back for sessions that predate the cookie; new ones
// anchor it inside `createSession`) touch only the cookie store, while the
// access resolution is a Postgres round-trip. Running them one after the
// other added the cookie hop to every /me, and /me sits on every app boot.
const [, , accountAccess] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
// v1.36.0 — which records this caller may open, which one they are
// inside, and what they may do there. Every value resolved server-side;
// see the module docblock for why none of it is left to the client.
resolveAccountAccess(auth),
]);
// The record the two module fields answer for, and the reason this read is
// sequenced after the block above rather than inside it: the active record
// has to be the GRANT-VALIDATED one. The session row's own selector is not
// that — it is a stamp, and a stamp whose grant was revoked a minute ago
// still names an account. `resolveAccountAccess` is what checks the live
// grant, so its answer is what may pick whose configuration is published.
// The cost is one round-trip on a hot path, paid on every /me; publishing a
// revoked record's configuration would be paid once, by somebody else.
const recordId = accountAccess.active?.accountId ?? user.id;
// What the grant opens, and therefore what the two record fields below may
// say. `null` is the whole record — the answer for an unscoped grant and for
// no switch at all, and the reason the own-record payload is untouched.
const sections = accountAccess.active?.sections ?? null;
const [record, cycleProfile, resolvedModules, moduleAvailability] =
await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
recordId === user.id
? Promise.resolve({ gender: user.gender })
: prisma.user.findUnique({
where: { id: recordId },
select: { gender: true },
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
where: { userId: recordId },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
@@ -68,19 +126,41 @@ export const GET = apiHandler(async () => {
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(user.id),
resolveModuleMap(recordId),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
// Server-WIDE, so it is the same map for every record and is not
// re-scoped here.
getOperatorModuleAvailability(),
// v1.36.0 — which records this caller may open, which one they are
// inside, and what they may do there. Every value resolved server-side;
// see the module docblock for why none of it is left to the client.
resolveAccountAccess(auth),
]);
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
// The masking step, and the one place the record scoping is narrowed rather
// than resolved. `active` is set for ANY live grant — a READ grant scoped to
// `measurements` included — so publishing the record's whole map would tell
// a delegate whether the owner tracks their cycle, their screeners, their
// illness episodes or their supplement intake, none of which their grant
// opens. A closed section reads `false`, the same answer a module that is
// off already gives, and the navigation the client builds from it drops
// exactly the doors `isSharedRecordPathPresentable` drops anyway.
// Two maps out of one pass: `modules` is the boolean gate map, masked
// exactly as it was, and `moduleAccess` is the same answer with the reason
// attached — the record's own switch, the grant's edge, or the operator's.
// Every client that only wants "paint it or not" keeps reading the boolean.
const { modules, moduleAccess } = buildModuleDisclosure(
resolvedModules,
moduleAvailability,
sections,
);
// The cycle flag is masked on its own rather than read off `modules.cycle`:
// that key also carries the operator's server-wide availability, and this
// field never has. Same section, same answer, without borrowing a second
// layer's decision.
const cycleTrackingEnabled =
sectionsOpen(sections, "cycle") &&
isCycleEnabled(record?.gender ?? null, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
@@ -145,6 +225,16 @@ export const GET = apiHandler(async () => {
// the account has never saved one, which is what makes the panel show the
// named template on the first run rather than a silent server default.
reportSelection: user.reportSelectionJson ?? null,
// The account's resolved notification preferences — the same resolver
// `GET /api/auth/me/notification-prefs` answers with, so the two surfaces
// cannot disagree. Published here because the medication detail's
// notification section decides between the server-side reminder switch and
// the client-managed chip on `medication.clientManaged`, and it reads that
// decision off THIS payload: the dedicated endpoint is not on the app-boot
// path. An account that has never opted in resolves to the documented
// defaults rather than to `undefined`, so the switch is what renders.
// No extra query — the session already loads the row this column is on.
notificationPrefs: parseNotificationPrefs(user.notificationPrefs ?? null),
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0078 commentary). Every Coach mount point on the client checks
@@ -157,7 +247,11 @@ export const GET = apiHandler(async () => {
insurerIkNumber: user.insurerIkNumber ?? null,
insuranceNumber,
// v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS
// hides the whole cycle tab when this is false.
// hides the whole cycle tab when this is false. v1.38.14 — resolved for
// the ACTIVE RECORD, so a switched browser hides the tab the record does
// not track rather than the one the actor does not (#939), and `false`
// whenever the active grant does not name the `cycle` section. Unchanged
// on the native transport, which carries no switch.
cycleTrackingEnabled,
// v1.18.0 — module enable/disable map. `{ <moduleKey>: boolean }`
// for every toggleable module; `false` means the module is OFF and
@@ -165,6 +259,8 @@ export const GET = apiHandler(async () => {
// …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the
// resolved `disableCoach` + operator master flag, so this map is the
// single thing a client needs to gate every secondary domain.
// v1.38.14 — resolved for the ACTIVE RECORD and masked to the sections
// the active grant opens; see the module docblock.
modules,
// v1.18.0 — operator-layer availability per toggleable module. `false`
// ⇒ the operator turned the module off server-wide (off for every
@@ -172,6 +268,15 @@ export const GET = apiHandler(async () => {
// this to show a "disabled server-wide" read-only row; everywhere else
// the already-AND-ed `modules` map is the single gate to read.
moduleAvailability,
// Why each module's surfaces are or are not there, for the record this
// session is inside: `enabled`, `disabled` (the record's own switch),
// `not_granted` (the active grant does not open the module's section) or
// `unavailable` (the operator switched it off for the whole instance).
// Precedence outside-in: unavailable > not_granted > disabled > enabled.
// `modules[key]` stays exactly `moduleAccess[key] === "enabled"`, so this
// adds a reason without moving a gate; empty states read it to say the
// honest thing rather than offering a switch the reader cannot reach.
moduleAccess,
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
+7 -2
View File
@@ -13,7 +13,12 @@
*/
import { NextRequest } from "next/server";
import { headers } from "next/headers";
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
AUTH_ERROR_CODES,
HttpError,
requireAuth,
} from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -139,7 +144,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
async function presentedBearerToken(): Promise<string> {
const authHeader = (await headers()).get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
throw new HttpError(401, "Not authenticated");
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
}
return authHeader.slice(7);
}
@@ -68,6 +68,7 @@ beforeEach(() => {
vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
@@ -137,6 +138,7 @@ describe("POST /api/c/[token]/unlock", () => {
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
@@ -55,7 +55,12 @@ import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit } from "@/lib/rate-limit";
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
const RL_OK = {
allowed: true,
limit: 20,
remaining: 19,
resetAt: Date.now() + 60_000,
};
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
@@ -109,6 +114,7 @@ describe("POST /api/consent/ai", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkConsentRateLimit).mockResolvedValue({
allowed: false,
limit: 20,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -54,7 +54,12 @@ import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit } from "@/lib/rate-limit";
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
const RL_OK = {
allowed: true,
limit: 20,
remaining: 19,
resetAt: Date.now() + 60_000,
};
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
@@ -99,6 +104,7 @@ describe("GET /api/consent/ai/latest", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkConsentRateLimit).mockResolvedValue({
allowed: false,
limit: 20,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -197,6 +203,7 @@ describe("DELETE /api/consent/ai/latest", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkConsentRateLimit).mockResolvedValue({
allowed: false,
limit: 20,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -43,7 +43,12 @@ const SESSION_OK = {
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
const RL_OK = {
allowed: true,
limit: 20,
remaining: 19,
resetAt: Date.now() + 60_000,
};
/** The mount heal posts no body and no content-type. */
function mkHeal(): Request {
@@ -79,6 +84,7 @@ describe("POST /api/consent/ai/web", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkConsentRateLimit).mockResolvedValue({
allowed: false,
limit: 20,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -90,10 +90,18 @@ describe("GET /api/custom-metrics/[id]/entries", () => {
expect(body.data.meta.total).toBe(5);
expect(body.data.meta.limit).toBe(1);
const findCall = vi.mocked(prisma.customMetricEntry.findMany).mock
.calls[0][0] as { where: Record<string, unknown>; take: number };
.calls[0][0] as {
where: Record<string, unknown>;
take: number;
orderBy: unknown;
};
expect(findCall.where.userId).toBe("user-1");
expect(findCall.where.customMetricId).toBe("cm-1");
expect(findCall.take).toBe(1);
// `measuredAt` is not unique on a custom metric — a batch of readings can
// share one instant — so offset paging over it alone can repeat a row on
// one page and drop another.
expect(findCall.orderBy).toEqual([{ measuredAt: "desc" }, { id: "desc" }]);
});
it("404s a cross-user / unknown metric", async () => {
@@ -62,7 +62,9 @@ export const GET = apiHandler(
const [rows, total] = await Promise.all([
prisma.customMetricEntry.findMany({
where,
orderBy: { measuredAt: sortDir },
// Unique tiebreaker — see the measurements list: offset paging over
// a non-unique sort key can repeat and drop rows between pages.
orderBy: [{ measuredAt: sortDir }, { id: sortDir }],
take: limit,
skip: offset,
}),
@@ -24,6 +24,7 @@ vi.mock("@/lib/modules/gate", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
limit: 60,
remaining: 39,
resetAt: Date.now() + 60_000,
})),
@@ -159,6 +160,7 @@ describe("POST /api/daily/digest/dismiss", () => {
const { checkRateLimit } = await import("@/lib/rate-limit");
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -168,6 +168,7 @@ beforeEach(() => {
vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 5,
resetAt: Date.now() + 60 * 60 * 1000,
});
@@ -304,6 +305,7 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
it("consumes the shared document-AI bucket honestly", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30 * 60_000,
});
@@ -128,6 +128,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 5,
resetAt: Date.now() + 60 * 60 * 1000,
});
@@ -222,6 +223,7 @@ describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
const resetAt = Date.now() + 47 * 60_000;
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt,
});
@@ -309,6 +311,7 @@ describe("POST /api/documents/inbound/[id]/index — staging continuation", () =
it("does not reach staging when the index itself was refused", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -71,6 +71,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
@@ -190,6 +190,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -229,6 +230,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -258,6 +260,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -289,6 +292,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -327,6 +331,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -370,6 +375,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -390,6 +396,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -425,6 +432,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -452,6 +460,7 @@ describe("GET /api/export/mood", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -492,6 +501,7 @@ describe("GET /api/export/full-backup", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -562,6 +572,7 @@ describe("GET /api/export/full-backup", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -130,6 +130,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
@@ -105,7 +105,10 @@ describe("GET /api/illness/episodes/{id}/day-logs — date-less list", () => {
const findArgs = vi.mocked(prisma.illnessDayLog.findMany).mock.calls[0][0];
expect(findArgs).toMatchObject({
where: { episodeId: "ep-1", deletedAt: null },
orderBy: { date: "desc" },
// `date` is a calendar day and repeats within an episode, so the
// unique tiebreaker is what keeps `skip`-based paging from repeating
// one row and dropping another between pages.
orderBy: [{ date: "desc" }, { id: "desc" }],
take: 60,
skip: 0,
});
@@ -126,7 +129,7 @@ describe("GET /api/illness/episodes/{id}/day-logs — date-less list", () => {
const findArgs = vi.mocked(prisma.illnessDayLog.findMany).mock.calls[0][0];
expect(findArgs).toMatchObject({
orderBy: { date: "asc" },
orderBy: [{ date: "asc" }, { id: "asc" }],
take: 10,
skip: 20,
});
@@ -88,7 +88,8 @@ export const GET = apiHandler(
const [rows, total] = await Promise.all([
prisma.illnessDayLog.findMany({
where,
orderBy: { date: sortDir },
// Unique tiebreaker — `date` is a calendar day and repeats.
orderBy: [{ date: sortDir }, { id: sortDir }],
take: limit,
skip: offset,
include: dayLogSymptomInclude,
@@ -117,6 +117,7 @@ beforeEach(() => {
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
limit: 120,
remaining: 119,
resetAt: Date.now() + 60_000,
});
@@ -103,6 +103,7 @@ beforeEach(() => {
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
limit: 120,
remaining: 119,
resetAt: Date.now() + 60_000,
});
@@ -229,6 +230,7 @@ describe("GET /api/insights/correlations", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: false,
limit: 120,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -188,6 +188,7 @@ beforeEach(() => {
selfContextText.mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -366,6 +367,7 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
expect(limit).toBe(10);
return {
allowed: callCount <= 10,
limit: 10,
remaining: Math.max(0, 10 - callCount),
resetAt: Date.now() + 3600_000,
};
@@ -392,6 +394,7 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
expect(limit).toBe(3);
return {
allowed: callCount <= 3,
limit: 3,
remaining: Math.max(0, 3 - callCount),
resetAt: Date.now() + 3600_000,
};
@@ -673,6 +676,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
mockBriefinglessFreshCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
@@ -691,6 +695,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
mockBriefinglessFreshCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
@@ -50,6 +50,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -114,6 +115,7 @@ describe("POST /api/internal/web-vitals", () => {
it("returns 429 when the rate-limit fires", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
+5 -1
View File
@@ -105,7 +105,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
const [rows, total] = await Promise.all([
prisma.labResult.findMany({
where,
orderBy: { takenAt: sortDir },
// Unique tiebreaker: `takenAt` is a day-resolution timestamp, so a
// panel drawn in one sitting ties across every analyte in it. Offset
// paging over a non-unique sort key can repeat a row on one page and
// drop another, invisibly to both sides.
orderBy: [{ takenAt: sortDir }, { id: sortDir }],
take: limit,
skip: offset,
include: {
+151 -6
View File
@@ -3,17 +3,166 @@ import { NextRequest } from "next/server";
import {
apiHandler,
MFA_STEP_UP_MAX_AGE_SECONDS,
requireCookieAuth,
requireFreshMfa,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import {
deleteManagedProfile,
ManagedProfileLifecycleError,
readManagedProfileForGuardian,
updateManagedProfile,
} from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { updateManagedProfileSchema } from "@/lib/validations/managed-profiles";
type RouteParams = { params: Promise<{ id: string }> };
/** The one refusal this file answers with for every "you may not have it". */
const notFound = () =>
apiError("Managed profile not found", 404, {
errorCode: "managed_profile.not_found",
});
/**
* The record as it stands.
*
* The read the edit form fills itself from, and the reason it exists at all:
* `accountAccess.accounts` on the account payload carries a managed record's
* NAME and nothing else, so a form offered from the Guardian's own panel had
* no way to show the timezone, the language or the date of birth it was about
* to overwrite. A form that opens with blank fields and PUTs them is how a
* record loses its timezone to an edit of its name.
*
* Cookie-only and NOT step-up gated, exactly like the guardian roster beside
* it and for the same reason: this is a read, it discloses to a Guardian what
* they already administer, and the gate belongs to the acts. (Named
* `requireCookieAuth` rather than described by the session helper it calls —
* see the roster route's docblock for why that distinction is load-bearing.)
*
* An ACTOR surface: the profile is named in the path and the caller acts as
* themselves, so it answers from the Guardian's own account rather than only
* while switched into the record.
*/
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireCookieAuth();
const { id } = await params;
const profile = await readManagedProfileForGuardian({
profileId: id,
guardianId: user.id,
});
// One refusal for "no such account", "not a managed profile" and "not a
// Guardian of it", so the route is not an enumeration oracle.
if (!profile) return notFound();
annotate({
action: { name: "managed_profile.read" },
meta: { profile_id: id },
});
return apiSuccess(profile);
},
);
/**
* The ceiling the create sibling carries, on the act that changes what it
* created. Ten an hour per caller: a person cannot mistype their way into ten
* edits of a record's identity, and the number matching creation and guardian
* invitation keeps one family answering one way.
*
* The GET beside it stays unlimited, matching the guardian roster read next to
* it: both are cheap identity reads a Guardian may already make, and neither
* family sibling has ever carried a read bucket.
*/
const UPDATE_LIMIT = 10;
const UPDATE_WINDOW_MS = 60 * 60 * 1000;
/**
* Change a managed record's identity.
*
* ## Why the same gate as deletion
*
* `requireFreshMfa`, unconditionally, which is what creation and deletion
* already carry. The three acts are one family: they mint, change and end an
* account that can never prove anything about itself, so the proof has to come
* from the person doing it. Softening the gate here — "it is only a rename" —
* would leave the display name of every record somebody looks after writable
* from an ambient cookie, and the display name is what every other Guardian,
* the switcher and the record banner identify the record by.
*
* A Guardian who is already switched INTO the record edits the same five
* columns through `PATCH /api/record-settings/profile` without a step-up, and
* that is not a contradiction: reaching that surface at all requires the
* switch, which is itself an act on the record. This route is the one reachable
* from the Guardian's own panel, where nothing else has been proved.
*
* ## Which fields, and why not more
*
* The five the creation takes. Everything else a `User` row carries —
* credentials, provider connections, notification routing, the health profile
* — is deliberately out of reach: this is the record's identity, not its
* account. The wider configuration surface is `PATCH
* /api/record-settings/{family}`, which is guardian-fenced and switched-in.
*/
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
// Before the id is read, exactly as the create sibling does it. The cheap
// refusal has to come first here for a second reason: `updateManagedProfile`
// opens a transaction and takes an advisory lock on the id it was HANDED
// before it decides the caller is not a Guardian of it, so an unthrottled
// caller can name any id they like and make the database hold a lock about
// it. Same ceiling as creating a record and inviting a Guardian — the three
// acts are one family and answering the same abuse differently is the
// asymmetry nobody notices until it is used.
const rateLimit = await checkRateLimit(
`managed-profile:update:${user.id}`,
UPDATE_LIMIT,
UPDATE_WINDOW_MS,
);
if (!rateLimit.allowed) {
return apiError("Too many profile edits, try again later", 429);
}
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateManagedProfileSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// The parsed patch goes to the service, which assembles the Prisma payload
// field by field. One assembly rather than one here and one there: two
// statements of which fields are writable is one statement too many.
let profile;
try {
profile = await updateManagedProfile({
profileId: id,
guardianId: user.id,
patch: parsed.data,
});
} catch (error) {
if (error instanceof ManagedProfileLifecycleError) return notFound();
throw error;
}
annotate({
action: { name: "managed_profile.update" },
meta: { profile_id: id, changed: Object.keys(parsed.data) },
});
return apiSuccess(profile);
},
);
/** Delete a managed record only after its cookie-backed Guardian proves MFA. */
export const DELETE = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
@@ -23,11 +172,7 @@ export const DELETE = apiHandler(
try {
await deleteManagedProfile({ profileId: id, guardianId: user.id });
} catch (error) {
if (error instanceof ManagedProfileLifecycleError) {
return apiError("Managed profile not found", 404, {
errorCode: "managed_profile.not_found",
});
}
if (error instanceof ManagedProfileLifecycleError) return notFound();
throw error;
}
+6 -11
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
@@ -62,6 +63,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
: null,
locale: parsed.data.locale,
timezone: parsed.data.timezone,
// Absent and null both mean "not recorded". The record keeps the same
// NULL an account that never answered carries, and the cycle module
// derives its default from it rather than from a guess about the person.
gender: parsed.data.gender ?? null,
});
await auditLog("managed_profile.created", {
@@ -73,15 +78,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: { profile_id: profile.id },
});
return apiSuccess(
{
id: profile.id,
displayName: profile.displayName,
dateOfBirth: profile.dateOfBirth,
locale: profile.locale,
timezone: profile.timezone,
recordKind: "managed" as const,
},
201,
);
return apiSuccess(toManagedProfileView(profile), 201);
});
@@ -37,12 +37,14 @@ vi.mock("@/lib/auth/issue-token", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(async () => ({
allowed: true,
limit: 60,
remaining: 100,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
})),
checkRateLimit: vi.fn(async () => ({
allowed: true,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
})),
@@ -103,6 +105,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -215,6 +218,7 @@ describe("authorization_code grant", () => {
it("rejects a replayed code (single-use jti already claimed)", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
+2 -10
View File
@@ -302,11 +302,7 @@ export async function GET(request: NextRequest): Promise<Response> {
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
headers: rateLimitHeaders(rl),
},
);
}
@@ -407,11 +403,7 @@ export async function POST(request: NextRequest): Promise<Response> {
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
headers: rateLimitHeaders(rl),
},
);
}
+1 -5
View File
@@ -80,11 +80,7 @@ export async function POST(request: NextRequest): Promise<Response> {
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
headers: rateLimitHeaders(rl),
},
);
}
+1 -5
View File
@@ -222,11 +222,7 @@ export async function POST(request: NextRequest): Promise<Response> {
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
headers: rateLimitHeaders(rl),
},
);
}
@@ -303,7 +303,10 @@ describe("GET /api/measurement-reminders/[id]/history", () => {
});
expect(body.data.meta).toEqual({ total: 2, limit: 10, offset: 0 });
const query = eventFindManyMock.mock.calls[0][0];
expect(query.orderBy).toEqual({ occurredAt: "desc" });
// The trailing `id` is the unique tiebreaker: several reminder events
// can share one `occurredAt`, and offset paging over a tied key repeats
// and drops rows between pages.
expect(query.orderBy).toEqual([{ occurredAt: "desc" }, { id: "desc" }]);
expect(query.where).toEqual({ reminderId: "r1", userId: "u1" });
});
@@ -45,7 +45,8 @@ export const GET = apiHandler(
const [rows, total] = await Promise.all([
prisma.measurementReminderEvent.findMany({
where,
orderBy: { occurredAt: "desc" },
// Unique tiebreaker — several reminder events can share one instant.
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: limit,
skip: offset,
}),
@@ -189,6 +189,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -186,6 +186,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -159,6 +159,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(session as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -114,6 +114,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -194,6 +194,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -271,6 +272,7 @@ describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", ()
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -148,6 +148,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -151,6 +151,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
+17 -2
View File
@@ -190,7 +190,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
measuredAt: { gte: dayStart, lt: dayEnd },
deletedAt: null,
},
orderBy: { measuredAt: sortDir },
// Same tiebreaker as the paged list. This one slices in memory rather
// than with `skip`, so there is no page drift to prevent — but two
// identical requests would still be free to render a tied block in
// different orders, and a drill-down that reshuffles on refresh reads
// as data changing.
orderBy: [{ measuredAt: sortDir }, { id: sortDir }],
// v1.4.38 — the 1000-row cap now lives on the validator (refine
// on `(limit, dayKey)` returning 422 when a caller asks for
// more). The route reads `limit` straight through because the
@@ -544,7 +549,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
const [measurements, total] = await Promise.all([
prisma.measurement.findMany({
where,
orderBy: { [sortBy]: sortDir },
// `{ id: sortDir }` is the tiebreaker, not decoration. `measuredAt`
// is not unique here and ties are the normal case. Only the exact
// tuple `(userId, type, measuredAt, source, sleepStage)` is unique,
// so a tie survives across `type` (the `stats:<id>:YYYY-MM-DD`
// daily-rollup convention puts every daily total at one instant),
// across `source` (two writers landing on the same instant) and
// across `sleepStage`. Without a unique secondary key Postgres is
// free to order the tied block differently between the query for page
// N and the query for page N+1, so a client paging its own history
// silently gets one row twice and never gets another.
orderBy: [{ [sortBy]: sortDir }, { id: sortDir }],
take: limit,
skip: offset,
}),
@@ -46,6 +46,7 @@ vi.mock("@/lib/cache/server-cache", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -139,6 +140,7 @@ beforeEach(() => {
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -416,6 +418,7 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
it("returns 429 when the per-user rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -30,6 +30,7 @@ vi.mock("@/lib/medications/route-guards", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -92,6 +93,7 @@ beforeEach(() => {
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -134,6 +136,7 @@ describe("GET /api/medications/[id]/dose-history", () => {
it("returns 429 when the per-user cap is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -38,6 +38,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -85,6 +86,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -251,6 +253,7 @@ describe("POST /api/medications/[id]/glp1", () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -208,6 +208,27 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
});
});
it("orders on a unique tiebreaker, keeping NULLS LAST on the takenAt arm", async () => {
// Scheduled slots share an instant by construction — a twice-daily
// medication puts every morning dose on the same `scheduledFor` — so
// offset paging over the sort key alone can repeat a row on one page and
// drop another. The `takenAt` arm keeps its NULLS LAST pin as well: the
// tiebreaker must not push skipped rows back to the top.
await GET(makeRequest("sortBy=takenAt&sortDir=desc"), ROUTE_PARAMS);
expect(
vi.mocked(prisma.medicationIntakeEvent.findMany).mock.calls[0][0]
?.orderBy,
).toEqual([{ takenAt: { sort: "desc", nulls: "last" } }, { id: "desc" }]);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockClear();
await GET(makeRequest("sortBy=scheduledFor&sortDir=asc"), ROUTE_PARAMS);
expect(
vi.mocked(prisma.medicationIntakeEvent.findMany).mock.calls[0][0]
?.orderBy,
).toEqual([{ scheduledFor: "asc" }, { id: "asc" }]);
});
it("rejects unknown status values with a 422", async () => {
const res = await GET(makeRequest("status=junk"), ROUTE_PARAMS);
expect(res.status).toBe(422);
@@ -39,6 +39,7 @@ vi.mock("@/lib/medications/route-guards", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -124,6 +125,7 @@ beforeEach(() => {
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -239,6 +241,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
it("returns 429 when the per-user cap is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -97,6 +97,7 @@ beforeEach(() => {
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -407,6 +408,7 @@ describe("POST /api/medications/[id]/intake/import — future bound and limiter"
it("refuses with 429 once the import limiter trips", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
+9 -2
View File
@@ -729,10 +729,17 @@ export const GET = apiHandler(
// the history view. Pinning them last keeps the descending order
// reading today → yesterday → … with real timestamps first. Other
// sort columns are non-null so they keep the simple shape.
// The trailing `{ id: sortDir }` is the unique tiebreaker. Scheduled
// slots share an instant by construction — a twice-daily medication puts
// every morning dose on the same `scheduledFor` — so offset paging over
// the primary key alone can repeat a row on one page and drop another.
const orderBy =
sortBy === "takenAt"
? { takenAt: { sort: sortDir, nulls: "last" as const } }
: { [sortBy]: sortDir };
? [
{ takenAt: { sort: sortDir, nulls: "last" as const } },
{ id: sortDir },
]
: [{ [sortBy]: sortDir }, { id: sortDir }];
const [events, total] = await Promise.all([
prisma.medicationIntakeEvent.findMany({
@@ -30,6 +30,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -87,6 +88,7 @@ beforeEach(() => {
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -253,6 +255,7 @@ describe("POST /api/medications/[id]/inventory", () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -31,6 +31,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -84,6 +85,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -298,6 +300,7 @@ describe("POST /api/medications/[id]/side-effects", () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -30,6 +30,7 @@ vi.mock("@/lib/cache/server-cache", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -106,6 +107,7 @@ beforeEach(() => {
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -178,6 +180,7 @@ describe("GET /api/medications/compliance", () => {
it("returns 429 when the per-user rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -110,6 +110,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 60_000,
});
@@ -152,6 +153,7 @@ describe("POST /api/medications/extract — rate limit", () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -704,6 +704,49 @@ describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => {
if (day.scheduled > 0) expect(day.taken).toBe(day.scheduled);
}
});
it("a deliberately skipped dose leaves the denominator — a skip is a pause, not a miss, so the tile matches the ledger rate", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const twiceDaily = activeDailyMed(new Date(Date.now() - 60 * 86_400_000));
twiceDaily.schedules[0].timesOfDay = ["08:00", "20:00"];
vi.mocked(prisma.medication.findMany).mockResolvedValue([
twiceDaily,
] as never);
// Yesterday: the morning dose taken, the evening dose deliberately
// skipped. The skipped slot must drop out of `scheduled` entirely, so the
// day reads 1 of 1 (100%) — the figure the medication card, the dose
// history and the doctor report already showed. Counting it as
// expected-and-missed made the same day read 1 of 2 (50%) here.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
medicationId: "m1",
scheduledFor: new Date("2026-06-09T08:00:00.000Z"),
takenAt: new Date("2026-06-09T08:05:00.000Z"),
skipped: false,
autoMissed: false,
},
{
medicationId: "m1",
scheduledFor: new Date("2026-06-09T20:00:00.000Z"),
takenAt: null,
skipped: true,
autoMissed: false,
},
] as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=compliance&days=7",
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ date: string; scheduled: number; taken: number }>;
};
const skipDay = body.data.find((d) => d.date === "2026-06-09");
expect(skipDay).toEqual({ date: "2026-06-09", scheduled: 1, taken: 1 });
});
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more