mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #946 from MBombeck/release/v1.38.15
Release v1.38.15
This commit is contained in:
176 files changed
+12719
-525
No files matched your search
+162
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -124,6 +124,24 @@ decompresses to a few hundred megabytes, and
|
||||
`NODE_OPTIONS=--max-old-space-size=2048` in front of the command is enough for
|
||||
a 445 000-measurement account.
|
||||
|
||||
### Restoring from the backups console
|
||||
|
||||
An admin can restore a stored snapshot from the browser instead of the shell.
|
||||
`/admin/backups` lists one row per account and per backup type: **Backup now**
|
||||
enqueues the `data-backup` pass for every account on the instance, **Download**
|
||||
decrypts one snapshot and hands back the JSON, and **Restore** opens a dialog
|
||||
that first shows what the file carries and then requires the word `RESTORE` to
|
||||
be typed before it will run. The restore targets the account the snapshot was
|
||||
taken for, never the admin running it, and it refuses a file whose declared
|
||||
owner does not match the stored row. Instance-wide settings ride along only
|
||||
when you tick _Also restore instance settings_ in the dialog; leave it clear to
|
||||
restore the account alone.
|
||||
|
||||
A stored copy this instance can no longer open — the key that wrote it dropped
|
||||
from `ENCRYPTION_KEYS` one rotation too early, or bytes that are not the ones
|
||||
written — is refused by all three buttons with `422` and
|
||||
`meta.errorCode = backup.payload.undecryptable`, before anything is read back.
|
||||
|
||||
### What a backup deliberately does not carry
|
||||
|
||||
Every credential-shaped row is left out, and this is not an oversight to fix:
|
||||
@@ -264,6 +282,16 @@ shape, and worse: a restore parses the whole document, so the read path needs
|
||||
several times the blob in heap. An operator restoring a very large account
|
||||
should give the container more memory for the duration.
|
||||
|
||||
### What a restore replaces
|
||||
|
||||
A restore replaces the account's data tables; it does not merge into them.
|
||||
Every row the file carries is written back under its original id, and every row
|
||||
the account gained after the snapshot was taken — readings, doses, mood
|
||||
entries, documents — is deleted with the rest of the class before the file is
|
||||
read back. Restoring Monday's copy on Wednesday therefore costs the account
|
||||
everything it recorded on Tuesday. If the current state is worth keeping, take
|
||||
a fresh snapshot from the backups console before restoring the old one.
|
||||
|
||||
## Monthly restore drill (automatic)
|
||||
|
||||
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* A record, a snapshot of it, and the snapshot put back — one journey.
|
||||
*
|
||||
* The script this file is checked against:
|
||||
*
|
||||
* 1. an account records readings across three domains — two measurements, a
|
||||
* medication with one dose, a mood entry — and files a small PDF in the
|
||||
* vault;
|
||||
* 2. it downloads its own record as the passphrase-sealed archive the export
|
||||
* card offers, and the bytes are the `HLX1` envelope
|
||||
* `src/lib/export/passphrase-archive.ts` documents, not a JSON file with
|
||||
* a new extension;
|
||||
* 3. an admin takes a snapshot from the backups console, waits for the
|
||||
* pg-boss pass by asking the console's own endpoint what it holds, and
|
||||
* downloads the stored copy;
|
||||
* 4. the account loses a reading and a dose, gains a reading the snapshot
|
||||
* never saw, and the snapshot is restored over it from the same console;
|
||||
* 5. what was deleted is back under its ORIGINAL id, and what arrived after
|
||||
* the snapshot is gone — which is what "the restore replaces the
|
||||
* account's data tables" in `docs/ops/backup-restore.md` means when it is
|
||||
* spelled out.
|
||||
*
|
||||
* Refusing controls, because a restore path that cannot say no is worse than
|
||||
* none: a stored copy with one flipped ciphertext byte is refused and changes
|
||||
* nothing, and a delegate holding whole-record READ inside the account sees no
|
||||
* backup surface at all and is refused by every route behind it.
|
||||
*
|
||||
* One act reaches past this account, and by design: "Backup now" enqueues the
|
||||
* `data-backup` pass, which writes a `WEEKLY_AUTO` row for EVERY account on the
|
||||
* instance. The journey reads only its own row and deletes only its own rows,
|
||||
* but it is not true that it touches nothing else. The restore is the other
|
||||
* direction and is confined: the transaction is scoped to the snapshot's owner,
|
||||
* and the instance-wide settings a disaster-recovery payload also carries are
|
||||
* written only when the dialog's opt-in is ticked, which this file never ticks
|
||||
* and asserts it never ticks.
|
||||
*
|
||||
* Everything the journey does, it does through the app: the readings and the
|
||||
* document ride the page's own `fetch`, the archive and the snapshot ride the
|
||||
* real buttons, the restore rides the typed-confirmation dialog. The only two
|
||||
* things reaching Postgres directly are the ones a browser cannot express —
|
||||
* resetting the account between repetitions, and corrupting one stored copy so
|
||||
* the authenticated envelope has something to refuse. Both live in
|
||||
* `setup/backup-journey-fixture.ts`.
|
||||
*
|
||||
* Serial and single-project: the four tests are four acts of one journey, they
|
||||
* mutate the account destructively, and the last one moves a session's record
|
||||
* selector. Its jar is its own for the reason `global-setup.ts` spells out.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive";
|
||||
|
||||
import {
|
||||
BACKUP_DELEGATE_STORAGE_STATE_PATH,
|
||||
BACKUP_STORAGE_STATE_PATH,
|
||||
E2E_BACKUP_ADMIN,
|
||||
} from "./setup/global-setup";
|
||||
import {
|
||||
backupAccountId,
|
||||
deleteStoredCopy,
|
||||
resetBackupJourney,
|
||||
storedIntakeIds,
|
||||
storedMeasurements,
|
||||
storeTamperedCopy,
|
||||
} from "./setup/backup-journey-fixture";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
/**
|
||||
* The pass backs up every account in the database, the restore rewrites a
|
||||
* dozen tables in one transaction, and the archive runs Argon2id at the OWASP
|
||||
* cost. None of that fits the suite's 30-second default on a loaded runner.
|
||||
*/
|
||||
test.setTimeout(180_000);
|
||||
|
||||
/** Long enough to clear the export card's own 12-character minimum. */
|
||||
const ARCHIVE_PASSPHRASE = "e2e-backup-journey-passphrase";
|
||||
|
||||
/** The reading the journey deletes and expects back, by value and by id. */
|
||||
const RESTORED_WEIGHT_KG = 81.4;
|
||||
/** The reading nothing touches — a restore that wiped it would be a defect. */
|
||||
const UNTOUCHED_PULSE_BPM = 58;
|
||||
/** The reading that arrives AFTER the snapshot and must not survive it. */
|
||||
const POST_SNAPSHOT_PULSE_BPM = 66;
|
||||
|
||||
const MEDICATION_NAME = "E2E Backup Tablet";
|
||||
|
||||
/** The console's row list is painted, or its honest empty state is. */
|
||||
const BACKUPS_SETTLED = '[data-slot="backup-rows"], [data-slot="empty-state"]';
|
||||
/** The measurements list has finished its read, either way. */
|
||||
const MEASUREMENTS_SETTLED =
|
||||
'[data-slot="measurement-rows"], [data-slot="empty-state"]';
|
||||
|
||||
/** One stored snapshot as the console's own endpoint reports it. */
|
||||
interface ConsoleRow {
|
||||
id: string;
|
||||
username: string;
|
||||
type: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SeededRecord {
|
||||
weightId: string;
|
||||
intakeId: string;
|
||||
medicationId: string;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
/** What the journey wrote, once the first act has written it. */
|
||||
let seeded: SeededRecord | null = null;
|
||||
/** The stored snapshot the first act takes; every later act addresses it. */
|
||||
let snapshotId: string | null = null;
|
||||
/** The deliberately corrupted copy, held so the file can take it away again. */
|
||||
let tamperedCopyId: string | null = null;
|
||||
|
||||
function requireSeeded(): SeededRecord {
|
||||
if (!seeded) throw new Error("the record was never seeded");
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function requireSnapshot(): string {
|
||||
if (!snapshotId) throw new Error("no snapshot was taken");
|
||||
return snapshotId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the record through the app's own routes, from the page's origin.
|
||||
*
|
||||
* The page's `fetch` rather than a Playwright request context, for the reason
|
||||
* `glucose-journey.spec.ts` gives: a pooled context can hand a POST to a
|
||||
* keep-alive socket the server has already closed, and the retry lands as a
|
||||
* second row.
|
||||
*/
|
||||
function seedRecord(page: Page): Promise<SeededRecord> {
|
||||
return page.evaluate(
|
||||
async ([weightKg, pulseBpm, medicationName]: [number, number, string]) => {
|
||||
const post = async (path: string, body: unknown): Promise<string> => {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = (await res.json()) as {
|
||||
data: { id: string } | null;
|
||||
error: string | null;
|
||||
};
|
||||
if (!res.ok || !json.data) {
|
||||
throw new Error(`${path} answered ${res.status}: ${json.error}`);
|
||||
}
|
||||
return json.data.id;
|
||||
};
|
||||
const at = (minutesAgo: number) =>
|
||||
new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||
|
||||
const weightId = await post("/api/measurements", {
|
||||
type: "WEIGHT",
|
||||
value: weightKg,
|
||||
measuredAt: at(180),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "PULSE",
|
||||
value: pulseBpm,
|
||||
measuredAt: at(170),
|
||||
});
|
||||
const medicationId = await post("/api/medications", {
|
||||
name: medicationName,
|
||||
dose: "5 mg",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
});
|
||||
const intakeId = await post(`/api/medications/${medicationId}/intake`, {
|
||||
takenAt: at(160),
|
||||
});
|
||||
await post("/api/mood-entries", {
|
||||
mood: "GUT",
|
||||
note: "backup journey",
|
||||
moodLoggedAt: at(150),
|
||||
});
|
||||
|
||||
// The vault, through the same multipart body the upload card sends. A
|
||||
// real minimal PDF: the route classifies by magic bytes and never trusts
|
||||
// the wire content-type, so bytes that only claim to be one are refused.
|
||||
const pdf = new TextEncoder().encode(
|
||||
"%PDF-1.4\n%e2e-backup-journey\n" +
|
||||
"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n" +
|
||||
"xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\n%%EOF\n",
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"file",
|
||||
new File([pdf], "backup-journey.pdf", { type: "application/pdf" }),
|
||||
);
|
||||
form.append("title", "Backup journey probe");
|
||||
const docRes = await fetch("/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const docJson = (await docRes.json()) as {
|
||||
data: { id: string } | null;
|
||||
error: string | null;
|
||||
};
|
||||
if (!docRes.ok || !docJson.data) {
|
||||
throw new Error(
|
||||
`/api/documents/inbound answered ${docRes.status}: ${docJson.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { weightId, medicationId, intakeId, documentId: docJson.data.id };
|
||||
},
|
||||
[RESTORED_WEIGHT_KG, UNTOUCHED_PULSE_BPM, MEDICATION_NAME] as [
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** This account's scheduled snapshot as the console's endpoint reports it. */
|
||||
function readConsoleRow(page: Page): Promise<ConsoleRow | null> {
|
||||
return page.evaluate(async (username: string) => {
|
||||
const res = await fetch("/api/admin/backups", { credentials: "include" });
|
||||
const body = (await res.json()) as {
|
||||
data: { rows: ConsoleRow[] } | null;
|
||||
};
|
||||
const mine = (body.data?.rows ?? []).filter(
|
||||
(row) => row.username === username && row.type === "WEEKLY_AUTO",
|
||||
);
|
||||
return mine[0] ?? null;
|
||||
}, E2E_BACKUP_ADMIN.username);
|
||||
}
|
||||
|
||||
/** Open the backups console and wait for its row list to settle. */
|
||||
async function openBackupsConsole(page: Page): Promise<void> {
|
||||
await page.goto("/admin/backups", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(BACKUPS_SETTLED).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The console's row for one snapshot, at whichever layout is painting.
|
||||
*
|
||||
* Both the wide table and the narrow card list are in the tree — only CSS
|
||||
* hides one — so the `:visible` filter is what keeps this a single element
|
||||
* rather than a pair. This file runs in the desktop project alone (see the
|
||||
* `chromium-mobile` ignore list in `playwright.config.ts`), so the table row is
|
||||
* the one that answers; the card's identical attributes are what let the same
|
||||
* handle hold if it ever runs narrow, and are not exercised here.
|
||||
*/
|
||||
function snapshotRow(page: Page, id: string) {
|
||||
return page.locator(`[data-backup-id="${id}"]:visible`);
|
||||
}
|
||||
|
||||
/** Drive the typed-confirmation dialog on one row and hand back the answer. */
|
||||
async function restoreThroughDialog(page: Page, id: string) {
|
||||
const answered = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/api/admin/backups/${id}/restore`) &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
await snapshotRow(page, id).getByTestId("backup-restore-trigger").click();
|
||||
// The instance-wide opt-in, left exactly where the dialog puts it. Asserted
|
||||
// rather than merely not clicked: a default that flipped would hand this
|
||||
// journey the power to rewrite the host's settings — the document size cap
|
||||
// among them — for every other spec sharing the server.
|
||||
await expect(
|
||||
page.getByTestId("backup-restore-instance-settings"),
|
||||
).not.toBeChecked();
|
||||
await page.getByTestId("backup-restore-prompt").fill("RESTORE");
|
||||
await page.getByTestId("backup-restore-confirm").click();
|
||||
return answered;
|
||||
}
|
||||
|
||||
test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
test.use({ storageState: BACKUP_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
seeded = null;
|
||||
snapshotId = null;
|
||||
tamperedCopyId = null;
|
||||
await resetBackupJourney();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (tamperedCopyId) await deleteStoredCopy(tamperedCopyId);
|
||||
tamperedCopyId = null;
|
||||
});
|
||||
|
||||
test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
seeded = await seedRecord(page);
|
||||
const record = seeded;
|
||||
|
||||
// On the list, by the number the row carries rather than by the text the
|
||||
// formatter prints for whoever is looking. The attribute holds the
|
||||
// DISPLAYED value, after the account's unit preference is applied — the
|
||||
// same number as the stored one under this account's default metric
|
||||
// preference, and deliberately not a claim about the stored row. The
|
||||
// database-side assertions later in the file are that claim.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
|
||||
`[data-measurement-value="${RESTORED_WEIGHT_KG}"]`,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// ── the account's own archive ────────────────────────────────────────
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("export-card-full-backup")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("export-full-backup-encrypt").click();
|
||||
await page
|
||||
.getByTestId("export-full-backup-passphrase")
|
||||
.fill(ARCHIVE_PASSPHRASE);
|
||||
const archiveDownload = page.waitForEvent("download");
|
||||
await page.getByTestId("export-action-full-backup").click();
|
||||
const bytes = await readFile(await (await archiveDownload).path());
|
||||
|
||||
// The header `passphrase-archive.ts` documents, field by field: magic,
|
||||
// version, KDF id, then the Argon2 cost the archive carries so a later
|
||||
// cost bump still opens it. Asserted as bytes, because "a file arrived"
|
||||
// is equally true of plaintext JSON under a new extension.
|
||||
expect(bytes.subarray(0, 4).toString("ascii")).toBe("HLX1");
|
||||
expect(bytes[4], "HLX1 version").toBe(0x01);
|
||||
expect(bytes[5], "KDF id — Argon2id").toBe(0x01);
|
||||
// The cost travels in the file so it may change, so it is asserted against
|
||||
// the constant the writer uses rather than against today's numbers: a
|
||||
// legitimate cost bump is a correct product change, not a red spec.
|
||||
expect(bytes.readUInt32BE(6), "Argon2 memory cost, KiB").toBe(
|
||||
EXPORT_ARGON2_PARAMS.memoryCost,
|
||||
);
|
||||
expect(bytes.readUInt32BE(10), "Argon2 time cost").toBe(
|
||||
EXPORT_ARGON2_PARAMS.timeCost,
|
||||
);
|
||||
expect(bytes[14], "Argon2 parallelism").toBe(
|
||||
EXPORT_ARGON2_PARAMS.parallelism,
|
||||
);
|
||||
expect(bytes[15], "salt length").toBe(16);
|
||||
// A record carrying five sections is not a header and an auth tag.
|
||||
expect(bytes.byteLength).toBeGreaterThan(1024);
|
||||
|
||||
// ── the instance's own snapshot ──────────────────────────────────────
|
||||
await openBackupsConsole(page);
|
||||
// The row this account had before the click, or none. The poll below asks
|
||||
// "is the stored copy a different one", and the server's own clock is the
|
||||
// only one both sides share — comparing a server timestamp against the
|
||||
// runner's would pass on a stale row under any clock skew.
|
||||
const previous = await readConsoleRow(page);
|
||||
const enqueued = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/admin/backups/run") &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
// The list is populated by now, so it is the card action that runs the
|
||||
// pass; the empty state's CTA carries its own id.
|
||||
await page.getByTestId("backup-run-now").click();
|
||||
// Named here rather than left to the poll: with no pg-boss instance in the
|
||||
// process the route answers 503 and nothing is ever enqueued, and the poll
|
||||
// would spend two minutes to report that the pass stored no copy — true,
|
||||
// and about the wrong subsystem. 429 is the same story on a reset that
|
||||
// stopped clearing the run bucket.
|
||||
expect(
|
||||
(await enqueued).status(),
|
||||
"the manual pass was enqueued (503 = no worker in this process, 429 = rate limit)",
|
||||
).toBe(200);
|
||||
|
||||
// The pass runs on pg-boss inside this process, so the console cannot know
|
||||
// when it finished — it can only be asked. Poll the endpoint the page
|
||||
// itself reads until this account's stored copy is newer than the click.
|
||||
// No sleep: a fixed wait would be a guess about a queue.
|
||||
const found: { row: ConsoleRow | null } = { row: null };
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const row = await readConsoleRow(page);
|
||||
const fresh =
|
||||
row !== null &&
|
||||
(previous === null ||
|
||||
row.id !== previous.id ||
|
||||
row.createdAt !== previous.createdAt);
|
||||
if (fresh) found.row = row;
|
||||
return fresh;
|
||||
},
|
||||
{
|
||||
timeout: 120_000,
|
||||
intervals: [500, 1_000, 2_000],
|
||||
message: "the data-backup pass never stored a copy of this account",
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const row = found.row;
|
||||
if (!row) throw new Error("the poll reported a row it did not keep");
|
||||
expect(
|
||||
row.sizeBytes,
|
||||
"a stored copy of a record is not empty",
|
||||
).toBeGreaterThan(512);
|
||||
snapshotId = row.id;
|
||||
|
||||
// The console paints it, and the row's own actions are on screen.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(snapshotRow(page, row.id)).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(
|
||||
snapshotRow(page, row.id).getByTestId("backup-restore-trigger"),
|
||||
).toBeVisible();
|
||||
|
||||
// ── what the stored copy actually carries ────────────────────────────
|
||||
const snapshotDownload = page.waitForEvent("download");
|
||||
await snapshotRow(page, row.id).getByTestId("backup-download").click();
|
||||
const payload = JSON.parse(
|
||||
await readFile(await (await snapshotDownload).path(), "utf8"),
|
||||
) as {
|
||||
measurements: Array<{ id: string }>;
|
||||
intakeEvents: Array<{ id: string }>;
|
||||
moodEntries: unknown[];
|
||||
documents: Array<{ id: string; contentEncrypted?: string }>;
|
||||
};
|
||||
|
||||
expect(payload.measurements.map((m) => m.id)).toContain(record.weightId);
|
||||
expect(payload.intakeEvents.map((e) => e.id)).toContain(record.intakeId);
|
||||
expect(payload.moodEntries).toHaveLength(1);
|
||||
expect(payload.documents.map((d) => d.id)).toContain(record.documentId);
|
||||
// A disaster-recovery copy carries document content as ciphertext, and the
|
||||
// restore refuses a file that brought only metadata — so an empty field
|
||||
// here is a snapshot nobody could restore.
|
||||
expect(
|
||||
payload.documents.find((d) => d.id === record.documentId)
|
||||
?.contentEncrypted ?? "",
|
||||
).not.toBe("");
|
||||
});
|
||||
|
||||
test("a stored copy with one flipped byte is refused and changes nothing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const before = await storedMeasurements();
|
||||
const tamperedId = await storeTamperedCopy(requireSnapshot());
|
||||
// Dropped again as soon as the control is done with it, rather than at the
|
||||
// next repetition's reset: until then it is a broken row in the console's
|
||||
// list for anyone who opens `/admin/backups`.
|
||||
tamperedCopyId = tamperedId;
|
||||
|
||||
await openBackupsConsole(page);
|
||||
const response = await restoreThroughDialog(page, tamperedId);
|
||||
|
||||
// The envelope is AES-256-GCM and its tag covers every ciphertext byte, so
|
||||
// the flip is caught before the transaction opens. 422 and
|
||||
// `backup.payload.undecryptable` are the refusal the route documents —
|
||||
// `docs/api/openapi.yaml`, and `docs/ops/backup-restore.md` for the
|
||||
// operator reading it after a key rotation — rather than whatever the
|
||||
// handler happened to answer. The code is asserted and not the sentence:
|
||||
// the sentence is prose that may be reworded, the code is the contract.
|
||||
expect(response.status()).toBe(422);
|
||||
expect(await response.json()).toMatchObject({
|
||||
data: null,
|
||||
meta: { errorCode: "backup.payload.undecryptable" },
|
||||
});
|
||||
|
||||
// A refusal that still wrote something would be worse than no refusal.
|
||||
expect(await storedMeasurements()).toEqual(before);
|
||||
});
|
||||
|
||||
test("the snapshot puts back what was deleted and drops what came after", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = requireSeeded();
|
||||
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// One reading and one dose go, and a reading the snapshot never saw
|
||||
// arrives. Both deletions are tombstones — the app soft-deletes a reading
|
||||
// and a dose — so "back" has to mean live again, not merely present.
|
||||
const laterId = await page.evaluate(
|
||||
async ([weightId, medicationId, intakeId, pulse]: [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
]) => {
|
||||
const del = async (path: string) => {
|
||||
const res = await fetch(path, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
|
||||
};
|
||||
await del(`/api/measurements/${weightId}`);
|
||||
await del(`/api/medications/${medicationId}/intake/${intakeId}`);
|
||||
|
||||
const res = await fetch("/api/measurements", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "PULSE",
|
||||
value: pulse,
|
||||
measuredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
const body = (await res.json()) as { data: { id: string } | null };
|
||||
if (!res.ok || !body.data) {
|
||||
throw new Error(`/api/measurements answered ${res.status}`);
|
||||
}
|
||||
return body.data.id;
|
||||
},
|
||||
[
|
||||
record.weightId,
|
||||
record.medicationId,
|
||||
record.intakeId,
|
||||
POST_SNAPSHOT_PULSE_BPM,
|
||||
] as [string, string, string, number],
|
||||
);
|
||||
|
||||
const beforeRestore = await storedMeasurements();
|
||||
expect(
|
||||
beforeRestore.find((m) => m.id === record.weightId)?.deletedAt,
|
||||
"the reading really is tombstoned before the restore",
|
||||
).not.toBeNull();
|
||||
expect(beforeRestore.map((m) => m.id)).toContain(laterId);
|
||||
|
||||
await openBackupsConsole(page);
|
||||
const response = await restoreThroughDialog(page, requireSnapshot());
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const afterRestore = await storedMeasurements();
|
||||
|
||||
// Identity, not merely equivalence. The payload carries each row's own id,
|
||||
// so a restore that re-created the reading under a fresh one would have
|
||||
// written a different record that happens to read the same.
|
||||
const weight = afterRestore.find((m) => m.id === record.weightId);
|
||||
expect(
|
||||
weight,
|
||||
"the deleted reading is back under its own id",
|
||||
).toBeDefined();
|
||||
expect(weight?.value).toBe(RESTORED_WEIGHT_KG);
|
||||
expect(
|
||||
weight?.deletedAt,
|
||||
"and it is live, not still tombstoned",
|
||||
).toBeNull();
|
||||
|
||||
const dose = (await storedIntakeIds()).find(
|
||||
(e) => e.id === record.intakeId,
|
||||
);
|
||||
expect(dose, "the deleted dose is back under its own id").toBeDefined();
|
||||
expect(dose?.deletedAt).toBeNull();
|
||||
|
||||
// The reading nothing touched is still there. Without this, an emptied
|
||||
// account would satisfy every assertion above about the tombstones.
|
||||
expect(
|
||||
afterRestore.filter((m) => m.value === UNTOUCHED_PULSE_BPM),
|
||||
).toHaveLength(1);
|
||||
|
||||
// And the reading that arrived after the snapshot is gone.
|
||||
// `docs/ops/backup-restore.md` says the restore "replaces the account's
|
||||
// data tables", and the route's own words are "one transaction replaces
|
||||
// every serialized owner-scoped class". Replacing is not merging: whoever
|
||||
// restores yesterday's copy loses today's readings, and that is the
|
||||
// documented behaviour rather than an accident of the implementation.
|
||||
expect(afterRestore.map((m) => m.id)).not.toContain(laterId);
|
||||
|
||||
// The list agrees with the database.
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
|
||||
`[data-measurement-value="${RESTORED_WEIGHT_KG}"]`,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="measurement-row"][data-measurement-type="PULSE"]' +
|
||||
`[data-measurement-value="${POST_SNAPSHOT_PULSE_BPM}"]`,
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("A read-level delegate inside the record", () => {
|
||||
test.use({ storageState: BACKUP_DELEGATE_STORAGE_STATE_PATH });
|
||||
|
||||
test("sees no backup surface, and every route behind it refuses", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Switched in through the switcher a person uses, not by a stamp written
|
||||
// behind the app's back. The switcher is a slice of the user menu, so the
|
||||
// menu opens first — the same two steps `v137-record-session-fence.spec.ts`
|
||||
// takes, and the trigger's accessible name is the only handle it has.
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const userMenu = page.getByRole("button", { name: "User menu" }).first();
|
||||
await expect(userMenu).toBeVisible({ timeout: 30_000 });
|
||||
await userMenu.click();
|
||||
const trigger = page.locator('[data-slot="account-switcher-trigger"]');
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const entry = page.locator(
|
||||
'[data-slot="account-switcher-entry"]' +
|
||||
`[data-account-username="${E2E_BACKUP_ADMIN.username}"]`,
|
||||
);
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
await entry.click();
|
||||
|
||||
// Wait for the shell to say the switch landed, not merely that the click
|
||||
// did. The stamp is written on the session row by a request the click
|
||||
// fires, so navigating straight afterwards can load the next page under
|
||||
// the delegate's OWN record and find every actor surface exactly where it
|
||||
// belongs — a pass that says nothing. The banner names the record and the
|
||||
// affordance it resolved, so this waits for both; `view` is what
|
||||
// `resolveRecordPresentation` calls a READ grant, and a grant that had
|
||||
// widened would paint `view-and-add` here instead of matching.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="shared-record-banner"]' +
|
||||
`[data-account-id="${await backupAccountId()}"]` +
|
||||
'[data-access-level="view"][data-record-kind="shared"]',
|
||||
),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Settings destinations that belong to the ACTOR do not mount inside
|
||||
// somebody else's record: the shell paints the not-covered panel in place
|
||||
// of the whole section. That panel is the positive half of this check —
|
||||
// without it, an absent export card would only mean the page had not
|
||||
// finished painting.
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("export-card-full-backup")).toHaveCount(0);
|
||||
await expect(page.getByTestId("export-action-full-backup")).toHaveCount(0);
|
||||
|
||||
// The console is not a place a non-admin lands: `AuthShell` sends them to
|
||||
// the dashboard and the admin frame paints nothing on the way.
|
||||
await page.goto("/admin/backups", { waitUntil: "domcontentloaded" });
|
||||
// The dashboard, by path. `/\/$/` also matches an admin URL that merely
|
||||
// ends in a slash, which is the one outcome this assertion exists to rule
|
||||
// out.
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, { timeout: 30_000 })
|
||||
.toBe("/");
|
||||
await expect(page.getByTestId("backup-run-now")).toHaveCount(0);
|
||||
await expect(page.getByTestId("backup-run-now-empty")).toHaveCount(0);
|
||||
await expect(page.getByTestId("backup-restore-trigger")).toHaveCount(0);
|
||||
|
||||
// And the routes underneath, asked from the delegate's own session.
|
||||
// `requireAdmin()` is cookie-only and refuses before the row is read, so
|
||||
// the restore below names the REAL snapshot: a 403 on an id that does not
|
||||
// exist would prove nothing about the one that does.
|
||||
const refusals = await page.evaluate(async (snapshot: string) => {
|
||||
const call = async (path: string, init?: RequestInit) => {
|
||||
const res = await fetch(path, init);
|
||||
const body = (await res.json()) as { error: string | null };
|
||||
return { status: res.status, error: body.error };
|
||||
};
|
||||
return {
|
||||
list: await call("/api/admin/backups"),
|
||||
run: await call("/api/admin/backups/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
}),
|
||||
restore: await call(`/api/admin/backups/${snapshot}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}),
|
||||
};
|
||||
}, requireSnapshot());
|
||||
|
||||
// Each refusal is named, not merely counted. A switched session is also
|
||||
// fenced off non-allowlisted endpoints (`sharing.not_permitted`), and that
|
||||
// fence answers 403 too — so a bare status check would stay green through
|
||||
// an admin gate that had been removed, with the second guard quietly
|
||||
// covering for it. The sentence says which boundary spoke.
|
||||
for (const refusal of [
|
||||
refusals.list,
|
||||
refusals.run,
|
||||
refusals.restore,
|
||||
] as const) {
|
||||
expect(refusal.status).toBe(403);
|
||||
expect(refusal.error).toBe("Admin access required");
|
||||
}
|
||||
|
||||
// The refused restore left the owner's record where the previous act put
|
||||
// it, rather than half-applying and reporting a refusal.
|
||||
const owner = await storedMeasurements();
|
||||
expect(owner.filter((m) => m.value === RESTORED_WEIGHT_KG)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
],
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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());
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
Reference in new issue
Block a user