The journey's isolation went one way only. `test.describe.configure` orders
the tests inside the file; the suite is fullyParallel with two workers, so
everything else runs beside it — and its own trigger, the admin reminder
sweep, walked the whole instance and dispatched for every account that had
an overdue dose. With the mail env pointed at the local responder, that
would have started sending real messages for accounts this file has never
heard of, and the exact-equality assertion on the accepted recipients would
have been reading them. The sweep now names the account it then counts, and
the comments that defended the opposite direction say what the exposure
actually is.
Its own server, too, and for two reasons. The `SMTP_*` vars decide whether
the Email card renders at all, so on the shared server they put a card the
axe scan and the card-anatomy sweep had never measured onto
`/settings/integrations` for every spec that opens the route. And unset,
`HEALTHLOG_PROCESS_TYPE` starts the pg-boss reminder scheduler inside the
process serving requests, whose every-fifteen-minutes tick either mints the
missed-dose row that makes the sweep skip its own dispatch or dispatches
beside it. A third server carries both: mail configured, `web`, and only
this spec talks to it.
The shared server keeps `all` on measured grounds rather than taste. Worker
status is per-process and in-memory, so a `web` process reports the worker
stopped, `/admin` renders that row destructive-red, and axe fails it at
3.97:1 against the muted tile — a real contrast defect any operator running
the documented web/worker split already sees, and not this branch's to fix.
That limit is written where the server is defined.
Also here: the stub draws its port per run instead of pinning one, and says
so by name when the port is taken; teardown ends its sockets and survives a
setup that never got as far as starting it; the ledger reset stops deleting
rows whose subject is this account but whose recipient is somebody else,
and clears intake events explicitly rather than through a cascade; the
attempt poll refuses a window larger than the twenty rows the diagnostic
returns; and the header names the precondition the file cannot enforce and
the internal reasons it pins on purpose.
Verified against a production build and a throwaway Postgres: the journey
three times over at one worker, then the a11y and settings geometry specs
at two. Both assertions broken once to see them fail.
One browser journey for the web arm of notification delivery: an account
configures its channels on the settings surface, a due medication reminder
runs through the product's own sweep, and the dispatch decision is read back
off that account's `push_attempts` ledger via the admin diagnostic.
What it pins:
- the enabled channel delivers (`result: ok`) and the message really leaves
the process, asserted against a local SMTP responder rather than against
the ledger row alone;
- a channel that is configured and switched OFF is never attempted, and its
own test control is inert on the surface;
- the APNs arm is attempted and skipped, and the ledger's reason
distinguishes the transport refusing (`apns_not_configured`) from the
client-managed opt-in refusing (`client_managed`) — with email still
delivering under the opt-in, which is the published "APNs leg only"
contract;
- an ntfy relay URL inside a private range is refused with 422, leaves the
stored config untouched, and writes no attempt.
Email is the channel exercised for real because it is the only one that can
be: every account-supplied delivery target crosses the SSRF floor
(`isPublicUrl` plus `safeFetch({ requirePublicHost: true })` with a
connect-time address pin), so ntfy, the generic webhook and Web Push refuse a
local stub by design, and Telegram and APNs address hard-coded third parties.
Email's transport is operator configuration, so `e2e/setup/smtp-stub.ts` can
stand in for it end to end. The config points the e2e server at that stub.
The journey carries one skipped test at full strength: the medication detail
surface decides between the reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged` read out of `/api/auth/me`, and
that payload does not carry the field. The flag therefore reads as false for
every account and the chip never renders, so a person whose phone owns the
reminders is still shown a server-side switch that no longer decides anything.
The write path and the dispatch decision are both correct; only the web
reflection is missing its end.
The account and its jar are seeded in global setup, and its channels, devices,
ledger and preferences are reset before every test so the counts are about the
run in front of them.
One journey across the whole path: an account writes readings in three
domains and files a PDF, downloads its own record as the passphrase-sealed
archive, an admin snapshots it from the backups console, and the snapshot is
restored over an account that has since lost a reading and a dose and gained
one the snapshot never saw.
The assertions address what the documentation promises rather than what the
implementation happens to do. The archive is checked byte by byte against the
HLX1 header in src/lib/export/passphrase-archive.ts — magic, version, KDF id
and the Argon2 cost the file carries — because "a file arrived" is equally
true of plaintext JSON under a new extension. The restored reading and dose
are checked by their ORIGINAL ids, because a restore that re-created them
under fresh ones would have written a different record that reads the same.
The reading created after the snapshot is asserted GONE: the runbook says the
restore replaces the account's data tables, and replacing is not merging.
Two refusing controls. A stored copy with one flipped ciphertext byte is
refused with the route's decrypt failure and leaves the record untouched — the
envelope is AES-256-GCM and its tag covers every byte, so the flip is caught
before the transaction opens. A delegate holding whole-record READ inside the
account gets the not-covered panel instead of the export card, never reaches
the console, and is refused 403 by the list, the manual run and the restore,
the last of them naming the real snapshot so the refusal cannot be mistaken
for a missing row.
Waiting is on the job's own evidence: the console's endpoint is polled until
this account's stored copy is newer than the click, so nothing in the file
guesses at a queue. The journey has its own account, its own delegate and its
own cookie jars; only the reset between repetitions and the deliberate
corruption reach Postgres directly, because a browser cannot express either.
The backups console gains data-testid hooks and a data-backup-id on each row
so the spec addresses controls rather than translated labels.
Generating a report writes: the route remembers the scope and the
practice name on the account row, and the export panel a later visit
opens is therefore the collapsed "repeat run" state. The journey was
generating on the shared fixture account, which is the account
`settings-export.spec.ts` asserts the first-run state against in nine
places — an expanded picker, an empty summary, a disabled button. The
two files run in an unconstrained order under two workers, so the first
symptom was an order-dependent red in CI; against a persistent local
database the flip never cleared and the red was permanent.
The journey now seeds, generates and reads back on `e2e-report-owner`,
with its own cookie jar. The shared account and the whole-record
fixtures also get `report_selection_json` and `last_report_practice_name`
cleared on every seed, which repairs a database an earlier run already
dirtied and keeps a delegate's scope choice from being left on a record
owner's row.
The account's date order is pinned rather than left to the locale, and
its export bucket is purged beside the share-link and login buckets: the
10-per-hour ceiling is shared by every export route, keys on the actor
and outlives a run by an hour, so repeated local runs would otherwise
meet a 429 the spec reports as a missing artefact.
The batched compliance read allows 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 two siblings
spend it as well — `refetch-nav.spec.ts` mounts `/medications` unstubbed and
drives a client-side round trip back to it, and `delegated-writes.spec.ts`
opens `/medications?new=1` on a SECOND session row of the same account, which
is a different session but the same actor and therefore the same bucket. Add a
serial group's retries and thirty reads inside one minute is reachable rather
than theoretical.
The cabinet has the same shape of problem in the other direction: the clear
before each test deletes every medication row of the account it runs on, and
that is today the account every other authenticated spec holds.
So the journey gets its own account and its own jar. The rate-limit bucket,
the medication cabinet and the dashboard tile's denominator become private at
once.
Two fixture repairs ride along, because they are the same helper:
- The clear now goes out as the app's own DELETE per row instead of one raw
`DELETE FROM medications`. Three fifteen-minute per-user cells hold
medication state and a raw statement evicts none of them; the file survived
only because every test happened to write through the API afterwards, which
is an accident of ordering rather than a property. The rows are still
enumerated in SQL, because the list route serves a memoised shape and a row
it has not caught up with would hide from it.
- `ageMedication` takes both stamps from its caller instead of deriving them
from `CURRENT_DATE`. That resolves in the database session's zone — UTC in
CI — while the plan dates the spec sends are computed in the browser's
pinned Europe/Berlin, so between midnight and 02:00 the row silently started
a day earlier than the caller asked for. The docstring now says `starts_on`
is overwritten, not adjusted.
The doctor report had unit coverage for the renderer and route coverage for
the envelope, and nothing that pressed the button. Two browser journeys close
that.
`doctor-report.spec.ts` seeds a few weeks of blood pressure and weight, one
allergy and one medication through the page's own `fetch`, picks a window in
the export panel and generates. The saved download is asserted as a real
artefact — `%PDF` magic, a `Content-Length` that agrees with the bytes, a
length no empty document reaches — and then parsed back to text, so the
period line, the blood-pressure section and the drug list are read out of the
document rather than inferred from a 200. The period is checked as a span, so
the assertion survives the account's timezone. Its refusing control is a
window with no readings: still a valid, readable report that names the period
and carries no blood-pressure section, with the reference data that is not
window-scoped as the control against a blank page passing.
`doctor-report-delegate.spec.ts` covers the access boundary the route draws at
MANAGE. A delegate inside a record it may only read is not offered the export
card at all, and the route refuses the request that card would have sent with
`sharing.access.denied`. The positive control is the same delegate, the same
payload and the same switched-record path against a record it manages, so the
one variable between pass and refusal is the access level. Both requests carry
the record-context headers the shipped client sends: without them the fence
refuses with the same status and the same error code, which would make the
refusal pass for the wrong reason.
Seeding is idempotent — a re-posted reading collides on its natural key and
answers 409 — so a second local run and a repeated run both stay green. The
delegate journey takes its own cookie jar because it moves the session's
record selector, and both specs run in one project.
The glucose display unit had thirty-odd readers and, until recently, no
control to set it. Nothing in the browser suite walks the path a person
actually takes: capture a reading in mg/dL, switch the account to mmol/L,
capture a second one, and read both back — each converted, none of them
reinterpreted — in the list and on the dashboard strip.
The journey covers that end to end, plus the boundary: a value past the
plausible ceiling is refused by the server, the capture keeps what was typed,
and the list is exactly what it was.
One reading arrives with no meal-time context, the way a meter export or a
device sync delivers one. It reads back correctly in the list; the tile it
should paint does not exist yet (#943), so the tile assertion is skipped by
issue number rather than softened into something that passes.
Three supporting changes:
- The list rows carry what they hold — type and resolved display unit on the
row, a slot on the value cell — so the journey pins "this reading now reads
in mmol/L" structurally instead of matching translated copy that also paints
through a different subtree on the card list. The meal-time options carry
their enum for the same reason.
- The sharing-marker weight rows were seeded by handing a Date to a
`timestamp without time zone` column, which stamps them in the host's zone.
On a machine east of UTC that put them hours in the future, where every fold
whose window ends "now" skips them, and the type stayed uncovered — which
keeps the snapshot's thick phase cold and the whole tile strip short. Seed
the instant as UTC, the shape the app's own writes use.
- The file mutates one shared account and reads global verdicts off it, so it
runs in a single project alongside the visits and immunization journeys.
The integration leg reads GET /api/auth/me as a guardian acting on a
managed profile and asserts both lists, lands a mood entry and an
allergy on the profile's record, is refused a vault write, and shows a
WRITE delegate writable sections without manageable ones together with
the refused mood entry the list withheld.
The browser leg opens the managed profile, adds a mood entry through
the control that used to be hidden and asserts the POST's status, and
finds the owner-only sentence and no upload control on the documents
page. The spec gets its own session jar and runs in the desktop
project only, like every other spec that moves the record selector.
Manual water entry is gone from the app: the water item on the capture
picker, the dashboard quick-add menu and the hydration card's add button
are removed, and the manual quick-add sheet is deleted. Water that syncs
in through Apple Health still arrives and still shows on the hydration
card with its daily total, history and reference line.
The POST /api/nutrients/water endpoint and the batch sync path stay
untouched. The nutrients page's module-enable error, which had borrowed
a water-quick-add string, gets its own key across all six locales.
Input-only water keys (nav.capture.water*, nutrients.hydration.quickAdd*)
are removed.
The switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.
- the account-access resolver publishes the owner's `fullName` alongside the
greeting name and the username
- `accountLabel` prefers fullName, then displayName, then username. Optional
on the argument so the grant-view and actor shapes that never carried it
keep the old two-field behaviour untouched
- the closed presentation schema carries `fullName` and its parity matcher
compares it, so the two published views of the active record cannot name
the same owner differently
- the OpenAPI contract regenerates with the additive field
This deliberately discloses the owner's full name to every delegate, read-only
included — it was patient-identity data behind a MANAGE-gated export until now.
That is the intended behaviour, documented at `accountLabel` so it is not
mistaken for a leak and tightened back. The resolver, the schema parity, the
label fallback and a read-only delegate's banner are each pinned by a test.
The shared-record strip was forced by capturing /api/auth/me and serving
it back with the sharing block filled. That state is now proved across
the payload validator, the fence store and the transition machine at
once, so a single route mock cannot produce it and the two narrow
repairs both stop one step short of the shell.
So the spec stops faking it: its own login jar, a real switch into the
seeded whole-record READ grant, and a place on the list of files that
run in exactly one project, because a second project would drive the
same session row from two browsers. The viewport below md that project
used to cover is measured inside the desktop project instead, where the
shell picks its column layout from the same width-driven rules.
- drive creation, the switcher entry, the managed banner and the return
trip in a real browser, plus the guardian roster and the floor stated
before an invitation is accepted
- seed a guardian account with a second factor and refresh its step-up
before the journey acts, since no fixture could reach a step-up-gated
route before this
- tie the journey id to its test titles, so a marker that goes missing
fails a check rather than vanishing from a run nobody counted
The suite only passed on one worker: four specs shared the scope
delegate's cookie jar and moved the same session's record selector, so
at CI's two workers they moved the epoch under each other and the
failures were the harness racing itself.
- one login per switching spec, which is a separate session row and
therefore a separate selector; the login bucket is cleared between
them because the ceiling is five and setup now signs in eight times
- each such spec runs in exactly one project, since a second project
drives the same row from two browsers
- clear what the browser suite WRITES, so it can be run twice: the
write journey saves a minute-rounded reading and collided with itself
- wait for adoption after leaving a record, retry a lost switch the way
a person would, and budget the cross-tab hold for machine load
The switch is stamped on the session row, not held in the tab. Every
authenticated spec opens its context from the same storageState file, so
the sharing journey switching that jar into the owner's record switched
it for whatever spec happened to be mid-navigation on the other worker.
The failures land nowhere near the cause: /settings/ai painting "Not
part of shared access" instead of the AI panel, an axe scan reading a
banner its spec never asked for, a waitForResponse waiting on a request
the refused surface never sends. Which spec loses depends on scheduling,
which is why it moved between runs.
Log the delegate in a second time and hand that jar to the journey. Same
account, its own session row, no reach outside the file. The three login
captures collapse into one helper while the third one is added, and the
fixture's login-bucket reset already covers the extra attempt.
The guard matches the two things a switching spec cannot avoid naming,
the switcher entry it clicks and the endpoint that entry calls, and
checks both that the match set is non-empty and that the shared jar is
still what the ordinary specs read. Verified red by pointing the journey
back at the shared jar.
Two browser contexts, two seeded accounts, one database: the owner invites,
the delegate accepts and opens the record, the owner takes it back. Every step
through the shipped UI, because the half of this feature that can go wrong is
the half a person touches.
The stale-paint check is a negative one and deliberately not "the new record's
data arrives" — a check that waits for the new data says nothing about what
was on screen while it arrived. An init script arms a MutationObserver before
any page script on every navigation, including the reload the switch performs,
and latches the delegate's own marker weight. The observer's positive control
runs first on a page that definitely shows it, so its later silence is not the
silence of a broken observer.
Then it was broken on purpose, and the result is written into the spec rather
than assumed. Removing the switch-time cache wipe, the persister's restore
guard and the record-scoped hash — all three — leaves the spec GREEN: the
restore already refuses to clobber a query the live cache holds, and the
dashboard's query is created during render, so the snapshot never reaches that
route's screen at all. Removing the reload turns it red on the banner rather
than on the marker. So this suite is a regression net over the journey, not a
proof of any one control; the controls are proven where they can be broken and
observed, in the unit suites, and the spec's header says so.
The nav assertion turned up a real gap while it was failing: the avatar block
still linked into the account settings from inside a shared record. Those
routes refuse under a switch, so the link led only to an explanation of why it
did not work. It renders as a plain identity block now, and the mobile user
menu drops the same utilities.
Setup gains a second account, a marker weight in each record, a grant reset,
and a clear of the login bucket — this setup signs in twice now, and the
ceiling is five attempts a quarter-hour, so three local runs used to end with a
429 from the fixture rather than a failure from the product.
Three additions to the contract, all of them things a native client cannot
work out on its own.
`GET /api/auth/me` enters the spec for the first time, and enters it loose:
`accountAccess` is specified in full, the remaining preference fields are
declared open rather than half-enumerated. The endpoint has never been in this
document and its payload carries thirty-odd keys no contract has ever named;
specifying a subset and closing the object would publish something the server
does not keep.
`X-HealthLog-Account` is published as a reusable parameter with no route
referencing it, which is the honest state of the contract — the routes that
read another account's record arrive with the surface migration and will
reference it as they land. The header is live in the resolver today, and a
client needs the description before it can be built against a tagged spec: the
value comes from `accountAccess.accounts`, it is a selector and never a
permission, and it must never ride the sync engine, device registration, or an
auth call.
`GET /api/account/activity` carries `retentionDays` in the response and says
why: the window is operator-configurable, so a client that assumed 365 would
tell somebody their record was never opened when the honest answer is "further
back than this reaches".
The parameter is defined in the sharing route module rather than the shared
components block, so the boundary guard keeps holding the header's name to two
files — the resolver that reads it, and the one place that publishes it.
The server honours User.locale when the locale cookie is absent now, and
the locale-switch spec mirrors its German pick into the shared e2e
user's profile — every spec running after it rendered German and the
English-string assertions failed. The explicit cookie sits at the top
of the resolution ladder, keeping each spec context deterministic;
locale-switch still overrides it inside its own context.
v1.18.0 resolves the cycle module through isCycleEnabled(gender,
CycleProfile): a NULL cycleTrackingEnabled derives from gender, so the
MALE-seeded e2e account read as cycle-OFF and /cycle redirected home —
the cycle-wheel tile never mounted. Seed a CycleProfile with
cycle_tracking_enabled = true so the cycle vertical renders for the
mobile smoke spec.
The medication-wizard e2e specs assert the German step counter, but the
seeded e2e user falls back to the English default locale; pin the
`healthlog-locale=de` cookie for the wizard flow and correct the
storage-state cookie allowlist to the hyphenated cookie name the i18n
layer actually reads.
The one-shot cadence integration fixture derived `startsOn`/`endsOn` from
the UTC calendar date but checked emission against the Europe/Berlin
today-window, so the assertion flipped when the suite ran in the UTC
evening. Derive the fixture dates from the same Berlin calendar day the
window uses.
Also refresh the OpenAPI spec version to 1.6.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-hydration redirect for users with `onboardingCompletedAt ===
null` caused a brief dashboard flash before the client-side effect
could fire. The redirect now lives in `src/proxy.ts` so it lands on the
first server response.
The proxy runs in the Edge runtime and can't reach Prisma, so the auth
routes (login / passkey-verify / register / password / /api/auth/me)
mirror the DB `onboardingCompletedAt` column into a non-httpOnly
`hl_onboarding` cookie. The cookie is a UX hint, not a security
signal — a user editing it locally only skips the dashboard flash;
they still can't bypass any server check. `/api/onboarding/complete`
clears the cookie so the next navigation drops the redirect
immediately, and `destroySession` + expired-session purge wipe it
alongside the session cookie.
The e2e onboarding-flicker spec can return to testing the actual
incomplete-onboarding case: the proxy reads the cookie (absent for
the fully-onboarded storage-state user), not the mocked `/api/auth/me`
response, so the test now sets `onboardingCompletedAt: null` honestly
instead of pinning it to a non-null value as a workaround.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated root-causes were keeping the Integration tests and e2e
workflows red on every push to main since `d8c549e` (50+ runs).
1. Integration: `.github/workflows/integration.yml` had `ENCRYPTION_KEY:
0000…` as a bare YAML scalar. YAML 1.2 parses a run of zeros as the
integer 0 — so the runner exported `ENCRYPTION_KEY=0`, and
`decodeKey()` rejected it as "must be hex (>= 32 chars) or base64
(32 bytes)". 6 tests in admin-backups-* and integration-status
failed at module load. Quoting the value forces a literal string.
2. e2e: the global-setup seeded user had `onboarding_tour_completed`
left at its default `false`, so the spotlight tour mounted a
full-viewport `role="dialog"` overlay (z-index 200) on every
authenticated spec. Playwright's hit-test then caught the tour's
skip-button instead of the intended target — every spec that
clicked sidebar / quick-add / logo timed out. Setting the column
to `true` in the upsert keeps the tour off the dashboard for the
rest of the suite. The two `onboarding-flicker.spec.ts` mocks
that fulfil `/api/auth/me` from inside the spec also need the
field, since they replace the seeded payload.
Workflow change is comment + quoted strings only — no secrets, no
runtime cost.
Authenticated specs need a deterministic user and a persistent session
cookie. global-setup.ts upserts an `e2e-tester` admin via raw `pg` (the
generated Prisma client uses ESM `import.meta.url`, which Playwright's
TS loader cannot resolve), then logs the user in once and writes
`storageState.json` so every authenticated spec reuses the same
session — the per-IP login rate-limit (5 attempts / 15 min) would
otherwise lock out the suite the moment six specs run in parallel.
Adds `pg` + `@types/pg` as devDependencies — required for the seed
step. `pg` is already used by `scripts/seed-demo.ts` for the same
loader-incompatibility reason, so we're not introducing a new
dependency footprint, only making it explicit in the lockfile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>