diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b676a8795..b9f390c4cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d5b69b924b..0acb08f3da 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.14 + version: 1.38.15 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -70,7 +70,18 @@ paths: schema: $ref: "#/components/schemas/CycleCustomSymptomListEnvelope" "401": &a1 - description: Authentication required or invalid credentials. + description: "Authentication required or invalid credentials. The generic auth gates — the ones every route passes + through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on + there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session + cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has + passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is + gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second + factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related + 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this + route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that + is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a + password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the + route refused the credential it was handed, not that the response is malformed." content: application/json: schema: @@ -101,7 +112,32 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": &a5 - description: Rate limit exceeded. + description: Rate limit exceeded. The response carries `Retry-After` (whole seconds, rounded up and never below 1 — wait + at least that long before retrying), `X-RateLimit-Limit` (the bucket's cap), `X-RateLimit-Remaining` and + `X-RateLimit-Reset` (the reset instant, ISO-8601, not epoch seconds). Back off on `Retry-After` rather than + guessing. A 429 raised by a ceiling that is not one of these buckets — a daily AI budget, an upstream + provider's own refusal relayed onward — carries none of them, because there is no bucket to describe. + headers: + Retry-After: + description: Whole seconds to wait before retrying. Rounded up, never below 1. + schema: + type: integer + minimum: 1 + X-RateLimit-Limit: + description: Requests the bucket allows per window. + schema: + type: integer + minimum: 1 + X-RateLimit-Remaining: + description: Requests left in the current window; 0 on a refusal. + schema: + type: integer + minimum: 0 + X-RateLimit-Reset: + description: Instant the current window rolls over, ISO-8601 — not epoch seconds. + schema: + type: string + format: date-time content: application/json: schema: @@ -3328,10 +3364,12 @@ paths: get: tags: - Sync - summary: Measurements delta feed - description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over an opaque - keyset cursor; each page carries `tombstones` (soft-deleted rows, keyed on `externalId`) and `upserts` (live - rows). Apply tombstones before upserts within a page. `cursorExpired: true` forces a clean re-init." + summary: Multi-domain delta feed + description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque + keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each + domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts + within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. + `cursorExpired: true` forces a clean re-init and the page is empty in every domain." parameters: - in: query name: cursor @@ -9712,6 +9750,50 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/admin/notifications/reminder-check: + post: + tags: + - Admin + summary: Run the medication-reminder sweep + description: >- + The admin panel's manual reminder sweep: it classifies every active medication's dose windows against the OWNING + account's wall clock and dispatches a MEDICATION_REMINDER for each overdue slot that has no intake event today. + Cookie auth only — `requireAdmin()` refuses every Bearer caller, wildcard scope included. + + + Scope is the thing to get right. With no body the sweep is INSTANCE-WIDE and dispatches into every account that + has an overdue dose and a configured channel; that is the operator button's behaviour and it is deliberate. A + caller that means to sweep one account sends `userId` and stops being a second writer for every other one. The + selector can only narrow: it feeds the Prisma `where`, never the authorisation. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + userId: + type: string + minLength: 1 + maxLength: 64 + pattern: ^[A-Za-z0-9_-]+$ + additionalProperties: false + responses: + "200": + description: The sweep's findings and how many reminders it sent. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminReminderCheckResponse" + "401": *a1 + "403": + description: Caller is not an admin. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/admin/notifications/diagnostic: get: tags: @@ -15423,9 +15505,10 @@ paths: returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries - the reset instant in `X-RateLimit-Reset` and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips - status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. - Never interprets or diagnoses; the title is a neutral filing label." + `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — + never stages facts, never flips status; the user reviews and saves. 422 + (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title + is a neutral filing label." parameters: - name: id in: path @@ -18200,7 +18283,8 @@ paths: summary: The signed-in account, and what sharing lets it do description: "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is - therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches + therefore an ACTOR surface: it serves the caller's own rows — apart from `modules` and `cycleTrackingEnabled`, + which answer for the record the session is inside, masked to what the grant opens — and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call." responses: @@ -18504,6 +18588,89 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/managed-profiles/{id}: + get: + tags: + - Account sharing + summary: Read a managed record's identity + description: "What the record is right now — the five fields its creation took. The read an edit form fills itself from, + and the reason it exists: `accountAccess.accounts` on the account payload carries the 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. Cookie transport only, an ACTOR surface (the profile is named in the path + and the caller acts as themselves, so it answers without switching into the record), and NOT step-up gated: this + is a read, it discloses to a Guardian only what they already administer, and the gate belongs to the acts." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The record as it stands. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileEnvelope" + "401": *a1 + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + patch: + tags: + - Account sharing + summary: Change a managed record's identity + description: "Edit the record after it exists — the name that was a placeholder, the timezone that moved with the + household, the date of birth somebody finally has, the sex that decides whether the cycle module is on. Any + active Guardian may do it, from a cookie session with a fresh second factor: the same gate creation and deletion + carry, because the three acts mint, change and end an account that can never prove anything about itself. Ten an + hour per caller, the ceiling creation carries. Answers with the whole record, so a client re-renders from the + server's version rather than from what it sent." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateManagedProfileRequest" + responses: + "200": + description: The record as it now stands. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileEnvelope" + "401": + description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation and deletion. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`). The two are byte-identical." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": + description: More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch + on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" delete: tags: - Account sharing @@ -19810,9 +19977,122 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": - description: More than 60 requests in a minute for this token — or, when no valid token was presented, from this client - IP. `meta.errorCode` = `ingest.rate_limited`, plus the standard `X-RateLimit-Remaining` / - `X-RateLimit-Reset` headers. + description: "More than 60 requests in a minute for this token — or, when no valid token was presented, from this client + IP. `meta.errorCode` = `ingest.rate_limited`, plus the standard rate-limit headers: `Retry-After` (whole + seconds, at least 1), `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/admin/backups/{id}/download: + get: + tags: + - Admin + summary: Download one stored backup as JSON + description: "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` + and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact + an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot + reach admin endpoints." + parameters: + - in: path + name: id + schema: + type: string + description: "`DataBackup.id`." + required: true + description: "`DataBackup.id`." + responses: + "200": + description: The decrypted backup document. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupDocument" + "403": &a43 + description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": &a44 + description: No stored copy with this id. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The stored copy could not be opened: either the key that wrote it is no longer in `ENCRYPTION_KEYS` (a + rotation that dropped the legacy entry too early), or the stored bytes are not the ones that were written. + `meta.errorCode` = `backup.payload.undecryptable`. Nothing was changed." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "500": + description: The copy decrypted and then failed schema validation — a document this instance wrote and cannot parse. + This one genuinely is a server fault, and is reported as one. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/admin/backups/{id}/restore: + post: + tags: + - Admin + summary: Restore one stored backup over its owner's record + description: "Replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file + carries. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the + rest of its class. The target is the account the snapshot was taken for, never the admin running it, and a + payload declaring a different owner than the stored row is refused. Admin session cookie required; Bearer tokens + cannot reach admin endpoints." + parameters: + - in: path + name: id + schema: + type: string + description: "`DataBackup.id`." + required: true + description: "`DataBackup.id`." + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupRestoreRequest" + responses: + "200": + description: The restore ran. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupRestoreResponse" + "400": + description: Body was not JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": *a43 + "404": *a44 + "409": + description: Either the payload declares a different owner than the stored row, or a request under the same + `Idempotency-Key` is still in flight. Nothing was changed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: Body exceeds 64 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The request or the file was refused before anything was written: `confirm` missing, the copy undecryptable + (`meta.errorCode` = `backup.payload.undecryptable`), the payload failing schema validation, an unsupported + `schemaVersion`, a section the file's own manifest says it carries and does not (`meta.errorCode` = + `backup.section.missing`), a metadata-only document entry, or an owner who no longer exists here." content: application/json: schema: @@ -28096,6 +28376,14 @@ components: type: string minLength: 1 maxLength: 64 + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - OTHER + - type: "null" required: - displayName - locale @@ -28105,12 +28393,58 @@ components: The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an - optional `YYYY-MM-DD` and is never synthesised from a year. `locale` and `timezone` belong to the RECORD and not - to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. - Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated + optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values + `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both + mean not recorded, and the value is what the cycle module derives its default from, so a record created for a + child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the + Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an + unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller." + UpdateManagedProfileRequest: + type: object + properties: + displayName: + type: string + minLength: 1 + maxLength: 80 + dateOfBirth: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + locale: + type: string + enum: + - de + - en + - es + - fr + - it + - pl + - ko + timezone: + type: string + minLength: 1 + maxLength: 64 + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - OTHER + - type: "null" + additionalProperties: false + description: "Change what a managed record is, from the Guardian's own panel. The same five fields creation takes, every + one of them optional, and at least one required — an empty body is a 422 rather than a no-op that audits a + change nobody made. Absence means leave the field alone; an explicit null on `dateOfBirth` or `gender` clears + it, and the two are different answers. Nothing else on the record is reachable here: credentials, provider + connections, notification routing and the health profile are not identity, and the wider configuration surface + is `PATCH /api/record-settings/{family}`, which is guardian-fenced and answers only while switched into the + record. Strict, cookie-only and step-up gated, like every act in this family." InviteManagedProfileGuardianRequest: type: object properties: @@ -28375,10 +28709,15 @@ components: vaccinations: type: boolean additionalProperties: false - required: - - modulePreferences + cycleTrackingEnabled: + type: boolean additionalProperties: false - description: Partial module toggle. Merged onto the record's current preferences; omitted keys keep their value. + description: "Partial module toggle. `modulePreferences` is merged onto the record's current preferences; omitted keys + keep their value. `cycleTrackingEnabled` is separate because `cycle` is a DELEGATED module: its state lives in + the record's own cycle profile and `modulePreferences.cycle` is refused, so this is the field that turns cycle + tracking on or off for the record — an explicit boolean overrides the default the record's sex would derive. + Both fields are optional and at least one is required; an empty body is a 422 rather than a save that changed + nothing." RecordNotificationSettingsPatch: type: object properties: @@ -28915,6 +29254,21 @@ components: the same plausibility range the interactive create paths use. `idempotencyKey` is an identity, not a windowed replay token: it is stored on the row forever and matched by equality, so a key that rotates per client launch never matches its own earlier row and every re-sync logs the dose again." + AdminBackupRestoreRequest: + type: object + properties: + confirm: + type: string + const: RESTORE + restoreInstanceSettings: + type: boolean + required: + - confirm + description: "`confirm` is the literal typed gate; anything else is refused with 422 and nothing is read. + `restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and + leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module + availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only + when rebuilding a host from a snapshot, because those settings apply to every account on the installation." MedicationTreatmentClass: type: string enum: @@ -29099,19 +29453,63 @@ components: type: "null" error: type: string + details: + description: "Present on a 422 raised by the multi-issue validator: every field the request got wrong, not only the + first. Absent on every other status." + type: object + properties: + issues: + type: array + items: + $ref: "#/components/schemas/ValidationIssue" + required: + - issues + additionalProperties: false meta: type: object properties: requestId: type: string errorCode: + description: Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be + reworded. type: string - additionalProperties: false + additionalProperties: {} required: - data - error additionalProperties: false - description: "Standard error response: data is null, error is human prose." + description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable + machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue + validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in + one round-trip." + ValidationIssue: + type: object + properties: + path: + type: string + description: Dot-joined path to the rejected field, e.g. `entries.3.measuredAt`. Empty string when the whole body was + rejected. + code: + type: string + description: Zod issue code, e.g. `invalid_type`, `too_small`, `unrecognized_keys`. Treat an unfamiliar code as a + generic rejection of `path`. + message: + type: string + description: English sentence describing the rejection. Safe to log; show the user your own wording keyed on `path`. + keys: + description: "Only on `unrecognized_keys`: the refused key names, bounded to 10 and 64 characters each. Zod reports + every unknown key of one object in a single issue whose `path` is the object, so the names live here and + nowhere in `path`." + type: array + items: + type: string + required: + - path + - code + - message + additionalProperties: false + description: "One rejected field in a multi-issue 422. Sanitised: no rejected VALUE is echoed back." CycleCustomSymptomCreatedEnvelope: type: object properties: @@ -37364,10 +37762,45 @@ components: - upserts - tombstones additionalProperties: false + cycleDays: + type: object + properties: + upserts: + type: array + items: + $ref: "#/components/schemas/CycleDayLogDTO" + tombstones: + type: array + items: + $ref: "#/components/schemas/SyncCycleDayTombstone" + required: + - upserts + - tombstones + additionalProperties: false + description: Cycle day logs. Present on every page, empty arrays when the account has the cycle module off. + cycles: + type: object + properties: + upserts: + type: array + items: + $ref: "#/components/schemas/MenstrualCycleDTO" + tombstones: + type: array + items: + $ref: "#/components/schemas/SyncCycleTombstone" + required: + - upserts + - tombstones + additionalProperties: false + description: Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has + the cycle module off. required: - measurements - mood - intakes + - cycleDays + - cycles additionalProperties: false required: - serverNow @@ -37376,9 +37809,10 @@ components: - cursorExpired - changes additionalProperties: false - description: "Multi-domain delta page (v1.7.0): measurements + mood + intakes. One opaque multi-domain keyset cursor; - tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, mood + - intakes on server id. The iOS consumer is measurements-only this cycle; mood + intakes are forward-prep." + description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain + keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on + externalId, every other domain on server id. All five keys are present on every response, including the + `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted." SyncMeasurementUpsert: type: object properties: @@ -37720,6 +38154,60 @@ components: - updatedAt additionalProperties: false description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page. + SyncCycleDayTombstone: + type: object + properties: + id: + type: string + externalId: + anyOf: + - type: string + - type: "null" + description: The client's own key for the day log, when it set one. + syncVersion: + type: integer + minimum: 0 + maximum: 9007199254740991 + deletedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - externalId + - syncVersion + - deletedAt + - updatedAt + additionalProperties: false + description: A soft-deleted cycle day log, keyed on server `id`. Apply before upserts within the domain page. + SyncCycleTombstone: + type: object + properties: + id: + type: string + syncVersion: + type: integer + minimum: 0 + maximum: 9007199254740991 + deletedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - syncVersion + - deletedAt + - updatedAt + additionalProperties: false + description: A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page. GetMeasurementsSeriesBatchResponse: type: object properties: @@ -43832,9 +44320,13 @@ components: - nutrients - vaccinations additionalProperties: false - description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means - the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; - `coach` mirrors the resolved per-user opt-out + operator master flag. + description: Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface + should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` + mirrors the resolved per-user opt-out + operator master flag. On `GET /api/auth/me` it answers for the RECORD + the session is inside rather than for the caller, because every surface it gates shows the record's data, and it + is masked to the sections the active grant opens — a module outside them reads `false` rather than the record's + true state. With no switch — which is every native request, since the Bearer transport carries none — the two + are the same account, nothing is masked and the payload is unchanged. PatchModulesResponse: type: object properties: @@ -48690,6 +49182,132 @@ components: - data - error additionalProperties: false + AdminReminderCheckResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AdminReminderCheckResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AdminReminderCheckResult: + type: object + properties: + timestamp: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + missedThresholdMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scoped: + type: boolean + description: True when the request named a `userId` and the sweep therefore walked one account only. + medications: + type: array + items: + $ref: "#/components/schemas/AdminReminderMedication" + notificationsSent: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + message: + type: string + required: + - timestamp + - missedThresholdMinutes + - scoped + - medications + - notificationsSent + - message + additionalProperties: false + description: What the sweep found and what it sent. `notificationsSent` counts DISPATCHES, not deliveries — each one + enters the channel cascade and the per-channel verdict lands in the `push_attempts` ledger the notification + diagnostic surfaces. + AdminReminderMedication: + type: object + properties: + name: + type: string + dose: + type: string + user: + type: string + timezone: + type: string + localTime: + type: string + dayOfWeek: + type: string + notificationsEnabled: + type: boolean + schedules: + type: array + items: + $ref: "#/components/schemas/AdminReminderScheduleStatus" + eventsToday: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - name + - dose + - user + - timezone + - localTime + - dayOfWeek + - notificationsEnabled + - schedules + - eventsToday + additionalProperties: false + description: One active medication the sweep walked, with the account it belongs to and that account's local clock — the + sweep classifies every window against the owner's timezone, never the operator's. + AdminReminderScheduleStatus: + type: object + properties: + window: + type: string + days: + type: string + status: + type: string + enum: + - open + - late + - threshold + - missed + - skipped + label: + type: string + minutesPastEnd: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + notificationSent: + type: boolean + required: + - window + - days + - status + - label + - minutesPastEnd + - notificationSent + additionalProperties: false + description: One dose window as the sweep classified it against the OWNING account's wall clock, and whether this run + dispatched for it. `notificationSent` is false for a window that is still open, for a day the schedule does not + cover, and for a slot that already has an intake event today. AdminDiagnosticResponse: type: object properties: @@ -65724,20 +66342,44 @@ components: type: string accountAccess: $ref: "#/components/schemas/AccountAccess" + moduleAccess: + $ref: "#/components/schemas/ModuleAccessMap" recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" - type: "null" + notificationPrefs: + type: object + properties: + medication: + type: object + properties: + clientManaged: + type: boolean + required: + - clientManaged + additionalProperties: {} + required: + - medication + additionalProperties: {} required: - id - username - accountAccess + - moduleAccess - recordSession + - notificationPrefs additionalProperties: {} description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. - Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this - payload still describes the CALLER — their preferences, their modules, their identity — because display - preferences belong to the person at the keyboard rather than to the record they are reading." + Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the + identity and preference fields still describe the CALLER, because display preferences belong to the person at + the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and + `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and + they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it + rather than the record's true state. With no switch — which is every native request, since the Bearer transport + carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is + not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the + `modules` booleans it never contradicts." AccountAccess: type: object properties: @@ -65942,6 +66584,191 @@ components: be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field." + ModuleAccessMap: + type: object + properties: + cycle: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "cycle" module is or is not available here. + mood: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mood" module is or is not available here. + sleep: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "sleep" module is or is not available here. + glucose: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "glucose" module is or is not available here. + workouts: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "workouts" module is or is not available here. + recovery: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "recovery" module is or is not available here. + labs: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "labs" module is or is not available here. + illness: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "illness" module is or is not available here. + achievements: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "achievements" module is or is not available here. + coach: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "coach" module is or is not available here. + insights: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "insights" module is or is not available here. + medications: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "medications" module is or is not available here. + doctorReport: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "doctorReport" module is or is not available here. + environment: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "environment" module is or is not available here. + mcp: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mcp" module is or is not available here. + inboundDocuments: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "inboundDocuments" module is or is not available here. + mentalHealth: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mentalHealth" module is or is not available here. + nutrients: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "nutrients" module is or is not available here. + vaccinations: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "vaccinations" module is or is not available here. + required: + - cycle + - mood + - sleep + - glucose + - workouts + - recovery + - labs + - illness + - achievements + - coach + - insights + - medications + - doctorReport + - environment + - mcp + - inboundDocuments + - mentalHealth + - nutrients + - vaccinations + additionalProperties: false + description: "Why each toggleable module is or is not available for the record this session is inside. `enabled` — the + module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the + active grant's sections do not open the module's section, which also covers every module that reads across the + whole record (achievements, the assistant surfaces, the export, environment, the account's own MCP endpoint) + since no scoped grant opens one. `unavailable` — the operator switched the module off for the whole instance. + Precedence, highest first: `unavailable` > `not_granted` > `disabled` > `enabled`, so the reason published is + the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`. + Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this + field adds a reason, never a different gate." RecordSessionState: type: object properties: @@ -66360,13 +67187,31 @@ components: description: The profile's account id — the value `POST /api/account/switch` and the `X-HealthLog-Account` selector take, and the `{id}` in this family's paths. displayName: - type: string + anyOf: + - type: string + - type: "null" dateOfBirth: anyOf: - type: string - type: "null" + description: "`YYYY-MM-DD`, or null. The same date-only shape the request takes — this is a calendar date, not an + instant, and publishing it with a time would invite a client to apply a zone to it." + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - OTHER + - type: "null" + description: "`User.gender` for this record. Null means not recorded, which is a real answer and not a missing one." locale: - type: string + anyOf: + - type: string + - type: "null" + description: The record's own language. Every profile this family creates carries one, because creation requires it; the + column is nullable, so null means a row written before that and a client renders its own language rather + than the word. timezone: type: string recordKind: @@ -66378,15 +67223,16 @@ components: - id - displayName - dateOfBirth + - gender - locale - timezone - recordKind additionalProperties: false - description: "A record somebody looks after, as its creation answers with it. There is deliberately no list endpoint: - the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, - because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change - here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner - until the next boot." + description: "A record somebody looks after, as its creation, its read and its edit all answer with it. There is + deliberately no list endpoint: the profiles a Guardian looks after are the `managed` entries of + `accountAccess.accounts` on GET /api/auth/me, because a Guardian's relationship to a profile IS a MANAGE grant. + Re-read the account payload after any change here; a client that refreshed only its own panel would leave the + new record out of the switcher and the banner until the next boot." DeletedManagedProfileEnvelope: type: object properties: @@ -68370,6 +69216,90 @@ components: - data - error additionalProperties: false + AdminBackupDocument: + type: object + propertyNames: + type: string + additionalProperties: {} + description: "The backup payload as the writer serialised it: `schemaVersion`, `exportedAt`, `userId`, and one array or + object per serialised class." + AdminBackupRestoreResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AdminBackupRestoreResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AdminBackupRestoreResult: + type: object + properties: + restored: + type: boolean + const: true + summary: + type: object + propertyNames: + type: string + additionalProperties: {} + skipped: + type: object + properties: + catalogueKeys: + type: array + items: + $ref: "#/components/schemas/AdminBackupRestoreSkipEntry" + links: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - catalogueKeys + - links + additionalProperties: false + cleared: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - restored + - summary + - skipped + - cleared + additionalProperties: false + description: What the transaction wrote. `cleared` counts the rows each class held before it was rebuilt from the file, + `summary` counts what the file carried, and `skipped.links` is the number of links dropped because a catalogue + key in the file does not exist here — zero is the normal answer. + AdminBackupRestoreSkipEntry: + type: object + properties: + catalogue: + type: string + key: + type: string + links: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - catalogue + - key + - links + additionalProperties: false + description: One catalogue key the file referenced and this instance could not resolve, with the number of links it cost. __schema0: anyOf: - type: string diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 64e7854e9f..2b6fe2af90 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -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 * *` — diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts new file mode 100644 index 0000000000..ec8cb61bc7 --- /dev/null +++ b/e2e/backup-restore-journey.spec.ts @@ -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 { + return page.evaluate( + async ([weightKg, pulseBpm, medicationName]: [number, number, string]) => { + const post = async (path: string, body: unknown): Promise => { + 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<>endobj\n" + + "2 0 obj<>endobj\n" + + "3 0 obj<>endobj\n" + + "xref\n0 4\ntrailer<>\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 { + 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 { + 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); + }); +}); diff --git a/e2e/doctor-report-delegate.spec.ts b/e2e/doctor-report-delegate.spec.ts new file mode 100644 index 0000000000..204734eeaf --- /dev/null +++ b/e2e/doctor-report-delegate.spec.ts @@ -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 { + 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, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = { + "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); + }); +}); diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts new file mode 100644 index 0000000000..c11fdee638 --- /dev/null +++ b/e2e/doctor-report.spec.ts @@ -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 { + return page.evaluate( + async (fixture) => { + const send = async ( + method: string, + path: string, + body: unknown, + ): Promise => { + const res = await fetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.status; + }; + + const readList = async (path: string): Promise => { + 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 { + 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 { + 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 { + 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 { + try { + return (await response.text()).slice(0, 400); + } catch { + return ""; + } +} + +/** 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 { + 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); + }); +}); diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts new file mode 100644 index 0000000000..d745c670a8 --- /dev/null +++ b/e2e/medication-compliance-journey.spec.ts @@ -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 { + 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( + page: Page, + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + body?: unknown, +): Promise> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + 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( + 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( + 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); + }); +}); diff --git a/e2e/mfa-step-up-journey.spec.ts b/e2e/mfa-step-up-journey.spec.ts new file mode 100644 index 0000000000..7a42acb957 --- /dev/null +++ b/e2e/mfa-step-up-journey.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + }); + } + }); +}); diff --git a/e2e/notification-dispatch-journey.spec.ts b/e2e/notification-dispatch-journey.spec.ts new file mode 100644 index 0000000000..ea8b68a908 --- /dev/null +++ b/e2e/notification-dispatch-journey.spec.ts @@ -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( + 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 { + const res = await fromPage( + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const res = await fromPage( + 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=.`, + ).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([]); + }); +}); diff --git a/e2e/setup/backup-journey-fixture.ts b/e2e/setup/backup-journey-fixture.ts new file mode 100644 index 0000000000..4588c41d70 --- /dev/null +++ b/e2e/setup/backup-journey-fixture.ts @@ -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(fn: (pool: pg.Pool) => Promise): Promise { + 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 { + 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 { + 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..` + * (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 { + 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 { + 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 { + 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, + })); + }); +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 9ee0f42ecf..8823334ecc 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -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 { 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 { [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 { 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 { [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 { 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 { ["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 { * 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 { // 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 { 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. // diff --git a/e2e/setup/medication-fixture.ts b/e2e/setup/medication-fixture.ts new file mode 100644 index 0000000000..86c85e3a0d --- /dev/null +++ b/e2e/setup/medication-fixture.ts @@ -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 { + 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, +): Promise { + 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, +): Promise { + 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(); +} diff --git a/e2e/setup/mfa-journey-fixture.ts b/e2e/setup/mfa-journey-fixture.ts new file mode 100644 index 0000000000..3718f969a7 --- /dev/null +++ b/e2e/setup/mfa-journey-fixture.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/e2e/setup/notification-fixture.ts b/e2e/setup/notification-fixture.ts new file mode 100644 index 0000000000..cd48bae00a --- /dev/null +++ b/e2e/setup/notification-fixture.ts @@ -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(fn: (pool: pg.Pool) => Promise): Promise { + 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 { + 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 { + 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}`, + ], + ]); + }); +} diff --git a/e2e/setup/notification-server.ts b/e2e/setup/notification-server.ts new file mode 100644 index 0000000000..053d30205e --- /dev/null +++ b/e2e/setup/notification-server.ts @@ -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}`; diff --git a/e2e/setup/smtp-stub.ts b/e2e/setup/smtp-stub.ts new file mode 100644 index 0000000000..d63ea8e5a1 --- /dev/null +++ b/e2e/setup/smtp-stub.ts @@ -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; +} + +const CRLF = "\r\n"; + +/** Start the responder and resolve once it is accepting connections. */ +export async function startSmtpStub(): Promise { + 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(); + + 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 ."); + } 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((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((resolve) => { + for (const socket of open) socket.destroy(); + open.clear(); + server.close(() => resolve()); + }), + }; +} diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 024f9199e2..6d9f4c65d8 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -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"; diff --git a/messages/de.json b/messages/de.json index 6256bc25b6..fca6667b94 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 3ccc183185..5891d7ddf1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 7ba0835c3d..990f5c4141 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 047d580a10..eee2b3cbdc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index c8b4e49d4d..f550c93a04 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index 4eb23051d0..641082b675 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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} 님에 대해 기록한 모든 것이 프로필과 함께 사라져요. 측정값, 약, 문서, 메모 전부요. 되돌릴 수 없어요.", diff --git a/messages/pl.json b/messages/pl.json index ae08e819ed..1caaad0a39 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ąć.", diff --git a/package.json b/package.json index e5f06bd40d..e3401361b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 4bd5961d63..1becdc7167 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, + }, + }, ], }); diff --git a/public/sw.js b/public/sw.js index 2233e7219d..45a57b7784 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts index 5d6714b181..c9708f90a4 100644 --- a/scripts/__tests__/ci-determinism.test.ts +++ b/scripts/__tests__/ci-determinism.test.ts @@ -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>; - 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", () => { diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index dffe39795c..011d629888 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -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([]); + }); }); diff --git a/src/__tests__/api/auth/check-user/route.test.ts b/src/__tests__/api/auth/check-user/route.test.ts index 7cd4e5eaea..8ae3cbd406 100644 --- a/src/__tests__/api/auth/check-user/route.test.ts +++ b/src/__tests__/api/auth/check-user/route.test.ts @@ -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", diff --git a/src/__tests__/idempotency-contract-publication.test.ts b/src/__tests__/idempotency-contract-publication.test.ts index 442aadbbd3..74af2402a8 100644 --- a/src/__tests__/idempotency-contract-publication.test.ts +++ b/src/__tests__/idempotency-contract-publication.test.ts @@ -124,10 +124,7 @@ function idempotentOperationsFromContract(): Set { * Listed rather than filtered silently, so the exclusion stays visible in the * file that would otherwise claim full coverage. */ -const UNPUBLISHED_PATHS: Readonly> = { - "/api/admin/backups/{id}/restore": - "Admin surface: cookie-only by construction, so arguably out of scope for a client contract.", -}; +const UNPUBLISHED_PATHS: Readonly> = {}; describe("idempotency — the contract matches the wrapper", () => { it("finds idempotent operations in the source tree at all", () => { diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 82480859c5..4e7c47b74e 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -217,14 +217,10 @@ const UNPUBLISHED: Readonly> = { "/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> = { }, "/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"] }, diff --git a/src/__tests__/record-settings-configuration.test.ts b/src/__tests__/record-settings-configuration.test.ts index a881aaabfa..2bbf98387b 100644 --- a/src/__tests__/record-settings-configuration.test.ts +++ b/src/__tests__/record-settings-configuration.test.ts @@ -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", { diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx index 8610e31de2..93ac58b024 100644 --- a/src/__tests__/shared-record-navigation.test.tsx +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -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"]); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 8074d5d9bc..b991b8cabd 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -78,7 +78,10 @@ const PINNED_AFFORDANCES: Record< string, Partial> > = { - "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, diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts index 0e55b6f7cf..6443137268 100644 --- a/src/app/api/admin/backups/[id]/download/route.ts +++ b/src/app/api/admin/backups/[id]/download/route.ts @@ -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 diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3deab49d46..409a5b3459 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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 diff --git a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts index 51441643c4..766e0d4dec 100644 --- a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts index 3ae840d804..4b79fcc792 100644 --- a/src/app/api/admin/backups/[id]/summary/route.ts +++ b/src/app/api/admin/backups/[id]/summary/route.ts @@ -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; diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 985e82e226..04a8c21399 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -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//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. diff --git a/src/app/api/admin/notifications/reminder-check/__tests__/route.test.ts b/src/app/api/admin/notifications/reminder-check/__tests__/route.test.ts new file mode 100644 index 0000000000..25d5a54c10 --- /dev/null +++ b/src/app/api/admin/notifications/reminder-check/__tests__/route.test.ts @@ -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( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + 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 { + const call = vi.mocked(prisma.medication.findMany).mock.calls.at(-1); + return (call?.[0] as { where: Record }).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(); + }); +}); diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index 784b7d4dd8..0124e0c898 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -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: diff --git a/src/app/api/auth/me/diabetes/__tests__/route.test.ts b/src/app/api/auth/me/diabetes/__tests__/route.test.ts index 5919ff3860..0cee31013d 100644 --- a/src/app/api/auth/me/diabetes/__tests__/route.test.ts +++ b/src/app/api/auth/me/diabetes/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts index 8042a65c6e..5ed9ad5b2a 100644 --- a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts +++ b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts index 943f37294f..a699059cbe 100644 --- a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts +++ b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts index 75d4ff20f8..da967879f1 100644 --- a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts +++ b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts index 7884d3f52d..6639267e61 100644 --- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts @@ -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(); }); }); diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 50d22d12ad..8e97182209 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -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 diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 8e701bf043..1251829d0a 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -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(); diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index 1e23957ac4..7a14aef252 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 088cecd53c..c8d5036498 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 425bfa33a5..c6a64acbea 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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. `{ : 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 diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts index 3d447e3d9d..ee4ade61c0 100644 --- a/src/app/api/auth/me/sessions/route.ts +++ b/src/app/api/auth/me/sessions/route.ts @@ -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 { 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); } diff --git a/src/app/api/c/[token]/unlock/__tests__/route.test.ts b/src/app/api/c/[token]/unlock/__tests__/route.test.ts index 7c0828da3a..e7cf2ef563 100644 --- a/src/app/api/c/[token]/unlock/__tests__/route.test.ts +++ b/src/app/api/c/[token]/unlock/__tests__/route.test.ts @@ -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", diff --git a/src/app/api/consent/ai/__tests__/route.test.ts b/src/app/api/consent/ai/__tests__/route.test.ts index cfb9c52c37..4a9cb42f3b 100644 --- a/src/app/api/consent/ai/__tests__/route.test.ts +++ b/src/app/api/consent/ai/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/consent/ai/latest/__tests__/route.test.ts b/src/app/api/consent/ai/latest/__tests__/route.test.ts index b21a15eebf..b486eb6b75 100644 --- a/src/app/api/consent/ai/latest/__tests__/route.test.ts +++ b/src/app/api/consent/ai/latest/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/consent/ai/web/__tests__/route.test.ts b/src/app/api/consent/ai/web/__tests__/route.test.ts index d646dcb1f0..baecb7a59f 100644 --- a/src/app/api/consent/ai/web/__tests__/route.test.ts +++ b/src/app/api/consent/ai/web/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts index d02cd4a08a..c139673177 100644 --- a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts @@ -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; take: number }; + .calls[0][0] as { + where: Record; + 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 () => { diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 71a54f7e0b..5fdd9ff4b6 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -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, }), diff --git a/src/app/api/daily/digest/dismiss/__tests__/route.test.ts b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts index 8c2ab3e05a..795f7ef8ce 100644 --- a/src/app/api/daily/digest/dismiss/__tests__/route.test.ts +++ b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index c5b3ba303a..6acb806595 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts index 5b6540011b..aa5b6c7d54 100644 --- a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/export/__tests__/measurement-streaming.test.ts b/src/app/api/export/__tests__/measurement-streaming.test.ts index 3fedbe375f..aa674b4604 100644 --- a/src/app/api/export/__tests__/measurement-streaming.test.ts +++ b/src/app/api/export/__tests__/measurement-streaming.test.ts @@ -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, }); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index f29671ba1a..f3ca604944 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -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, }); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 3294056290..bd4a5669c9 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -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, }); diff --git a/src/app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts b/src/app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts index 659d661596..6c2ae4d528 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts index 9d01977dfb..2202f338fd 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -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, diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 372a313ea3..e6dde951a5 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 0a110f9ed6..aa9ddb6d40 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 55516a66dd..5289fa0d18 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/internal/web-vitals/__tests__/route.test.ts b/src/app/api/internal/web-vitals/__tests__/route.test.ts index 81a792abfc..e3e1ac303e 100644 --- a/src/app/api/internal/web-vitals/__tests__/route.test.ts +++ b/src/app/api/internal/web-vitals/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index b420b745e7..97fc344ce5 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -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: { diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts index e952f5f808..b713e7ad13 100644 --- a/src/app/api/managed-profiles/[id]/route.ts +++ b/src/app/api/managed-profiles/[id]/route.ts @@ -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; } diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts index ddc059d68a..693a7af478 100644 --- a/src/app/api/managed-profiles/route.ts +++ b/src/app/api/managed-profiles/route.ts @@ -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); }); diff --git a/src/app/api/mcp/oauth/__tests__/token.test.ts b/src/app/api/mcp/oauth/__tests__/token.test.ts index 54bf4e4516..df17975e96 100644 --- a/src/app/api/mcp/oauth/__tests__/token.test.ts +++ b/src/app/api/mcp/oauth/__tests__/token.test.ts @@ -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, }); diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index 1abc2c847f..04de08df83 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -302,11 +302,7 @@ export async function GET(request: NextRequest): Promise { { 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 { { error: "temporarily_unavailable" }, { status: 429, - headers: rateLimitHeaders({ - allowed: false, - remaining: rl.remaining, - resetAt: rl.resetAt, - }), + headers: rateLimitHeaders(rl), }, ); } diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts index 046453158a..aa5d76a83b 100644 --- a/src/app/api/mcp/oauth/register/route.ts +++ b/src/app/api/mcp/oauth/register/route.ts @@ -80,11 +80,7 @@ export async function POST(request: NextRequest): Promise { { error: "temporarily_unavailable" }, { status: 429, - headers: rateLimitHeaders({ - allowed: false, - remaining: rl.remaining, - resetAt: rl.resetAt, - }), + headers: rateLimitHeaders(rl), }, ); } diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts index e9493db204..a82b732a58 100644 --- a/src/app/api/mcp/oauth/token/route.ts +++ b/src/app/api/mcp/oauth/token/route.ts @@ -222,11 +222,7 @@ export async function POST(request: NextRequest): Promise { { error: "temporarily_unavailable" }, { status: 429, - headers: rateLimitHeaders({ - allowed: false, - remaining: rl.remaining, - resetAt: rl.resetAt, - }), + headers: rateLimitHeaders(rl), }, ); } diff --git a/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts index 8483ec1f62..ecafa0b858 100644 --- a/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts +++ b/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts @@ -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" }); }); diff --git a/src/app/api/measurement-reminders/[id]/history/route.ts b/src/app/api/measurement-reminders/[id]/history/route.ts index 19872c4fc5..df16f4a938 100644 --- a/src/app/api/measurement-reminders/[id]/history/route.ts +++ b/src/app/api/measurement-reminders/[id]/history/route.ts @@ -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, }), diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index a46987f29f..3f766d9fbb 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -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, }); diff --git a/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts b/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts index b5e36298ce..7597199f4d 100644 --- a/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts +++ b/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts @@ -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, }); diff --git a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts index 9b551c5ac3..e7eb79f74c 100644 --- a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts +++ b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts @@ -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, }); diff --git a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts index 01f5cb72d4..a36974a0c8 100644 --- a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts +++ b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts @@ -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, }); diff --git a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts index fa834286fb..83a7416d53 100644 --- a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts +++ b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts @@ -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, }); diff --git a/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts b/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts index 5c79d949ef..8fdeb46da6 100644 --- a/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts +++ b/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts @@ -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, }); diff --git a/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts b/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts index e7369d8330..a68fdd0123 100644 --- a/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts +++ b/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts @@ -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, }); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index d8f76ada02..6dcfe54567 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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::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, }), diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 4b6de773f5..128bcfe878 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index ccc4f707d2..4a5c8fed89 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts index 5e6bbad290..3d52191ee6 100644 --- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 2555f8d7b4..60569dcb6e 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index 381421305f..c087df6f91 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index f4c5a06643..a66164c056 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 6d684b6674..635ee69f14 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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({ diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index cd9d4251a9..5231ae2af9 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts index e30b8372a0..ba27f803e4 100644 --- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/compliance/__tests__/route.test.ts b/src/app/api/medications/compliance/__tests__/route.test.ts index b947205d6d..8490762bb7 100644 --- a/src/app/api/medications/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/compliance/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts index b80464eb32..c01d578ee9 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 5ca9c9025a..1b63f0ed80 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -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", () => { diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index 1d780f051a..b6311acfc4 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -15,7 +15,11 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, - mentalHealthAssessment: { create: vi.fn(), findFirst: vi.fn() }, + mentalHealthAssessment: { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + }, measurement: { create: vi.fn().mockResolvedValue({}) }, }, })); @@ -60,7 +64,7 @@ vi.mock("next/headers", () => ({ })), })); -import { POST } from "../route"; +import { GET, POST } from "../route"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -96,6 +100,7 @@ beforeEach(() => { vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, + limit: 60, remaining: 29, resetAt: Date.now(), }); @@ -448,3 +453,25 @@ describe("POST /api/mental-health/assessments — external-id stability floor", expect(prisma.mentalHealthAssessment.create).toHaveBeenCalledTimes(1); }); }); + +describe("GET /api/mental-health/assessments", () => { + it("orders the history on a unique tiebreaker", async () => { + vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue( + [] as never, + ); + + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest( + new URL("http://localhost/api/mental-health/assessments"), + ), + ); + expect(res.status).toBe(200); + + // `takenAt` is day-resolution, so two instruments answered in one sitting + // tie; without the unique secondary key offset paging can repeat a row on + // one page and drop another. + const findArgs = vi.mocked(prisma.mentalHealthAssessment.findMany).mock + .calls[0][0] as { orderBy: unknown }; + expect(findArgs.orderBy).toEqual([{ takenAt: "desc" }, { id: "desc" }]); + }); +}); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index a37b223b3e..4229ecfd38 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -123,7 +123,9 @@ export const GET = apiHandler(async (request: NextRequest) => { deletedAt: null, ...(instrument ? { instrument } : {}), }, - orderBy: { takenAt: "desc" }, + // Unique tiebreaker — `takenAt` is day-resolution and two instruments + // taken in one sitting tie. + orderBy: [{ takenAt: "desc" }, { id: "desc" }], take: limit ?? 100, skip: offset ?? 0, select: { diff --git a/src/app/api/nutrients/water/__tests__/route.test.ts b/src/app/api/nutrients/water/__tests__/route.test.ts index 051a16d14a..354ce7cc29 100644 --- a/src/app/api/nutrients/water/__tests__/route.test.ts +++ b/src/app/api/nutrients/water/__tests__/route.test.ts @@ -101,6 +101,7 @@ beforeEach(() => { vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, + limit: 60, remaining: 59, resetAt: Date.now() + 60_000, }); @@ -137,6 +138,7 @@ describe("POST /api/nutrients/water", () => { it("returns 429 when the rate limit is exceeded", async () => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false, + limit: 60, remaining: 0, resetAt: Date.now() + 60_000, }); diff --git a/src/app/api/onboarding/__tests__/step.test.ts b/src/app/api/onboarding/__tests__/step.test.ts index 1700626a3a..5856891f49 100644 --- a/src/app/api/onboarding/__tests__/step.test.ts +++ b/src/app/api/onboarding/__tests__/step.test.ts @@ -36,6 +36,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, }), @@ -79,6 +80,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, + limit: 60, remaining: 29, resetAt: Date.now() + 60_000, }); @@ -243,6 +245,7 @@ describe("POST /api/onboarding/step — rate limit", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValueOnce({ allowed: false, + limit: 60, remaining: 0, resetAt: Date.now() + 60_000, }); diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index c18bf39198..0897158970 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -30,6 +30,8 @@ import { parseManagedRecordSettingsPatch, resolveGuardianRecordSettingsAccess, } from "@/lib/record-settings"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { resolveInsightsLayout, serializeInsightsLayout, @@ -101,13 +103,23 @@ export const GET = apiHandler( dateFormat: record.dateFormat, }; break; - case "modules": + case "modules": { + // The delegated key needs its real source, which is not the module + // blob. `isCycleEnabled` resolves the record's own opt-in against its + // sex exactly as `/api/auth/me` does for the account itself, so the + // switch a guardian sees is the state the gate will answer with. + const cycleProfile = await prisma.cycleProfile.findUnique({ + where: { userId: access.recordId }, + select: { cycleTrackingEnabled: true }, + }); settings = { modulePreferences: managedModulePreferencesFrom( record.modulePreferencesJson, ), + cycleTrackingEnabled: isCycleEnabled(record.gender, cycleProfile), }; break; + } case "notifications": { const preferences = parseNotificationPrefs(record.notificationPrefs); settings = { @@ -233,19 +245,50 @@ export const PATCH = apiHandler( const patch = parseManagedRecordSettingsPatch("modules", body); const current = await prisma.user.findUnique({ where: { id: access.recordId }, - select: { modulePreferencesJson: true }, + select: { modulePreferencesJson: true, gender: true }, }); const modulePreferences = { ...managedModulePreferencesFrom(current?.modulePreferencesJson), ...patch.modulePreferences, }; - await prisma.user.update({ - where: { id: access.recordId }, - data: { modulePreferencesJson: toJson(modulePreferences) }, + if (patch.modulePreferences !== undefined) { + await prisma.user.update({ + where: { id: access.recordId }, + data: { modulePreferencesJson: toJson(modulePreferences) }, + }); + } + // The delegated key writes its own column. An explicit boolean is + // what the cycle gate honours over the sex-derived default, so a + // guardian turning Cycle off for a record whose sex says otherwise + // is recorded as the deliberate answer it is. + if (patch.cycleTrackingEnabled !== undefined) { + await getOrCreateCycleProfile(access.recordId); + await prisma.cycleProfile.update({ + where: { userId: access.recordId }, + data: { cycleTrackingEnabled: patch.cycleTrackingEnabled }, + }); + } + const cycleProfile = await prisma.cycleProfile.findUnique({ + where: { userId: access.recordId }, + select: { cycleTrackingEnabled: true }, }); invalidateUserHealthScore(access.recordId); - settings = { modulePreferences }; - changed = Object.keys(patch.modulePreferences); + settings = { + modulePreferences, + cycleTrackingEnabled: isCycleEnabled( + current?.gender ?? null, + cycleProfile, + ), + }; + // The module keys themselves, not the wrapper field: the record's + // trail should say which module moved. `cycleTrackingEnabled` is + // named as itself because that is the column it writes. + changed = [ + ...Object.keys(patch.modulePreferences ?? {}), + ...(patch.cycleTrackingEnabled !== undefined + ? ["cycleTrackingEnabled"] + : []), + ]; break; } case "notifications": { diff --git a/src/app/api/user/avatar/__tests__/route.test.ts b/src/app/api/user/avatar/__tests__/route.test.ts index 0cfb500ee3..72e005dce2 100644 --- a/src/app/api/user/avatar/__tests__/route.test.ts +++ b/src/app/api/user/avatar/__tests__/route.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, + limit: 60, remaining: 9, resetAt: Date.now() + 3_600_000, }), @@ -89,6 +90,7 @@ beforeEach(() => { // Default rate-limit pass-through; per-test overrides drop in below. vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, + limit: 60, remaining: 9, resetAt: Date.now() + 3_600_000, }); @@ -210,6 +212,7 @@ describe("POST /api/user/avatar", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValueOnce({ allowed: false, + limit: 60, remaining: 0, resetAt: Date.now() + 30_000, }); diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index 05a5dba93f..37e971e2a4 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -145,6 +145,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, }); @@ -218,6 +219,7 @@ describe("POST /api/workouts/batch — auth + size + rate-limit", () => { it("returns 429 when the rate-limit gate blocks the call", async () => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false, + limit: 60, remaining: 0, resetAt: Date.now() + 60_000, }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index eede46032f..2ece6eabc0 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -424,6 +424,7 @@ export default function LoginPage() { {mode === "passkey" ? ( + {guardians.data && ( remove.mutate(profile.accountId)} /> - - )} + )} + + {editing && ( + setEditing(false)} + /> + )} {failed && ( (actorLocale); + const [gender, setGender] = useState(null); const [timezone, setTimezone] = useState(() => browserTimezone()); const [error, setError] = useState(null); const [created, setCreated] = useState(null); @@ -95,14 +106,16 @@ export function ManagedProfileCreateForm() { dateOfBirth: dateOfBirth.length > 0 ? dateOfBirth : null, locale, timezone, + gender, }, { onSuccess: (profile) => { setDisplayName(""); setDateOfBirth(""); setLocale(actorLocale); + setGender(null); setTimezone(browserTimezone()); - setCreated(profile.displayName); + setCreated(profile.displayName ?? trimmedName); }, // Nothing is cleared here, on purpose: a refused creation is one the // person is being asked to retry, and a form that emptied itself @@ -193,6 +206,28 @@ export function ManagedProfileCreateForm() {

+
+ + setGender(genderFromSelect(e.target.value))} + > + {MANAGED_PROFILE_GENDER_OPTIONS.map((option) => ( + + ))} + +

+ {t("recordSharing.managed.genderHint")} +

+
+
= [ + { value: "", labelKey: "recordSharing.managed.genderNone" }, + { value: "MALE", labelKey: "recordSharing.managed.genderMale" }, + { value: "FEMALE", labelKey: "recordSharing.managed.genderFemale" }, + { value: "OTHER", labelKey: "recordSharing.managed.genderOther" }, +]; + +/** + * The select's string, as the route's enum. + * + * Exported and pure because the empty option is the one that matters: it means + * NULL — "not recorded" — and a form that sent `""` would be refused by the + * enum for a choice the person deliberately made. + */ +export function genderFromSelect(value: string): ManagedProfileGender { + return value === "MALE" || value === "FEMALE" || value === "OTHER" + ? value + : null; +} + /** The browser's zone, or the app default when the engine will not say. */ function browserTimezone(): string { const detected = detectBrowserTimezone(); diff --git a/src/components/settings/access/managed-profile-edit-form.tsx b/src/components/settings/access/managed-profile-edit-form.tsx new file mode 100644 index 0000000000..2245f61c33 --- /dev/null +++ b/src/components/settings/access/managed-profile-edit-form.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Loader2 } from "lucide-react"; + +import { + FieldGuidance, + MANAGED_PROFILE_GENDER_OPTIONS, + displayNameIssue, + genderFromSelect, +} from "@/components/settings/access/managed-profile-create-form"; +import { stepUpErrorKey } from "@/components/settings/access/grant-action-error"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { TimezonePicker } from "@/components/settings/timezone-picker"; +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { ApiError } from "@/lib/api/api-fetch"; +import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; +import { useTranslations } from "@/lib/i18n/context"; +import { + useManagedProfile, + useUpdateManagedProfile, + type ManagedProfileGender, + type ManagedProfileView, + type UpdateManagedProfileInput, +} from "@/lib/queries/use-managed-profiles"; +import { isValidTimezone } from "@/lib/tz/format"; + +/** + * v1.38.14 (#939) — changing a record after it exists. + * + * ## Why the form loads before it renders + * + * The account payload names a managed record and nothing else, so this form + * cannot be filled from what the card already holds. It reads + * `GET /api/managed-profiles/{id}` first and paints nothing editable until + * that answers. The alternative — open the fields blank and send whatever is + * in them — is how a record loses its timezone to an edit of its name, and it + * would be invisible: the write would succeed. + * + * The read is `enabled` only while the form is open, so a Guardian who looks + * after four records does not fetch four identities to paint a list that shows + * none of them. + * + * ## Why it sends only what moved + * + * `PATCH` reads an absent key as "leave it" and an explicit null on the two + * nullable fields as "clear it". The diff below is what keeps those two + * different: a form that always sent five fields would overwrite a timezone + * somebody changed in the record's own settings while this form sat open, with + * the value it read minutes earlier. It also makes the audit row honest — + * `changed` is derived from the body, so "the guardian renamed the record" is + * what the record's trail says when that is what happened. + * + * ## The step-up is the expected first answer + * + * Same family, same gate as creating and deleting: `requireFreshMfa`, so the + * first save in a session is a 401 carrying `auth.stepup.required`. It gets + * its own sentence naming the next step rather than the generic failure, which + * would send somebody looking for a problem with what they typed. + */ +export function ManagedProfileEditForm({ + profileId, + onDone, +}: { + profileId: string; + /** Called after a save lands, so the row can close the form. */ + onDone: () => void; +}) { + const { t } = useTranslations(); + const profile = useManagedProfile(profileId); + + if (profile.isError) { + return ( + void profile.refetch()} + /> + ); + } + + if (!profile.data) { + return ( +

+

+ ); + } + + // Keyed on WHICH record is open, so the field state below is seeded once per + // record rather than through an effect that re-seeds on every refetch. The + // key was the query's `dataUpdatedAt`, which did the opposite of what it + // claimed: any invalidation of the `managed-profiles` prefix — removing a + // guardian from another row, say — refetches this read, moves the timestamp + // and remounts the fields, so a half-typed name disappears while somebody is + // looking at it. The id is stable for as long as the form is about the same + // record, which is exactly as long as the seeding should hold. + return ( + + ); +} + +function ManagedProfileEditFields({ + profile, + onDone, +}: { + profile: ManagedProfileView; + onDone: () => void; +}) { + const { t, locale: actorLocale } = useTranslations(); + const update = useUpdateManagedProfile(); + + const [displayName, setDisplayName] = useState(profile.displayName ?? ""); + const [dateOfBirth, setDateOfBirth] = useState(profile.dateOfBirth ?? ""); + // A record with no recorded language opens on the actor's, which is the same + // default the creation form uses. It is a starting point for a control the + // person is looking at, not an inference written behind their back — nothing + // is sent unless they move it, which is why the seed is frozen at mount and + // handed to the patch builder as what "moved" is measured against. + const [seededLocale] = useState(() => + isLocale(profile.locale) ? profile.locale : actorLocale, + ); + const [locale, setLocale] = useState(seededLocale); + const [gender, setGender] = useState(profile.gender); + const [timezone, setTimezone] = useState(profile.timezone); + const [error, setError] = useState(null); + + const nameIssue = displayNameIssue(displayName); + const timezoneIssue = isValidTimezone(timezone) + ? null + : "recordSharing.managed.timezoneInvalid"; + const patch = managedProfileEditPatch( + profile, + { + displayName, + dateOfBirth, + locale, + gender, + timezone, + }, + seededLocale, + ); + const blocked = + nameIssue !== null || timezoneIssue !== null || patch === null; + + const submit = (event: FormEvent) => { + event.preventDefault(); + // `blocked` already covers it; narrowed again so the spread below is typed + // rather than asserted. + if (blocked || patch === null || update.isPending) return; + setError(null); + update.mutate( + { profileId: profile.id, ...patch }, + { + onSuccess: onDone, + // Nothing is reset: a refused save is one somebody is being asked to + // retry, and a form that restored the server's values would throw the + // edit away to explain that it did not land. + onError: (err) => setError(t(editManagedProfileErrorKey(err))), + }, + ); + }; + + return ( +
+
+ + { + setDisplayName(e.target.value); + setError(null); + }} + autoComplete="off" + maxLength={80} + aria-invalid={nameIssue !== null ? "true" : undefined} + /> + +
+ +
+ + +
+ +
+ + setLocale(e.target.value as Locale)} + > + {locales.map((option) => ( + + ))} + +
+ +
+ + setGender(genderFromSelect(e.target.value))} + > + {MANAGED_PROFILE_GENDER_OPTIONS.map((option) => ( + + ))} + +

+ {t("recordSharing.managed.genderHint")} +

+
+ +
+ { + setTimezone(next); + setError(null); + }} + hint={t("recordSharing.managed.timezoneHint")} + /> + +
+ + {/* Above the action row, per UI-STANDARDS §12: nothing follows it. */} + {error && ( +

+ {error} +

+ )} + + + + + +
+ ); +} + +/** Is this the record's language, as the app knows languages. */ +function isLocale(value: string | null): value is Locale { + return value !== null && (locales as readonly string[]).includes(value); +} + +/** The form's five controls, as strings, before they become a patch. */ +interface ManagedProfileEditDraft { + displayName: string; + dateOfBirth: string; + locale: Locale; + gender: ManagedProfileGender; + timezone: string; +} + +/** + * What actually changed, or `null` when nothing did. + * + * Exported and pure because it is the decision this form makes: the route + * refuses an empty body, so "nothing changed" has to be answered here rather + * than discovered as a 422, and a field the person did not touch must not ride + * along and overwrite a concurrent edit made in the record's own settings. + * The empty date field is `null` — "clear it" — and not the empty string, + * which the route's date format would refuse. + */ +export function managedProfileEditPatch( + profile: ManagedProfileView, + draft: ManagedProfileEditDraft, + /** + * The language the control opened on, which is the record's own or — for a + * record that has none — the actor's. Compared against instead of + * `profile.locale` because those two differ exactly when the record has no + * language: comparing against the column made every save of a record like + * that carry `locale`, so moving only the timezone wrote the actor's + * language into somebody else's record. + */ + seededLocale: Locale, +): Omit | null { + const displayName = draft.displayName.trim(); + const dateOfBirth = draft.dateOfBirth.length > 0 ? draft.dateOfBirth : null; + const patch: Omit = { + ...(displayName !== (profile.displayName ?? "") ? { displayName } : {}), + ...(dateOfBirth !== profile.dateOfBirth ? { dateOfBirth } : {}), + ...(draft.locale !== seededLocale ? { locale: draft.locale } : {}), + ...(draft.gender !== profile.gender ? { gender: draft.gender } : {}), + ...(draft.timezone !== profile.timezone + ? { timezone: draft.timezone } + : {}), + }; + return Object.keys(patch).length > 0 ? patch : null; +} + +/** + * The message for a refused edit. + * + * The arms creation carries plus the one it does not need, and with the same + * reasoning: the step-up arm is a gate rather than a failure and must not read + * as one, a 429 is a wait rather than a failure and reuses creation's own line, + * and a 422 is a disagreement between this form's bounds and the route's rather + * than something the person typed — the multi-issue envelope's `details.issues` + * never reach the browser. The 404 is this route's alone: the record can be + * ended by another Guardian while the form sits open. + */ +export function editManagedProfileErrorKey(err: unknown): string { + if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline"; + if (err.status === 401) return stepUpErrorKey(err); + if (err.status === 422) return "recordSharing.managed.errorInvalid"; + if (err.status === 429) return "recordSharing.managed.errorRateLimit"; + if (err.status === 404) return "recordSharing.managed.editErrorGone"; + return "recordSharing.managed.editErrorFailed"; +} diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 3cb4c98785..e612daae2e 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -484,6 +484,49 @@ function ProfileSettingsForm({ ); } +/** + * What a Modules save actually sends. + * + * The direct modules ride along whether or not they moved — they are one blob + * the route merges, and a key that did not change writes the value it already + * had. `cycleTrackingEnabled` is not one of them: it is a DELEGATED module + * whose column the cycle gate honours OVER the sex-derived default, so sending + * the switch's current position on every save freezes the derivation. A record + * created with no recorded sex derives cycle OFF; a guardian who turns Labs off + * and saves would write that OFF as an explicit answer, and the record's sex + * being set to FEMALE afterwards would then leave cycle off with nothing on + * screen to say why. It also put `cycleTrackingEnabled` in the record's audit + * trail on every save of any module. + * + * So it is sent only when it differs from the value the form opened on, which + * is what the managed-record edit form's `managedProfileEditPatch` already does + * for its own five fields. + * + * Exported and pure because it is the decision this form makes, and a decision + * about what NOT to send is invisible in the rendered markup. + */ +export function managedModulesPatch( + settings: Record, + form: FormData, +): { + modulePreferences: Record; + cycleTrackingEnabled?: boolean; +} { + const cycleTrackingEnabled = form.get("cycleTrackingEnabled") === "on"; + // The same expression the switch is seeded from, so "differs from what the + // form opened on" is one statement rather than two that can drift. + const loaded = settings.cycleTrackingEnabled === true; + return { + modulePreferences: Object.fromEntries( + Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS).map((key) => [ + key, + form.get(key) === "on", + ]), + ), + ...(cycleTrackingEnabled !== loaded ? { cycleTrackingEnabled } : {}), + }; +} + function ModulesSettingsForm({ settings, disabled, @@ -500,12 +543,7 @@ function ModulesSettingsForm({ function submit(event: FormEvent) { event.preventDefault(); - const form = new FormData(event.currentTarget); - onSave({ - modulePreferences: Object.fromEntries( - entries.map(([key]) => [key, form.get(key) === "on"]), - ), - }); + onSave(managedModulesPatch(settings, new FormData(event.currentTarget))); } return ( @@ -523,6 +561,17 @@ function ModulesSettingsForm({ disabled={disabled} /> ))} + {/* v1.38.14 (#939) — Cycle sits below the rest because it is not one of + them: it is a DELEGATED module, so its state lives in the record's own + cycle profile rather than in the module blob, and it had no row here + at all. A guardian could not turn it off for a record whose sex says + it should be on, which is the case the issue was opened about. */} + ); diff --git a/src/components/settings/security-section/__tests__/totp-card.test.tsx b/src/components/settings/security-section/__tests__/totp-card.test.tsx index 9b778c0317..8e82079c6f 100644 --- a/src/components/settings/security-section/__tests__/totp-card.test.tsx +++ b/src/components/settings/security-section/__tests__/totp-card.test.tsx @@ -8,6 +8,26 @@ * Project convention is SSR-only tests (no `@testing-library/react`); the * dialog's open-state behaviour is Radix's own, so we assert the source wires * the trigger + consequence copy rather than driving a portal at runtime. + * + * ## The refused mutation + * + * A refusal used to leave the confirmation up: the confirming click + * `preventDefault()`s Radix's close so the request can stay in flight behind a + * disabled button, and nothing took the dialog down again. On success that was + * invisible — the fresh-codes panel replaces the branch the dialog lives in, + * so it unmounted anyway — but a refused rotation (or a mistyped code at + * disable) left an overlay covering the one place the card explains itself. + * + * Two halves are pinned below: the sentence a step-up refusal puts on the + * card, called directly; and the wiring that takes the dialog down when the + * request settles, read off the source. The second half is structural because + * the DOM is out of reach here — what a person actually sees, a closed dialog + * over a visible message, is asserted in `e2e/mfa-step-up-journey.spec.ts` + * against a real browser. + * + * Mutation check: drop either `onSettled` and the wiring case goes red; drop + * an `open=` binding and the controlled case goes red; return `err.message` + * from `describeError`'s step-up arm and the sentence case goes red. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; @@ -15,8 +35,12 @@ import { resolve } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ApiError } from "@/lib/api/api-fetch"; import { I18nProvider } from "@/lib/i18n/context"; -import { TotpCard } from "../totp-card"; +import en from "@/../messages/en.json"; +import { describeError, TotpCard } from "../totp-card"; + +const STEP_UP_SENTENCE = en.settings.security.stepUpRequired; function render(node: React.ReactNode) { const client = new QueryClient({ @@ -29,6 +53,10 @@ function render(node: React.ReactNode) { ); } +function source(): string { + return readFileSync(resolve(__dirname, "../totp-card.tsx"), "utf8"); +} + describe(" — recovery-code regeneration", () => { it("SSRs the enabled card with the regenerate action", () => { const html = render(); @@ -36,7 +64,7 @@ describe(" — recovery-code regeneration", () => { }); it("wraps regenerate in a confirmation dialog with a consequence line", () => { - const src = readFileSync(resolve(__dirname, "../totp-card.tsx"), "utf8"); + const src = source(); // The regenerate action is a dialog trigger, not a bare mutate-on-click. expect(src).toContain("settings.security.recovery.regenerateTitle"); expect(src).toContain("settings.security.recovery.regenerateConfirm"); @@ -44,3 +72,39 @@ describe(" — recovery-code regeneration", () => { expect(src).toContain("regenerate.mutate()"); }); }); + +describe(" — a refused mutation", () => { + it("puts the step-up sentence on the card, not the server's prose", () => { + const refusal = new ApiError( + "Recent second-factor verification required", + 401, + { errorCode: "auth.stepup.required" }, + ); + + const shown = describeError(refusal, "fallback", STEP_UP_SENTENCE); + + expect(shown).toBe(STEP_UP_SENTENCE); + expect(shown).not.toContain("second-factor verification required"); + }); + + it("closes each confirmation when its request settles", () => { + const src = source(); + // Whatever the answer was — the refusal is the case that needs it, and a + // handler that only closed on success would leave the refusal covered. + expect(src).toContain("onSettled: () => setRegenerateOpen(false)"); + expect(src).toContain("onSettled: () => setDisableOpen(false)"); + }); + + it("leaves no confirmation for Radix to close on its own", () => { + const src = source(); + // Every dialog on this card is controlled, so none of them can be left + // holding an overlay after a `preventDefault()`ed confirm. + const dialogs = src.match(/]/g); + const controlled = src.match(/ setDisableOpen(false), }); const regenerate = useMutation({ @@ -183,6 +205,7 @@ export function TotpCard({ t("settings.security.stepUpRequired"), ), ), + onSettled: () => setRegenerateOpen(false), }); async function copySecret() { @@ -197,7 +220,7 @@ export function TotpCard({ } return ( - + beginSetup.mutate()} disabled={beginSetup.isPending} @@ -291,6 +315,7 @@ export function TotpCard({
setCode(e.target.value.replace(/\D/g, "").slice(0, 6)) @@ -303,6 +328,7 @@ export function TotpCard({ />