mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge pull request #718 from MBombeck/release/v1.37.0
v1.37.0 — share part of a record, or keep one for somebody with no login
This commit is contained in:
482 files changed
+47023
-3442
No files matched your search
@@ -210,6 +210,20 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Audit-trail retention (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# How long rows in `audit_logs` are kept. Default 365 days; the floor is 7 and
|
||||
# anything below it is ignored, so a window set in seconds by mistake cannot
|
||||
# empty a fresh table. A daily pg-boss job does the pruning.
|
||||
#
|
||||
# The number is not internal. Settings -> Shared access states it under the
|
||||
# activity list, and the dialog that ends somebody's access says how long
|
||||
# "who entered what" stays answerable. Both read the value the server
|
||||
# resolved, so an instance running 90 says 90 everywhere.
|
||||
# AUDIT_LOG_RETENTION_DAYS="365"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# APNs — Apple Push Notification service (v1.4.23+, optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -184,6 +184,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# SMTP_SECURE="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Session cookie Secure flag -- the most common self-host snag
|
||||
# -----------------------------------------------------------------------------
|
||||
# Unset, the flag tracks NODE_ENV, so a production image sets `Secure` and a
|
||||
# browser on plain HTTP silently drops the session cookie: login appears to
|
||||
# succeed and every following request is anonymous. Set it to false ONLY on a
|
||||
# LAN, Tailscale or VPN-only host that deliberately serves HTTP. Behind a TLS
|
||||
# reverse proxy, leave it alone.
|
||||
# SESSION_COOKIE_SECURE="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -230,6 +241,21 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# BACKUP_ENCRYPTION_KEY=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Audit-trail retention -- optional
|
||||
# -----------------------------------------------------------------------------
|
||||
# How long rows in `audit_logs` survive, in days. Default 365. The floor is 7:
|
||||
# a smaller value is ignored rather than obeyed, so a window entered in seconds
|
||||
# by mistake cannot empty a fresh table. A daily job does the pruning.
|
||||
#
|
||||
# This number is shown to users, not just enforced. Settings -> Shared access
|
||||
# prints the window under the activity list, and the dialog that ends somebody
|
||||
# else's access states how long "who entered what" stays answerable. Both take
|
||||
# the value the server resolved from this variable, so an instance running 90
|
||||
# says 90 on both surfaces.
|
||||
# AUDIT_LOG_RETENTION_DAYS="365"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# FHIR health-record export -- optional
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+2
-4
@@ -18,9 +18,7 @@
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
/e2e/setup/storageState.json
|
||||
/e2e/setup/storageStateOwner.json
|
||||
/e2e/setup/storageStateDelegate.json
|
||||
/e2e/setup/storageState*.json
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -95,4 +93,4 @@ next-env.d.ts
|
||||
# times over. The same two names are excluded from eslint and vitest, for the
|
||||
# same reason in a different tool.
|
||||
/.wt-*/
|
||||
/.worktrees/
|
||||
/.worktrees/
|
||||
@@ -2,8 +2,79 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.37.0] — 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- Sharing a record no longer has to mean sharing the whole of it. An invitation
|
||||
can name the parts it opens: readings, medications, lab results, health
|
||||
background, illness, mood and mind, cycle, documents. Everything you do not
|
||||
pick stays closed, and a section you kept back is refused the same way a
|
||||
record nobody shared with you is, so what you held back is not visible from
|
||||
the outside.
|
||||
|
||||
Two things the invitation screen says out loud rather than leaving you to
|
||||
work them out. A section can mention the rest of your health in its notes and
|
||||
its names, so sharing a section shares whatever was written in it. And the
|
||||
dashboard overview, the health score and the daily digest appear only when
|
||||
the entire record is shared, because a figure computed from part of a record
|
||||
would read as a figure about the person.
|
||||
|
||||
Every grant written before this release opens the entire record and is
|
||||
untouched. Nobody has to agree to anything again.
|
||||
|
||||
- A third level of access. Read access looks at the record. Write access adds
|
||||
to it. Manage access can also change and remove what is already there,
|
||||
including entries you made yourself, and can read the insights generated from
|
||||
the record. The line it stops at is the account around the record: your
|
||||
login, your second factor, your connected services, your API tokens, where
|
||||
your notifications go, which modules and thresholds are on, and who else has
|
||||
access.
|
||||
|
||||
Offering manage access asks for your second factor, so it can only be done in
|
||||
a browser. Accepting it can be done anywhere. It always covers the entire
|
||||
record. There is no way to hand somebody everything about part of you, because
|
||||
a note in one section can be about any other.
|
||||
|
||||
Everything a manager does is recorded under their own name, with a verb that
|
||||
says what it was rather than "made a change".
|
||||
|
||||
- A health record for somebody who does not sign in. @balajiv113 asked in #360
|
||||
for a way to look after a family member, and a grant between two accounts only
|
||||
ever covered half of that. The other half is the person who has no account to
|
||||
begin with: a child, or somebody you care for.
|
||||
|
||||
A managed profile has no login and no e-mail address. You give it a name,
|
||||
optionally a real date of birth, and the language and timezone its own days
|
||||
and reminders are measured in. Nothing is invented from a year you did not
|
||||
give. Creating one asks for your second factor, and you become its first
|
||||
guardian in the same transaction, so there is no moment where the record
|
||||
exists and nobody is looking after it.
|
||||
|
||||
You can invite a second guardian, who accepts the invitation the way any
|
||||
other is accepted. The record can never be left with nobody: the last
|
||||
guardian cannot hand it back and cannot be removed, and the screen says so
|
||||
where the refusal happens. The way out is to add somebody else, or to delete
|
||||
the profile.
|
||||
|
||||
- The reminders a managed profile produces reach the people looking after it. A
|
||||
record with no login has no phone and no chat of its own, so medication
|
||||
reminders, measurement reminders, safety-floor alerts and low-stock alerts go
|
||||
to its guardians, each in their own language and over the channels they had
|
||||
already chosen. The message names whose record it is about. It carries no
|
||||
buttons: opening the app takes you through the ordinary switch into that
|
||||
record rather than acting on it from a lock screen.
|
||||
|
||||
One guardian turning off their own reminders on their own phone does not
|
||||
silence the others.
|
||||
|
||||
- A guardian can keep a managed profile's settings. Its modules, its units, its
|
||||
language and timezone, its thresholds and its notification preferences, read
|
||||
and written as that record's settings rather than as yours. Your own screens
|
||||
stay in your own language while you do it. Connected services show a status
|
||||
and nothing else for now: connecting, syncing and disconnecting a wearable on
|
||||
somebody else's behalf is not part of this release.
|
||||
|
||||
- The document vault opens inside a shared record. `/documents` was offered in
|
||||
the navigation of somebody else's record and then refused every read behind
|
||||
it, so the page rendered an error card and the illness view rendered another
|
||||
@@ -33,6 +104,31 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching records with more than one tab open could leave every tab on a
|
||||
spinner for half a minute. While a switch is in flight the other tabs hold,
|
||||
and they are released by the answer the server gives afterwards. A tab that
|
||||
reloaded during the switch was mistaken for somebody else starting a second
|
||||
one, which quietly detached the hold from the switch it belonged to, so no
|
||||
answer could release it and the tabs waited out the timeout instead. The
|
||||
release now stays attached to the switch that opened it.
|
||||
|
||||
- Closing a document could leave it named in the address bar. Opening a
|
||||
document from a card adds a step to the browser's history so that Back closes
|
||||
it again, and closing it asks the browser to consume that step. The browser
|
||||
can decline, and when it did the sheet closed while the address bar went on
|
||||
naming the document, so a reload or a shared link re-opened the one you had
|
||||
just closed. The close now checks that the step was consumed and tidies the
|
||||
address bar itself when it was not.
|
||||
|
||||
- Two tabs of one browser could disagree about which record they were in.
|
||||
Switching records moves a session rather than a tab, so a tab left open on
|
||||
the record you just left went on reading and writing there until somebody
|
||||
reloaded it. Every request now carries the record the tab believes it is in,
|
||||
and one naming the record it has left is refused with an answer the tab
|
||||
recovers from by re-reading who it is. A session that has never opened
|
||||
somebody else's record is unaffected, and so is the phone app, which carries
|
||||
the record on the request itself and never held a selector to go stale.
|
||||
|
||||
- Somebody with write access could still overturn a dose you had already
|
||||
recorded. The rule that keeps that with the owner was on one request and the
|
||||
medication card reaches the same entry through another, where an explicit
|
||||
|
||||
@@ -98,6 +98,16 @@ x-healthlog-env: &healthlog_env
|
||||
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
||||
# How long the audit trail is kept, in days. Default 365, floor 7 (a value
|
||||
# below the floor is ignored rather than obeyed, so a window set in seconds
|
||||
# by mistake cannot empty a fresh table). It is not only a retention knob:
|
||||
# the shared-access panel PUBLISHES this number — "who entered what stays
|
||||
# answerable for N days" is on the dialog that ends somebody's access, and
|
||||
# the activity view states the same window. An instance running 90 says 90,
|
||||
# so the var has to actually reach the container. Listed here for the reason
|
||||
# the block exists: `environment:` is a whitelist, and a value in .env that
|
||||
# is not named here never arrives.
|
||||
AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}"
|
||||
# Per-export ceiling on MedicationAdministration rows in the FHIR
|
||||
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
|
||||
# an operator override in .env actually reaches the container (the
|
||||
|
||||
+5
-4
@@ -28,10 +28,11 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending
|
||||
login IPs to a third-party lookup. And
|
||||
[`self-hosting/account-sharing.md`](./self-hosting/account-sharing.md) —
|
||||
letting one account on the instance open another's health record: what
|
||||
the person given access can and cannot reach, what revocation does and
|
||||
does not undo, and which trust properties of the instance are unchanged
|
||||
by it.
|
||||
letting one account on the instance open another's health record: the
|
||||
three access levels, the eight sections a grant can be narrowed to,
|
||||
managed profiles for somebody with no login and the guardians who look
|
||||
after them, what revocation does and does not undo, and which trust
|
||||
properties of the instance are unchanged by any of it.
|
||||
- [`migration/`](./migration/) — release-by-release migration notes.
|
||||
Read the entry for the version you're upgrading from.
|
||||
- [`audit/`](./audit/) — per-release audit summaries archived for
|
||||
|
||||
+1142
-165
File diff suppressed because it is too large.
Load diff
@@ -5,8 +5,12 @@ can open the other's health record. A parent and an adult child, two
|
||||
partners, somebody keeping an eye on a relative's blood pressure — the
|
||||
case this exists for is a household, not an organisation.
|
||||
|
||||
This page describes what that connection does, what it deliberately does
|
||||
not do, and which of the instance's existing trust properties it leaves
|
||||
Since v1.37.0 there is a second arrangement beside it: a **managed
|
||||
profile**, a health record for somebody who has no account at all. That
|
||||
half is described further down, under "Records with no login".
|
||||
|
||||
This page describes what these arrangements do, what they deliberately do
|
||||
not do, and which of the instance's existing trust properties they leave
|
||||
exactly as they were.
|
||||
|
||||
## The shape of it
|
||||
@@ -25,7 +29,7 @@ the settings copy these are the owner and the person they invited.
|
||||
- Once accepted, the record shows up in that person's user menu under
|
||||
"Open a record". Opening it reloads the app into the owner's record.
|
||||
- A banner across the top of every page names whose record is open and
|
||||
says it is read-only, with a button back to the person's own. It is
|
||||
what the access admits, with a button back to the person's own. It is
|
||||
loud on purpose: somebody who forgets they are switched will log their
|
||||
own reading into another person's record, and there is no undo for an
|
||||
entry that was never theirs.
|
||||
@@ -36,11 +40,61 @@ the settings copy these are the owner and the person they invited.
|
||||
|
||||
## What the other person can and cannot do
|
||||
|
||||
**Read the health record.** Measurements, medications, mood, labs, the
|
||||
illness journal, preventive-care reminders, documents. In this release
|
||||
that is the whole of it: every grant is read-only. The write level exists
|
||||
in the data model so that adding it later is enforcement work rather than
|
||||
a migration, and no invitation can create one today.
|
||||
An invitation carries two answers: how much of the record it opens, and
|
||||
what may be done inside it.
|
||||
|
||||
### How much of the record
|
||||
|
||||
Either the entire record, which is what sharing has always meant and what
|
||||
every grant written before v1.37.0 carries, or a set of named sections
|
||||
chosen when the invitation is written:
|
||||
|
||||
| Section | What it covers |
|
||||
| ----------------- | -------------------------------------------------------------------------- |
|
||||
| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics |
|
||||
| Medications | The medication list, its schedules, doses taken, side effects and stock |
|
||||
| Lab results | Lab results and the analytes behind them |
|
||||
| Health background | Allergies, family history and the facts in the health profile |
|
||||
| Illness | Illness episodes and the day logs kept under them |
|
||||
| Mood and mind | Mood entries and the mental-health questionnaire history |
|
||||
| Cycle | Cycle tracking |
|
||||
| Documents | Everything in the document vault |
|
||||
|
||||
A section that was not shared is refused exactly the way a record nobody
|
||||
shared is, so the shape of what was held back is not readable from
|
||||
outside. Sections are route families and nothing finer: there is no row
|
||||
filtering, no field filtering and no redaction inside a section that WAS
|
||||
shared. The invitation screen says this in as many words, because a note
|
||||
or a document name inside one section can be about any other.
|
||||
|
||||
Aggregate surfaces (the dashboard overview, the health score, the daily
|
||||
digest) appear only on a whole-record grant. A figure derived from part of
|
||||
a record reads as a figure about the person, and it would not be one.
|
||||
|
||||
A stored scope never grows. A section added by a later release is not in
|
||||
a set somebody ticked before it existed.
|
||||
|
||||
### What may be done inside it
|
||||
|
||||
Three levels, fixed when the invitation is written. Raising one means
|
||||
revoking and inviting again, because widening a grant in place would carry
|
||||
a consent the other person never gave.
|
||||
|
||||
| Level | Adds |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Read | Read what the grant opens, and change nothing. |
|
||||
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, and marking a dose taken or skipped. Editing, deleting and restoring stay with you. |
|
||||
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
|
||||
|
||||
Offering **manage** asks the owner for a fresh second factor if they have
|
||||
one enrolled, which makes it a browser-only act either way: a Bearer
|
||||
caller is refused before anything else happens and told to use a browser.
|
||||
Accepting it has no such restriction. Manage never carries a section
|
||||
scope, because "they can do anything, but only to part of you" promises a
|
||||
boundary an edit cannot keep.
|
||||
|
||||
Everything a manager does is written to the trail under their own name,
|
||||
with a verb naming the act rather than a generic "made a change".
|
||||
|
||||
**Nothing about the account around the record.** This is the line the
|
||||
whole feature is built on, and it is drawn on the server:
|
||||
@@ -53,10 +107,16 @@ whole feature is built on, and it is drawn on the server:
|
||||
| Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record |
|
||||
| Notification channels and push devices | The owner's health alerts redirected to another phone or chat |
|
||||
| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
|
||||
| Insights and the Coach | AI processing of the owner's data, under the owner's own consent |
|
||||
| Asking an AI provider anything | AI processing of the owner's data, under the owner's own consent |
|
||||
| Modules, thresholds, units, the record's language and timezone | Configuration of the account, not content of the record |
|
||||
| Sharing itself | Inviting somebody else, widening the access, or passing it on |
|
||||
| Locale, theme, dashboard layout | Presentation belongs to the person, not to the record |
|
||||
|
||||
This table is about a grant between two accounts. A managed profile is
|
||||
the one place where some of it is deliberately different, because a record
|
||||
with no login has nobody else to keep its own configuration: see "Records
|
||||
with no login" below for exactly which part of it a guardian may reach.
|
||||
|
||||
The last two rows are worth a sentence each. Sharing is not
|
||||
transferable: somebody given access cannot invite a third person, cannot
|
||||
raise their own level, and cannot hand it on. And display preferences
|
||||
@@ -66,8 +126,11 @@ does not change your own theme, language or dashboard.
|
||||
The AI row is the one that looks arbitrary and is not. Server-managed AI
|
||||
features send the record's data to a provider under a consent the record's
|
||||
OWNER gave, for their own use. Somebody else triggering that would create
|
||||
a consent-shaped act the owner never made, so those surfaces stay
|
||||
owner-only.
|
||||
a consent-shaped act the owner never made. So a manage grant can READ
|
||||
insights that were already generated, and nothing a delegate does at any
|
||||
level causes an outbound provider call or queues a generation job. The
|
||||
owner's own path is unchanged and is the control that proves the
|
||||
difference.
|
||||
|
||||
These are not checks that a page forgot to render. Every route in the
|
||||
product refuses by default while a browser is acting on another account;
|
||||
@@ -94,6 +157,62 @@ dates and with who ended it, because "who had access, from when to when,
|
||||
and who ended it" is a question a deleted row cannot answer. Re-inviting
|
||||
the same person creates a new row beside the old one.
|
||||
|
||||
## Records with no login
|
||||
|
||||
A **managed profile** is a health record for somebody who does not sign
|
||||
in: a child, or an adult who is cared for. It is a real account row with
|
||||
no password, no passkey and no e-mail address, and it is never presented
|
||||
as something to log in as.
|
||||
|
||||
- It is created from **Settings → Shared access**, under a fresh second
|
||||
factor. An account with no second factor enrolled is refused rather than
|
||||
waved through, because the act creates a permanent management
|
||||
relationship over a person who cannot object to it.
|
||||
- Creation takes a display name, optionally a real date of birth, and the
|
||||
language and timezone the record's own days and reminders are measured
|
||||
in. A year on its own is never turned into the first of January. The
|
||||
language belongs to the record, not to the guardian reading it, so the
|
||||
guardian's own screens stay in their own language.
|
||||
- The creator becomes the first **guardian** in the same transaction, over
|
||||
an ordinary accepted MANAGE grant with the profile as grantor. If either
|
||||
half fails, neither happens: there is no window in which the profile
|
||||
exists with nobody looking after it.
|
||||
- A second guardian is invited and accepts exactly like any other
|
||||
invitation. An accepted guardian grant does not expire; the invitation
|
||||
token still can.
|
||||
- **A managed profile can never be left guardian-less.** Revoking,
|
||||
handing back, deleting a guardian's own account and deleting the
|
||||
profile all take the same lock on the profile and re-check the count
|
||||
inside it, so two people cannot both leave at once. The last guardian is
|
||||
refused, with a message naming the two ways out: add another guardian,
|
||||
or delete the profile under a fresh second factor. Deleting the profile
|
||||
deletes everything recorded for it.
|
||||
- A guardian may keep that record's settings: modules, units, thresholds,
|
||||
its language and timezone, and its notification preferences. Connected
|
||||
services are visible as a status only. Connect, callback, sync,
|
||||
credential, disconnect, resume and test are not guardian surfaces in
|
||||
this release, and their deep links land on the shared-record unavailable
|
||||
page rather than on a raw refusal.
|
||||
- **Its reminders go to its guardians.** A record with no login has no
|
||||
channels of its own, so medication reminders, measurement reminders,
|
||||
safety-floor alerts and low-stock alerts fan out to every active
|
||||
guardian, in that guardian's own language and over the channels that
|
||||
guardian already configured. Nothing else fans out; every other event
|
||||
stays on the path it was already on. The payloads carry no take or skip
|
||||
action, on any channel, so a notification about somebody else's record
|
||||
cannot be acted on from a lock screen. One guardian suppressing
|
||||
server-side reminders on their own device does not suppress anybody
|
||||
else's.
|
||||
- Delivery attempts record both the record the message is about and the
|
||||
person it was sent to, so the admin notification diagnostic can answer
|
||||
"who was this for" and "whose record was it about" separately.
|
||||
|
||||
There is no emancipation flow: no route, no control and no copy suggests
|
||||
a managed profile can be turned into an ordinary account. The data model
|
||||
leaves room for it (the credentials are nullable and the managed marker
|
||||
can be cleared) and clearing that marker stops the notification fan-out,
|
||||
but nothing in v1.37.0 exercises any of that.
|
||||
|
||||
## Seeing what happened
|
||||
|
||||
**Settings → Shared access** answers three questions for the owner, at
|
||||
@@ -104,9 +223,21 @@ Below that, a list of the days somebody else opened it and how many times
|
||||
That list is bounded by the instance's audit retention, and the page says
|
||||
so with the actual number rather than a hardcoded one. The default is 365
|
||||
days (`AUDIT_LOG_RETENTION_DAYS`); an operator who shortens it shortens
|
||||
this view too, and the sentence under the heading changes to match. An
|
||||
empty list means nobody opened the record within that window — not that
|
||||
nobody ever did.
|
||||
this view too, and the sentence under the heading changes to match. The
|
||||
query is bounded to that window, so the sentence is the real limit of the
|
||||
list rather than a caption over an unbounded one.
|
||||
|
||||
Two things the list does not claim, both worth reading before treating it
|
||||
as an answer:
|
||||
|
||||
- **An empty list is not proof that nobody opened the record.** It means
|
||||
nobody did so within the window, and that any earlier row has been
|
||||
purged.
|
||||
- **The list is capped.** It returns at most the hundred most recent rows
|
||||
inside the window; when it hits that ceiling it says so under the
|
||||
heading. A busy household can reach the cap in far less time than the
|
||||
retention window, and the oldest line on screen is then the oldest line
|
||||
shown, not the oldest that exists.
|
||||
|
||||
## What this does not change about the instance
|
||||
|
||||
@@ -130,8 +261,9 @@ re-reads the trust model:
|
||||
|
||||
Nothing to configure. Sharing is available on every instance, needs no
|
||||
environment variable, and does nothing until one account invites another.
|
||||
The same is true of managed profiles.
|
||||
|
||||
Two operational notes:
|
||||
Four operational notes:
|
||||
|
||||
- **Invitations disclose whether a username exists.** An invitation to a
|
||||
name nobody uses is refused with "no account with that name". That is
|
||||
@@ -143,4 +275,20 @@ Two operational notes:
|
||||
rows naming that account as the one who opened somebody's record stay,
|
||||
and render as "a deleted account" — nulling them would turn "your
|
||||
daughter opened your record" into "you opened your record", which is
|
||||
worse than an unresolvable id.
|
||||
worse than an unresolvable id. Deleting the last guardian's account is
|
||||
refused while the managed profile would survive it, so an account
|
||||
deletion cannot strand a record.
|
||||
- **A managed profile is a user row and appears as one.** It is listed in
|
||||
the admin user list like any other account, with no e-mail address
|
||||
beside it and an internal username nobody is ever asked to type. No
|
||||
login path will accept it: there is no password hash to verify against
|
||||
and no address to look it up by.
|
||||
- **The second-factor requirement is not the same on both features, and
|
||||
the difference is deliberate.** Creating a managed profile, deleting
|
||||
one and inviting a second guardian require a second factor
|
||||
unconditionally: an account with none enrolled is refused and told to
|
||||
set one up, because those acts create or end a permanent relationship
|
||||
over somebody who cannot object. Offering manage access on an ordinary
|
||||
invitation asks for a fresh factor only from an account that has one, so
|
||||
an instance where nobody has enrolled a factor can still use every level
|
||||
of ordinary sharing. All of them are browser-only either way.
|
||||
@@ -203,10 +203,19 @@ test.describe("account sharing", () => {
|
||||
// date mapping, and BOTH still pass with the card wired back to `null`.
|
||||
// Only reading the posted body proves the value a person typed is the
|
||||
// value the server receives, which is the half that was missing.
|
||||
const lapse = ownerPage.locator('[data-slot="grant-invite-expires"]');
|
||||
// The VISIBLE overlay, not the hidden ISO mirror: `data-slot` rides
|
||||
// `{...rest}` onto `DateField`'s hidden input, so the old selector resolved
|
||||
// to an element that can never be visible and typed into nothing.
|
||||
const lapse = ownerPage.getByTestId("grant-invite-expires-input");
|
||||
await expect(lapse).toBeVisible();
|
||||
const chosenDay = isoDayFromNow(30);
|
||||
await lapse.fill(chosenDay);
|
||||
// The overlay commits on blur; without it the ISO text sits in local state
|
||||
// and the hidden mirror still holds the old value when the form submits.
|
||||
await lapse.blur();
|
||||
await expect(
|
||||
ownerPage.locator('input[data-slot="grant-invite-expires"]'),
|
||||
).toHaveValue(chosenDay);
|
||||
|
||||
// The level, through the real control and onto the real wire.
|
||||
//
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { SEAM_BANNERS_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* The seam again, this time with chrome above it.
|
||||
@@ -33,18 +33,24 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* alive (`about:blank`): `I18nProvider` rewrites `healthlog-locale` from
|
||||
* its own active locale on mount, so a cookie written under a live English
|
||||
* page is reverted within the second.
|
||||
* - **Shared record** paints from `accountAccess.active` on `/api/auth/me`.
|
||||
* The real payload is captured first and served back with that block
|
||||
* filled, rather than performing a real switch: the switch is stamped on
|
||||
* the SESSION ROW, so switching the shared cookie jar would switch it for
|
||||
* every other spec holding the same cookie (see
|
||||
* `DELEGATE_STORAGE_STATE_PATH` in `global-setup.ts` for what that costs).
|
||||
* A captured-and-patched payload keeps this file's reach inside this file,
|
||||
* and it keeps working after the offline stage because it never touches the
|
||||
* network again.
|
||||
* - **Shared record** paints while this session is inside somebody else's
|
||||
* record, so this file switches into one. It used to fake the state instead
|
||||
* — capture `/api/auth/me`, serve it back with the sharing block filled —
|
||||
* because the switch is stamped on the SESSION ROW and moving the shared
|
||||
* jar's row would move it for every spec holding that cookie. v1.37.0 took
|
||||
* that shortcut away and gave back the thing that made it necessary: the
|
||||
* record context is now proved across the payload, the fence store and the
|
||||
* transition machine together, so a payload alone cannot produce it; and a
|
||||
* spec that switches gets a login of its own
|
||||
* (`SEAM_BANNERS_STORAGE_STATE_PATH`), which is what keeps this file's reach
|
||||
* inside this file. The grant is the seeded whole-record READ on
|
||||
* `e2e-level-read` — whole-record deliberately, because a narrowed one drops
|
||||
* nav destinations and that is a different spec's subject.
|
||||
* - **Offline** paints while `navigator.onLine` is false, which
|
||||
* `context.setOffline(true)` produces for real, event and all. It is armed
|
||||
* last because it is the only stage a navigation cannot follow.
|
||||
* last because it is the only stage a navigation cannot follow, and it is
|
||||
* armed after the switch for the same reason: entering a record is a
|
||||
* network act.
|
||||
*
|
||||
* The demo banner is the one that cannot be forced from a spec: `DEMO_MODE` is
|
||||
* a server env var read by the root layout, and the suite shares one server
|
||||
@@ -65,9 +71,16 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* `h-dvh` budget, not added on top of it, so no depth of stack introduces
|
||||
* a page-level scrollbar. `<main>` remains the only vertical scroller.
|
||||
*
|
||||
* Property 2 runs on mobile too, where there is no sidebar and therefore no
|
||||
* seam, because the scroll model is the half of the layout that the hoist could
|
||||
* have broken at that breakpoint.
|
||||
* Property 2 is measured below `md` as well as above it, where there is no
|
||||
* sidebar and therefore no seam, because the scroll model is the half of the
|
||||
* layout that the hoist could have broken at that breakpoint. It used to get
|
||||
* there by running the file in the `chromium-mobile` project. It cannot any
|
||||
* more: this file moves a session's record selector, and a second PROJECT is
|
||||
* the one thing a separate cookie jar cannot fix — it would drive the same
|
||||
* session row from two browsers at once. So the narrow viewport is measured
|
||||
* inside the desktop project instead. Nothing is lost by that: the shell picks
|
||||
* its column layout and its scroller from width-driven rules, and the property
|
||||
* under test is a scroll height in CSS pixels.
|
||||
*/
|
||||
|
||||
const LOCALE_COOKIE = "healthlog-locale";
|
||||
@@ -130,46 +143,81 @@ async function readShell(page: Page): Promise<ShellGeometry> {
|
||||
);
|
||||
}
|
||||
|
||||
/** The seeded whole-record READ grant this file switches into. */
|
||||
const TARGET_USERNAME = "e2e-level-read";
|
||||
|
||||
const sharedRecordBanner = (page: Page) =>
|
||||
page.locator('[data-slot="shared-record-banner"]');
|
||||
|
||||
/**
|
||||
* The live `/api/auth/me` body, read through the page so it carries the page's
|
||||
* own cookies. Captured before anything else is armed, so the mock that later
|
||||
* replaces it differs from the real payload in exactly one block.
|
||||
* Wait until the shell is usable rather than merely present.
|
||||
*
|
||||
* The hydration gate is the shell's own hold while it settles which record it
|
||||
* is showing. Measuring geometry through it would measure the placeholder. The
|
||||
* budget is deliberately larger than the suite default: ten seconds is a
|
||||
* statement about machine load, thirty about the gate never releasing.
|
||||
*
|
||||
* The paired positive control is the top bar and not an accessible name: half
|
||||
* of this file runs under the French strip, and a helper that reached for an
|
||||
* English label would be a helper that only works before the locale swap.
|
||||
*/
|
||||
async function captureAccountPayload(page: Page): Promise<string> {
|
||||
const raw = await page.evaluate(async () => {
|
||||
const res = await fetch("/api/auth/me", { credentials: "same-origin" });
|
||||
return res.ok ? await res.text() : null;
|
||||
async function expectShellReady(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(
|
||||
raw,
|
||||
"could not capture /api/auth/me for the sharing mock",
|
||||
).not.toBeNull();
|
||||
return raw!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the captured payload back with the sharing block filled in, so the
|
||||
* shell believes this session is inside somebody else's record. Everything else
|
||||
* the shell reads (modules, role, onboarding flags) stays what the server said.
|
||||
* Start outside any record.
|
||||
*
|
||||
* The first stage asserts that nothing is painted above the top bar, and this
|
||||
* jar's session row keeps whatever the previous test — or the previous attempt,
|
||||
* on a CI retry — left it pointed at. Without this the "no banner" stage is a
|
||||
* statement about run order.
|
||||
*/
|
||||
async function armSharedRecord(page: Page, captured: string): Promise<void> {
|
||||
const payload = JSON.parse(captured) as { data: Record<string, unknown> };
|
||||
const entry = {
|
||||
accountId: "seam-fixture-account",
|
||||
username: "seam-owner",
|
||||
displayName: null,
|
||||
access: "read" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
payload.data.accountAccess = {
|
||||
accounts: [entry],
|
||||
active: entry,
|
||||
canSwitch: true,
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body }),
|
||||
);
|
||||
async function ensureOwnRecord(page: Page): Promise<void> {
|
||||
const banner = sharedRecordBanner(page);
|
||||
if ((await banner.count()) === 0) return;
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectShellReady(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch into the target record, and re-press the button if the switch did not
|
||||
* land.
|
||||
*
|
||||
* `POST /api/account/switch` is a compare-and-set on the session's record epoch
|
||||
* and the client reconciles and retries exactly once, which is the right call
|
||||
* for a person at a keyboard and one attempt short for a fixture that has just
|
||||
* navigated. Bounded at three and asserted: if the record is not entered by
|
||||
* then, that is a failure and it fails.
|
||||
*/
|
||||
async function enterSharedRecord(page: Page): Promise<void> {
|
||||
const banner = sharedRecordBanner(page);
|
||||
if ((await banner.count()) > 0) return;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
await entry.click();
|
||||
try {
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
await expectShellReady(page);
|
||||
return;
|
||||
} catch {
|
||||
// The switcher menu may still be open over the page; start clean.
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
}
|
||||
}
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,21 +273,22 @@ async function armOffline(page: Page): Promise<void> {
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||
}
|
||||
|
||||
test.describe("app-chrome header seam under banners", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
/**
|
||||
* Serial, and for the same reason the file has its own jar: both tests move
|
||||
* this session row's record selector, and two workers running them at once
|
||||
* would move it under each other.
|
||||
*/
|
||||
test.describe.serial("app-chrome header seam under banners", () => {
|
||||
test.use({ storageState: SEAM_BANNERS_STORAGE_STATE_PATH });
|
||||
|
||||
test("the header seam survives every depth of the banner stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
test.info().project.name === "chromium-mobile",
|
||||
"the sidebar is desktop-only (md+), so below md there is no seam to measure",
|
||||
);
|
||||
// Four stages measured at two widths, with a full page load per stage and
|
||||
// a network settle either side of the offline one. Comfortably inside the
|
||||
// suite default when it runs alone; the default 30s is not enough headroom
|
||||
// for it on a loaded machine.
|
||||
test.setTimeout(60_000);
|
||||
// Four stages measured at two widths, with a full page load per stage, a
|
||||
// real record switch and a network settle either side of the offline one.
|
||||
// Comfortably inside the suite default when it runs alone; the default 30s
|
||||
// is not enough headroom for it on a loaded machine.
|
||||
test.setTimeout(90_000);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
@@ -247,8 +296,8 @@ test.describe("app-chrome header seam under banners", () => {
|
||||
const sidebarHeader = page.locator('[data-slot="sidebar-header"]');
|
||||
await expect(topBar).toBeVisible();
|
||||
await expect(sidebarHeader).toBeVisible();
|
||||
|
||||
const captured = await captureAccountPayload(page);
|
||||
await expectShellReady(page);
|
||||
await ensureOwnRecord(page);
|
||||
|
||||
// Each stage adds one banner and leaves the previous ones up, so the last
|
||||
// stage measures the whole stack rather than one banner at a time.
|
||||
@@ -266,26 +315,30 @@ test.describe("app-chrome header seam under banners", () => {
|
||||
beforeResize?: () => Promise<void>;
|
||||
afterResize?: () => Promise<void>;
|
||||
};
|
||||
// The shared record is armed FIRST, before the locale swap: entering a
|
||||
// record is done through the switcher, and the switcher's controls are
|
||||
// reached by their accessible names. Under the French strip those names are
|
||||
// French, and a spec that drove the UI in one language while asserting the
|
||||
// labels of another would be testing the translation bundle.
|
||||
const stages: Stage[] = [
|
||||
{ label: "no banner", arm: async () => {} },
|
||||
{
|
||||
label: "1 banner (maintainership)",
|
||||
label: "1 banner (shared record)",
|
||||
arm: async () => {
|
||||
await armUnmaintainedLocale(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="maintainership-banner"]'),
|
||||
).toBeVisible();
|
||||
await enterSharedRecord(page);
|
||||
await expect(sharedRecordBanner(page)).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "2 banners (+ shared record)",
|
||||
label: "2 banners (+ maintainership)",
|
||||
arm: async () => {
|
||||
await armSharedRecord(page, captured);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await armUnmaintainedLocale(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expectShellReady(page);
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
page.locator('[data-slot="maintainership-banner"]'),
|
||||
).toBeVisible();
|
||||
await expect(sharedRecordBanner(page)).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -380,43 +433,64 @@ test.describe("app-chrome header seam under banners", () => {
|
||||
test("the banner stack never pushes the shell past the viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Mobile as well as desktop. Below `md` the shell stacks in a column and
|
||||
// there is no sidebar, so there is no seam to measure — but the scroll
|
||||
// model is what hoisting the banners could have broken there, and that
|
||||
// half is measurable at both breakpoints.
|
||||
// A real switch, a page load either side of it and a network settle before
|
||||
// the offline stage, at two widths.
|
||||
test.setTimeout(90_000);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
|
||||
const captured = await captureAccountPayload(page);
|
||||
await armSharedRecord(page, captured);
|
||||
await enterSharedRecord(page);
|
||||
await armUnmaintainedLocale(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expectShellReady(page);
|
||||
await expect(
|
||||
page.locator('[data-slot="maintainership-banner"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
await expect(sharedRecordBanner(page)).toBeVisible();
|
||||
await armOffline(page);
|
||||
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
|
||||
|
||||
const shell = await readShell(page);
|
||||
expect(shell.bannerCount, "the stack did not arm").toBe(3);
|
||||
expect(shell.bannerHeight).toBeGreaterThan(0);
|
||||
expect(
|
||||
shell.documentOverflow,
|
||||
`the document scrolls by ${shell.documentOverflow.toFixed(
|
||||
2,
|
||||
)}px under a ${shell.bannerHeight.toFixed(2)}px banner stack`,
|
||||
).toBeLessThan(1);
|
||||
// 393px is the narrow end — a Pixel-5 viewport, below `md`, where the shell
|
||||
// stacks in a column and there is no sidebar. 1280px is the wide end. The
|
||||
// network comes back for the resize and the stage re-arms after it, for the
|
||||
// reason written at the offline stage of the test above: a chunk fetch that
|
||||
// fails with no network takes the whole shell down to the error boundary.
|
||||
for (const [width, height] of [
|
||||
[393, 851],
|
||||
[1280, 900],
|
||||
] as const) {
|
||||
await page.context().setOffline(false);
|
||||
await expect.poll(() => page.evaluate(() => navigator.onLine)).toBe(true);
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.innerWidth))
|
||||
.toBe(width);
|
||||
await armOffline(page);
|
||||
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
|
||||
|
||||
// The top bar is still fully painted below the stack rather than pushed
|
||||
// off the bottom of the viewport.
|
||||
const top = shell.topBar!;
|
||||
expect(top.height).toBeGreaterThan(32);
|
||||
expect(
|
||||
top.bottom,
|
||||
"the banner stack pushed the top bar out of the viewport",
|
||||
).toBeLessThanOrEqual(shell.viewportHeight);
|
||||
const shell = await readShell(page);
|
||||
const at = `at ${width}px`;
|
||||
expect(shell.bannerCount, `${at}: the stack did not arm`).toBe(3);
|
||||
expect(shell.bannerHeight).toBeGreaterThan(0);
|
||||
expect(
|
||||
shell.documentOverflow,
|
||||
`${at}: the document scrolls by ${shell.documentOverflow.toFixed(
|
||||
2,
|
||||
)}px under a ${shell.bannerHeight.toFixed(2)}px banner stack`,
|
||||
).toBeLessThan(1);
|
||||
|
||||
// The top bar is still fully painted below the stack rather than pushed
|
||||
// off the bottom of the viewport.
|
||||
const top = shell.topBar!;
|
||||
expect(top.height, `${at}: the top bar paints no height`).toBeGreaterThan(
|
||||
32,
|
||||
);
|
||||
expect(
|
||||
top.bottom,
|
||||
`${at}: the banner stack pushed the top bar out of the viewport`,
|
||||
).toBeLessThanOrEqual(shell.viewportHeight);
|
||||
}
|
||||
});
|
||||
});
|
||||
+18
-2
@@ -235,6 +235,19 @@ test.describe("document vault", () => {
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await openButton(page, "MRT Knie").click();
|
||||
const detail = page.getByRole("dialog", { name: "MRT Knie" });
|
||||
// The sheet is CLOSED is a different claim from the sheet is showing this
|
||||
// document, and only one of them can be made by a name-matched locator.
|
||||
//
|
||||
// The sheet takes its accessible name from the document title and falls
|
||||
// back to "Untitled document" while the detail read is pending, so
|
||||
// `expect(detail).not.toBeVisible()` also passes on an OPEN sheet that has
|
||||
// gone back to its skeleton — measured, not theorised: a slow detail read
|
||||
// reports the named dialog invisible and the unnamed one visible at the
|
||||
// same instant. That resolves immediately instead of waiting, which is the
|
||||
// wait this test needs after a history traversal, and the assertion that
|
||||
// depended on it then reads a URL the app has not finished with. The
|
||||
// deep-link test above already uses the unnamed role for this reason.
|
||||
const sheet = page.getByRole("dialog");
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("doc"))
|
||||
@@ -246,15 +259,18 @@ test.describe("document vault", () => {
|
||||
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
|
||||
|
||||
await page.goBack();
|
||||
await expect(detail).not.toBeVisible();
|
||||
await expect(sheet).not.toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
|
||||
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
|
||||
|
||||
await page.goForward();
|
||||
// Both: the sheet is open AND it is this document, so Escape is pressed on
|
||||
// a settled sheet rather than on one still resolving its title.
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(detail).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(detail).not.toBeVisible();
|
||||
await expect(sheet).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("doc"))
|
||||
.toBeNull();
|
||||
|
||||
+441
-28
@@ -65,6 +65,83 @@ export const E2E_OWNER = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.37.0 — the account that creates and administers managed profiles.
|
||||
*
|
||||
* Its own account, and not one of the others, for one reason: every route in
|
||||
* the managed-profile family resolves `requireFreshMfa`, which refuses an
|
||||
* account with NO second factor enrolled (`auth.stepup.mfa_not_enrolled`). So
|
||||
* this account carries a confirmed TOTP secret, and an account with a confirmed
|
||||
* secret cannot be logged in by the password-only capture below. The setup
|
||||
* therefore logs it in FIRST and stamps the factor afterwards — see the
|
||||
* enrolment step at the end of `globalSetup`.
|
||||
*
|
||||
* Enrolling the factor on a shared account would have changed behaviour for
|
||||
* every spec that touches a step-up-gated surface, starting with the MANAGE
|
||||
* invitation on the sharing panel.
|
||||
*/
|
||||
export const E2E_GUARDIAN = {
|
||||
email: "e2e-guardian@healthlog.test",
|
||||
username: "e2e-guardian",
|
||||
password: "Rt5!Nm8xQ3wZ6bJp",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/** A separate delegate session for the eight scoped-record browser journeys. */
|
||||
export const E2E_SCOPE_DELEGATE = {
|
||||
email: "e2e-scope-delegate@healthlog.test",
|
||||
username: "e2e-scope-delegate",
|
||||
password: "Qz8!Vp4rL2nX7mKs",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/** One non-personal target record per closed sharing domain. */
|
||||
export const E2E_SCOPE_RECORDS = [
|
||||
{
|
||||
domain: "measurements",
|
||||
username: "e2e-scope-measurements",
|
||||
href: "/measurements",
|
||||
},
|
||||
{
|
||||
domain: "medications",
|
||||
username: "e2e-scope-medications",
|
||||
href: "/medications",
|
||||
},
|
||||
{ domain: "labs", username: "e2e-scope-labs", href: "/labs" },
|
||||
{ domain: "profile", username: "e2e-scope-profile", href: "/profile" },
|
||||
{ domain: "illness", username: "e2e-scope-illness", href: "/illness" },
|
||||
{ domain: "mind", username: "e2e-scope-mind", href: "/mood" },
|
||||
{ domain: "cycle", username: "e2e-scope-cycle", href: "/cycle" },
|
||||
{ domain: "documents", username: "e2e-scope-documents", href: "/documents" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Whole-record grants used to prove the three adult access levels and the
|
||||
* separate managed-profile boundary in a real browser session.
|
||||
*/
|
||||
export const E2E_LEVEL_RECORDS = [
|
||||
{
|
||||
username: "e2e-level-read",
|
||||
access: "READ",
|
||||
recordKind: "shared",
|
||||
},
|
||||
{
|
||||
username: "e2e-level-write",
|
||||
access: "WRITE",
|
||||
recordKind: "shared",
|
||||
},
|
||||
{
|
||||
username: "e2e-level-manage",
|
||||
access: "MANAGE",
|
||||
recordKind: "shared",
|
||||
},
|
||||
{
|
||||
username: "e2e-level-managed",
|
||||
access: "MANAGE",
|
||||
recordKind: "managed",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Weights seeded one per account, chosen so neither can be mistaken for the
|
||||
* other in rendered markup and neither collides with a value any other spec
|
||||
@@ -105,6 +182,80 @@ export const DELEGATE_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateDelegate.json",
|
||||
);
|
||||
|
||||
export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateScopeDelegate.json",
|
||||
);
|
||||
|
||||
export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateScopeA11y.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
|
||||
* well.
|
||||
*
|
||||
* The record-session fence made the cost visible. Four specs moved the same
|
||||
* session row's record selector — the two fence specs, the cross-tab journey
|
||||
* and the scoped-sharing journey — and the fence's whole subject is that row's
|
||||
* context and its monotonic epoch. On one worker they never overlapped; on two
|
||||
* they moved the epoch under each other, and the failure was the harness racing
|
||||
* itself. Passing only at `--workers=1` is not a passing suite: CI runs two.
|
||||
*
|
||||
* A separate LOGIN, not a separate account: the selector lives on the session
|
||||
* row, so two jars for one account are two independent contexts. The grants,
|
||||
* the fixtures and the assertions all stay exactly as they were.
|
||||
*/
|
||||
export const FENCE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateFence.json",
|
||||
);
|
||||
|
||||
export const FENCE_OFFLINE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateFenceOffline.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The managed-profile journey's own jar. Its own session row, like every other
|
||||
* switching spec, so a run at CI's worker count cannot have two specs moving
|
||||
* the same session's record selector.
|
||||
*/
|
||||
export const GUARDIAN_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateGuardian.json",
|
||||
);
|
||||
|
||||
export const CROSS_TAB_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateCrossTab.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The banner-geometry spec's jar, and the reason it exists is a release-shaped
|
||||
* one rather than a load-shaped one.
|
||||
*
|
||||
* `chrome-header-seam-banners.spec.ts` needs a session that is INSIDE somebody
|
||||
* else's record, because one of the strips it stacks only paints there. It used
|
||||
* to produce that state by capturing `/api/auth/me` and serving it back with
|
||||
* the sharing block filled — cheaper than a switch, and self-contained, which is
|
||||
* exactly why it was written that way.
|
||||
*
|
||||
* v1.37.0 ended that. The record context is now proved across three places at
|
||||
* once — the payload's own validator (fail-closed, no partial credit), the fence
|
||||
* store's scope, and the transition machine that has to agree with both — so a
|
||||
* payload claiming a shared record while the session row still says `self` is a
|
||||
* state the shell refuses on purpose. A single route mock can no longer make it,
|
||||
* and the honest fix is to stop faking it: switch for real, and take a jar of
|
||||
* its own so moving one session row cannot move anybody else's.
|
||||
*/
|
||||
export const SEAM_BANNERS_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSeamBanners.json",
|
||||
);
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, {
|
||||
memoryCost: 19456,
|
||||
@@ -201,6 +352,202 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// v1.37.0 — the managed-profile guardian. Seeded exactly like the other
|
||||
// fixture accounts; its second factor is stamped AFTER the login capture,
|
||||
// because the password-only capture cannot complete a TOTP login.
|
||||
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)
|
||||
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,
|
||||
totp_confirmed_at = NULL`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_GUARDIAN.username,
|
||||
E2E_GUARDIAN.email,
|
||||
await hashPassword(E2E_GUARDIAN.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// Whatever a previous run created. A managed profile is a real account row,
|
||||
// so a suite that left one behind would find the guardian looking after two
|
||||
// records on the next run and the roster assertions would read the wrong
|
||||
// one. Deleting the profile takes its grants with it.
|
||||
await pool.query(
|
||||
`DELETE FROM users
|
||||
WHERE managed_profile_at IS NOT NULL
|
||||
AND id IN (
|
||||
SELECT g.grantor_id FROM account_grants g
|
||||
JOIN users u ON u.id = g.grantee_id
|
||||
WHERE u.username = $1
|
||||
)`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
|
||||
// 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
|
||||
// record's resolved scope, rather than the actor's preferences, must keep
|
||||
// each granted doorway visible.
|
||||
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_SCOPE_DELEGATE.username,
|
||||
E2E_SCOPE_DELEGATE.email,
|
||||
await hashPassword(E2E_SCOPE_DELEGATE.password),
|
||||
now,
|
||||
JSON.stringify({
|
||||
medications: false,
|
||||
labs: false,
|
||||
illness: false,
|
||||
mood: false,
|
||||
mentalHealth: false,
|
||||
inboundDocuments: false,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM account_grants
|
||||
WHERE grantee_id = (SELECT id FROM users WHERE username = $1)`,
|
||||
[E2E_SCOPE_DELEGATE.username],
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE sessions SET acting_as_user_id = NULL
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
|
||||
[E2E_SCOPE_DELEGATE.username],
|
||||
);
|
||||
|
||||
for (const record of E2E_SCOPE_RECORDS) {
|
||||
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, NULL, 'USER', $4, $4, $4, true, $5::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
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(),
|
||||
record.username,
|
||||
`${record.username}@healthlog.test`,
|
||||
now,
|
||||
JSON.stringify({
|
||||
medications: true,
|
||||
labs: true,
|
||||
illness: true,
|
||||
mood: true,
|
||||
mentalHealth: true,
|
||||
inboundDocuments: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
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', $2::jsonb, $3, $3, $3
|
||||
FROM users owner, users delegate
|
||||
WHERE owner.username = $4 AND delegate.username = $5`,
|
||||
[
|
||||
cuid(),
|
||||
JSON.stringify([record.domain]),
|
||||
now,
|
||||
record.username,
|
||||
E2E_SCOPE_DELEGATE.username,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Whole-record fixtures intentionally sit beside the narrow records
|
||||
// above. They make the browser prove the presentation and mutation
|
||||
// boundaries of READ, WRITE, MANAGE, and a guardian-managed record
|
||||
// without changing the invitation lifecycle fixture below.
|
||||
for (const record of E2E_LEVEL_RECORDS) {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
managed_profile_at, onboarding_completed_at,
|
||||
onboarding_tour_completed, module_preferences_json)
|
||||
VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $5, $4, true, $6::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = NULL,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
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`,
|
||||
[
|
||||
cuid(),
|
||||
record.username,
|
||||
`${record.username}@healthlog.test`,
|
||||
now,
|
||||
record.recordKind === "managed" ? now : null,
|
||||
JSON.stringify({
|
||||
medications: true,
|
||||
labs: true,
|
||||
illness: true,
|
||||
mood: true,
|
||||
mentalHealth: true,
|
||||
inboundDocuments: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
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, $2, NULL, $3, $3, $3
|
||||
FROM users owner, users delegate
|
||||
WHERE owner.username = $4 AND delegate.username = $5`,
|
||||
[
|
||||
cuid(),
|
||||
record.access,
|
||||
now,
|
||||
record.username,
|
||||
E2E_SCOPE_DELEGATE.username,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Cycle is delegated to its profile gate rather than module preferences.
|
||||
await pool.query(
|
||||
`INSERT INTO cycle_profiles
|
||||
(id, user_id, cycle_tracking_enabled, created_at, updated_at)
|
||||
SELECT $1, u.id, true, $2, $2
|
||||
FROM users u WHERE u.username = $3
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
cycle_tracking_enabled = true,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[cuid(), now, "e2e-scope-cycle"],
|
||||
);
|
||||
|
||||
// Repeated local E2E runs reuse the same seeded account and database.
|
||||
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
|
||||
// run can start above the 20-operation ceiling and fail before exercising
|
||||
@@ -281,12 +628,12 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
WHERE key LIKE 'sharing:%'`,
|
||||
);
|
||||
|
||||
// The login bucket, for the same reason. This setup signs in THREE times
|
||||
// now (the shared jar, the owner, and the sharing journey's own delegate
|
||||
// jar), and the ceiling is five attempts per IP per quarter-hour — so two
|
||||
// local runs in a row would otherwise end with a 429 from the fixture
|
||||
// rather than a failure from the product. Only the auth surfaces' own
|
||||
// buckets are cleared; nothing else in the table is touched.
|
||||
// The login bucket, for the same reason. This setup signs in TEN 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
|
||||
// from the fixture rather than a failure from the product. Only the auth
|
||||
// surfaces' own buckets are cleared; nothing else in the table is touched.
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||||
|
||||
// One marker weight per record. Re-seeded by delete-then-insert so a
|
||||
@@ -313,6 +660,29 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.37.0 — clear what the browser suite WRITES, so the suite can be run
|
||||
// twice.
|
||||
//
|
||||
// The adult-WRITE journey saves a blood-pressure reading through the real
|
||||
// form, and the form stamps a minute-rounded `measuredAt`. Two runs inside
|
||||
// the same minute therefore collide on the natural key
|
||||
// (user, type, measured_at, source) — the second save is a duplicate, and
|
||||
// the journey asserts its POST succeeded. It has always been that way; it
|
||||
// used to surface as a 500 and now surfaces as the 409 the route learned to
|
||||
// give, which is what made it legible. A suite that cannot be re-run is a
|
||||
// suite whose green is a statement about the clock.
|
||||
//
|
||||
// Only rows the journey itself creates: MANUAL, and carrying no
|
||||
// `external_id`, which is what distinguishes them from the seeded markers
|
||||
// above.
|
||||
await pool.query(
|
||||
`DELETE FROM measurements
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)
|
||||
AND source = 'MANUAL'
|
||||
AND external_id IS NULL`,
|
||||
["e2e-level-write"],
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -320,31 +690,74 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
console.log(
|
||||
`[e2e/global-setup] seeded user ${E2E_USER.username} (${E2E_USER.email})`,
|
||||
);
|
||||
// Log the fixture accounts in here and persist their cookie jars so specs
|
||||
// can reuse them via `test.use({ storageState })`. This is the documented
|
||||
// Playwright pattern (`docs/auth.md`) and the only way to avoid the
|
||||
// per-spec login + rate-limit dance.
|
||||
const baseURL =
|
||||
config.projects[0]?.use.baseURL ??
|
||||
process.env.E2E_BASE_URL ??
|
||||
"http://localhost:3000";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
const capture = async (
|
||||
account: { username: string; password: string },
|
||||
path: string,
|
||||
): Promise<void> => {
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||||
await captureAuthState(baseURL, account, path);
|
||||
};
|
||||
|
||||
// The shared jar every authenticated spec reads.
|
||||
await capture(E2E_USER, STORAGE_STATE_PATH);
|
||||
|
||||
// v1.36.0 — the owner's jar. The sharing journey needs both sides of a
|
||||
// grant signed in at once, and the login endpoint is IP-rate-limited, so
|
||||
// the owner is logged in here rather than inside the spec.
|
||||
await capture(E2E_OWNER, OWNER_STORAGE_STATE_PATH);
|
||||
|
||||
// The delegate's own jar for that journey. Same account as the shared one,
|
||||
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH
|
||||
// for what happens when the journey switches the shared row instead.
|
||||
await capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH);
|
||||
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
|
||||
|
||||
// v1.37.0 — one jar per switching spec. See the block comment on
|
||||
// FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's
|
||||
// row with the scoped-sharing journey, which is why the suite only passed
|
||||
// on a single worker.
|
||||
await capture(E2E_SCOPE_DELEGATE, FENCE_STORAGE_STATE_PATH);
|
||||
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);
|
||||
|
||||
// v1.37.0 — the guardian's jar, and only then its second factor.
|
||||
//
|
||||
// Order is the whole of it. `requireFreshMfa` refuses an account with no
|
||||
// factor enrolled, so the managed-profile routes are unreachable without
|
||||
// this; and `captureAuthState` posts a password to `/api/auth/login`, which
|
||||
// for a TOTP account answers with a challenge rather than a session. Log in
|
||||
// first, enrol second. The freshness stamp on the session is refreshed by
|
||||
// the spec immediately before it acts — the window is five minutes and a
|
||||
// full suite run is longer than that.
|
||||
await capture(E2E_GUARDIAN, GUARDIAN_STORAGE_STATE_PATH);
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||||
[E2E_GUARDIAN.username, now],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// Log the fixture accounts in here and persist their cookie jars so specs
|
||||
// can reuse them via `test.use({ storageState })`. This is the documented
|
||||
// Playwright pattern (`docs/auth.md`) and the only way to avoid the per-spec
|
||||
// login + rate-limit dance.
|
||||
const baseURL =
|
||||
config.projects[0]?.use.baseURL ??
|
||||
process.env.E2E_BASE_URL ??
|
||||
"http://localhost:3000";
|
||||
|
||||
// The shared jar every authenticated spec reads.
|
||||
await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH);
|
||||
|
||||
// v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant
|
||||
// signed in at once, and the login endpoint is IP-rate-limited, so the owner
|
||||
// is logged in here rather than inside the spec.
|
||||
await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH);
|
||||
|
||||
// The delegate's own jar for that journey. Same account as the shared one,
|
||||
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for
|
||||
// what happens when the journey switches the shared row instead.
|
||||
await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* v1.37.0 — the step-up the managed-profile journey needs, and nothing else.
|
||||
*
|
||||
* ## Why a fixture exists at all
|
||||
*
|
||||
* Every route in the managed-profile family resolves
|
||||
* `requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS)`. That gate has two halves:
|
||||
* the account must have a second factor enrolled, and the SESSION must carry a
|
||||
* `mfaVerifiedAt` newer than five minutes. `global-setup.ts` supplies the first
|
||||
* half once; the second cannot be supplied once, because a full suite run takes
|
||||
* longer than the window and the journey would be gated on how long the specs
|
||||
* ahead of it took.
|
||||
*
|
||||
* So the freshness stamp is refreshed immediately before the journey acts. It
|
||||
* is not a bypass and it does not weaken the route: the route still runs its
|
||||
* gate, the session still has to carry a stamp, and a session without one is
|
||||
* still refused. What it simulates is the person having just proved their
|
||||
* factor — which is the state the product requires and the only state in which
|
||||
* this surface is usable at all.
|
||||
*
|
||||
* Seeded through `pg`, the same transport `global-setup.ts` and
|
||||
* `vault-fixture.ts` use, because there is no product endpoint that says "I
|
||||
* proved a factor" without a real factor to prove.
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
import { E2E_GUARDIAN } from "./global-setup";
|
||||
|
||||
function connect(): pg.Pool {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("[managed-profile-fixture] DATABASE_URL is not set");
|
||||
}
|
||||
return new pg.Pool({ connectionString: url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the guardian's step-up fresh, and clear anything an earlier run left.
|
||||
*
|
||||
* Idempotent, and safe to call from a `beforeEach`: both statements are
|
||||
* unconditional writes scoped to this one fixture account.
|
||||
*/
|
||||
export async function refreshGuardianStepUp(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
const stamped = await pool.query(
|
||||
`UPDATE sessions SET mfa_verified_at = NOW()
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
// A zero-row update means the jar this journey runs under points at a
|
||||
// session that is not there, and every act below would be refused for a
|
||||
// reason that has nothing to do with what it tests. Fail loudly here.
|
||||
if ((stamped.rowCount ?? 0) === 0) {
|
||||
throw new Error(
|
||||
`[managed-profile-fixture] no session for ${E2E_GUARDIAN.username}`,
|
||||
);
|
||||
}
|
||||
// And the factor itself, in case a spec ran before global setup stamped it.
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_confirmed_at = COALESCE(totp_confirmed_at, NOW())
|
||||
WHERE username = $1`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
// The guardian-invitation bucket, for the same reason `global-setup.ts`
|
||||
// clears the auth bucket between its own logins: the ceiling is ten
|
||||
// invitations an hour per caller (and ten creations), and this journey spends
|
||||
// one of each every time it
|
||||
// runs. On CI that never matters; locally, the eleventh run inside an hour
|
||||
// is answered by the product's own 429 rather than by what the test is
|
||||
// about, and the failure reads as a broken invitation form. Only this one
|
||||
// fixture account's bucket is touched.
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE key IN (
|
||||
'managed-profile:guardian-invite:' ||
|
||||
(SELECT id FROM users WHERE username = $1),
|
||||
'managed-profile:create:' ||
|
||||
(SELECT id FROM users WHERE username = $1)
|
||||
)`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every managed profile this account looks after.
|
||||
*
|
||||
* A managed profile is a real account row, so a journey that created one and
|
||||
* left it behind makes the next run's roster assertions read the wrong record.
|
||||
* Deleting the profile takes its grants with it.
|
||||
*/
|
||||
export async function clearGuardianProfiles(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
await pool.query(
|
||||
`DELETE FROM users
|
||||
WHERE managed_profile_at IS NOT NULL
|
||||
AND id IN (
|
||||
SELECT g.grantor_id FROM account_grants g
|
||||
JOIN users u ON u.id = g.grantee_id
|
||||
WHERE u.username = $1
|
||||
)`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,19 @@
|
||||
export {
|
||||
E2E_USER,
|
||||
E2E_OWNER,
|
||||
E2E_GUARDIAN,
|
||||
E2E_SCOPE_DELEGATE,
|
||||
E2E_SCOPE_RECORDS,
|
||||
E2E_LEVEL_RECORDS,
|
||||
E2E_DELEGATE_MARKER_KG,
|
||||
E2E_OWNER_MARKER_KG,
|
||||
STORAGE_STATE_PATH,
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
DELEGATE_STORAGE_STATE_PATH,
|
||||
SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
SCOPE_A11Y_STORAGE_STATE_PATH,
|
||||
FENCE_STORAGE_STATE_PATH,
|
||||
FENCE_OFFLINE_STORAGE_STATE_PATH,
|
||||
CROSS_TAB_STORAGE_STATE_PATH,
|
||||
GUARDIAN_STORAGE_STATE_PATH,
|
||||
} from "./global-setup";
|
||||
@@ -0,0 +1,478 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { FENCE_OFFLINE_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
/**
|
||||
* FENCE-AC-07 — the disk layers cannot serve one record's bytes inside
|
||||
* another.
|
||||
*
|
||||
* ## Why this is a spec of its own
|
||||
*
|
||||
* `playwright.config.ts` sets `serviceWorkers: "block"` for every other
|
||||
* project, and for a good reason written there: a worker-originated `fetch` is
|
||||
* not subject to `page.route`, so the SW would bypass the per-spec route mocks.
|
||||
*
|
||||
* This case is the exact inverse. Its whole claim is about what the
|
||||
* `healthlog-data-*` cache can and cannot serve across a record switch — and
|
||||
* with the worker blocked that cache is never populated at all, so the positive
|
||||
* control ("the owner snapshot really WAS on disk before the switch") could
|
||||
* never pass no matter what the application did. A check that cannot pass is
|
||||
* worse than no check, so this runs in the `chromium-service-worker` project
|
||||
* with the worker allowed, and uses no route mocks.
|
||||
*
|
||||
* ## What is asserted, in order
|
||||
*
|
||||
* 1. the owner's record reads really are on disk before the switch — a test
|
||||
* that seeded nothing proves nothing;
|
||||
* 2. only current-version caches exist, which is the eviction the fence's
|
||||
* design relies on to make "a response with no echo" safe;
|
||||
* 3. the switch empties the data cache before it reloads;
|
||||
* 4. every response now on disk carries the TARGET's record scope and never
|
||||
* the previous one — the claim, read off the disk layer itself rather
|
||||
* than inferred from what a page did or did not paint;
|
||||
* 5. and offline, that cache is what answers a record read, still carrying
|
||||
* the target's context. The non-empty scope set is the paired positive
|
||||
* control: an empty cache would satisfy "no previous-record bytes" while
|
||||
* proving nothing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pages whose reads the service worker is configured to hold on disk
|
||||
* (`sw-cache-policy.ts` allowlists `/api/dashboard/snapshot`,
|
||||
* `/api/measurements` and `/api/medications`). Visited rather than fetched, so
|
||||
* the requests are the app's own and carry the record-session assertion.
|
||||
*/
|
||||
const WARM_PAGES = ["/", "/measurements", "/medications"] as const;
|
||||
|
||||
/**
|
||||
* A WHOLE-record READ grant, deliberately not a narrowed one.
|
||||
*
|
||||
* The narrowed `e2e-scope-labs` fixture opens only its labs section, so the
|
||||
* nav drops the other destinations and the record reads this file drives are
|
||||
* refused as out-of-scope before the fence ever matters. Section narrowing is
|
||||
* `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that
|
||||
* hides the thing under test.
|
||||
*/
|
||||
const TARGET_USERNAME = "e2e-level-read";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the target record, retrying the click if the switch did not land.
|
||||
*
|
||||
* See the twin in `v137-record-session-fence.spec.ts`: the switch is a
|
||||
* compare-and-set the client retries exactly once, and a harness driving it at
|
||||
* machine speed can legitimately lose twice. Bounded, and a third failure is a
|
||||
* real one.
|
||||
*/
|
||||
async function enterRecord(page: Page) {
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await openSwitcher(page);
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
await entry.click();
|
||||
try {
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
return accountId;
|
||||
} catch {
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
}
|
||||
}
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function expectShellReady(page: Page) {
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
// A real budget, not the 10 s default. The gate IS the cross-tab hold: a
|
||||
// peer sits behind it until its own `/api/auth/me` resolves, and that read
|
||||
// queues behind whatever the other worker's browser is doing. Ten seconds
|
||||
// is a statement about machine load; thirty is about the hold actually
|
||||
// never releasing, which is the failure worth reporting.
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "User menu" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the `healthlog-data-*` caches hold right now. */
|
||||
async function dataCacheState(page: Page) {
|
||||
return page.evaluate(async () => {
|
||||
const names = await caches.keys();
|
||||
const data = names.filter((n) => n.startsWith("healthlog-data-"));
|
||||
let entries = 0;
|
||||
for (const name of data) {
|
||||
entries += (await (await caches.open(name)).keys()).length;
|
||||
}
|
||||
return { names, dataCaches: data, entries };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The record context every cached response was stored WITH.
|
||||
*
|
||||
* This is the disk-layer claim, read off the disk layer. A cached record
|
||||
* response carries the echo it was served under, so if a previous record's
|
||||
* bytes were still reachable they would show up here as that record's scope.
|
||||
*/
|
||||
async function cachedScopes(page: Page): Promise<string[]> {
|
||||
return page.evaluate(async () => {
|
||||
const scopes: string[] = [];
|
||||
for (const name of (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
)) {
|
||||
const cache = await caches.open(name);
|
||||
for (const request of await cache.keys()) {
|
||||
const cached = await cache.match(request);
|
||||
const scope = cached?.headers.get("x-healthlog-record-scope");
|
||||
if (scope) scopes.push(scope);
|
||||
}
|
||||
}
|
||||
return scopes;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
|
||||
test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_OFFLINE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
// The gate is spelled out here rather than left inside a helper because
|
||||
// the next act is a one-shot `page.evaluate`, and
|
||||
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
|
||||
// text between the navigation and the read.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
// The session is shared with the other sharing journeys, so "not inside
|
||||
// a record" is not something a fresh page can assume.
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
if ((await banner.count()) > 0) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Wait for the worker to control the page — until it does, nothing it
|
||||
// would cache is going through it.
|
||||
await page.waitForFunction(
|
||||
() => navigator.serviceWorker?.controller !== null,
|
||||
undefined,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
// Warm the disk cache by VISITING the pages, not by issuing bare
|
||||
// fetches. A bare `fetch` in `page.evaluate` carries no record-session
|
||||
// assertion, so on a session that has been inside a shared record the
|
||||
// fence refuses it and nothing cacheable comes back — the cache filled
|
||||
// from the app's own reads instead, and those were the ones that happened
|
||||
// to resolve no record scope. Driving the app is what puts real,
|
||||
// echo-carrying record responses on disk.
|
||||
for (const path of WARM_PAGES) {
|
||||
await page.goto(path);
|
||||
// Spelled out rather than left inside `expectShellReady`, because the
|
||||
// next act is a one-shot `page.evaluate` and
|
||||
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
|
||||
// text between the navigation and the read. A gate hidden in a helper
|
||||
// is invisible to that guard and to the next reader.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await dataCacheState(page)).entries, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// (1) POSITIVE CONTROL: the owner's bytes really are on disk.
|
||||
const seeded = await dataCacheState(page);
|
||||
expect(seeded.dataCaches.length).toBeGreaterThan(0);
|
||||
expect(seeded.entries).toBeGreaterThan(0);
|
||||
|
||||
// (2) Only current-version caches survive activation. This is the
|
||||
// eviction the fence's "a response with no echo is served normally" rule
|
||||
// depends on: a PRE-fence cached response carries no echo, and it can
|
||||
// only be served if its cache is still there.
|
||||
const version = seeded.dataCaches[0].replace("healthlog-data-", "");
|
||||
const stale = seeded.names.filter(
|
||||
(n) =>
|
||||
/^healthlog-(static|pages|data)-/.test(n) && !n.endsWith(version),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
|
||||
await openSwitcher(page);
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
const targetAccountId = await entry.getAttribute("data-account-id");
|
||||
expect(targetAccountId).not.toBeNull();
|
||||
await entry.click();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// (3) NOT asserted: "the data cache is momentarily empty".
|
||||
//
|
||||
// The switch does wipe the disk layers — `clearOfflineCachesForRecordSwitch`
|
||||
// runs before the reload — but the reload immediately refills them from
|
||||
// the target's own reads, so an empty cache is a state that exists for a
|
||||
// few milliseconds and cannot be sampled reliably. Polling for it made
|
||||
// this case fail for a reason that had nothing to do with the claim.
|
||||
//
|
||||
// The claim is asserted directly below instead, and it is the stronger
|
||||
// statement anyway: what matters is not that the cache was briefly empty
|
||||
// but that nothing in it belongs to the previous record.
|
||||
|
||||
// Visit inside the TARGET record so there is something of its own on
|
||||
// disk to find.
|
||||
for (const path of WARM_PAGES) {
|
||||
await page.goto(path);
|
||||
// Spelled out rather than left inside `expectShellReady`, because the
|
||||
// next act is a one-shot `page.evaluate` and
|
||||
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
|
||||
// text between the navigation and the read. A gate hidden in a helper
|
||||
// is invisible to that guard and to the next reader.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await dataCacheState(page)).entries, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// (4) + (5) The disk layer now holds the TARGET's bytes and only those,
|
||||
// read off the disk layer itself.
|
||||
//
|
||||
// The first draft asserted the shared-record banner after an offline
|
||||
// reload. That could never pass and had nothing to do with the claim:
|
||||
// `/api/auth/me` is not a cached read, so offline it fails, `useAuth`
|
||||
// errors and the shell paints its offline gate rather than a banner. The
|
||||
// banner's absence says nothing about what is on disk.
|
||||
//
|
||||
// What IS on disk is checkable, and it is the whole point: every cached
|
||||
// record response carries the echo it was served under, so a previous
|
||||
// record's bytes surviving the switch would appear here as that record's
|
||||
// scope.
|
||||
const scopes = await cachedScopes(page);
|
||||
// POSITIVE CONTROL: something really is cached with a context, so the
|
||||
// assertion below is about a non-empty set.
|
||||
expect(scopes.length).toBeGreaterThan(0);
|
||||
for (const scope of scopes) {
|
||||
expect(scope).toBe(targetAccountId);
|
||||
}
|
||||
expect(scopes).not.toContain("self");
|
||||
|
||||
// And the target's own record read really is one of the entries on disk,
|
||||
// so the scope assertion above is about the reads that matter rather
|
||||
// than about some incidental cached response.
|
||||
const cachedUrls = await page.evaluate(async () => {
|
||||
const urls: string[] = [];
|
||||
for (const name of (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
)) {
|
||||
for (const request of await (await caches.open(name)).keys()) {
|
||||
urls.push(new URL(request.url).pathname);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
});
|
||||
expect(cachedUrls).toContain("/api/measurements");
|
||||
|
||||
// NOT asserted: that a bare `fetch` from page context can read those
|
||||
// bytes back while offline. Since v1.37.0 an echoing response also
|
||||
// carries `Vary: x-healthlog-record-epoch, x-healthlog-record-scope`, and
|
||||
// the Cache API honours `Vary` — so a request WITHOUT the fence headers
|
||||
// does not match the stored entry at all. That is the correct behaviour
|
||||
// and a second line of defence worth naming: bytes cached under one
|
||||
// record context cannot be retrieved by a request asserting a different
|
||||
// one, or none. The app's own reads carry the headers and do match.
|
||||
} finally {
|
||||
await context.setOffline(false);
|
||||
// Hand the shared session back to its own record; see the note in
|
||||
// `v137-record-session-fence.spec.ts`.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-07 a cached record response cannot be SERVED under a different context", async ({
|
||||
browser,
|
||||
}) => {
|
||||
/**
|
||||
* The serving path, which the case above does not exercise.
|
||||
*
|
||||
* The test above asserts what is IN the cache. That is worth asserting and
|
||||
* it is not the same claim: an entry can sit on disk under one context and
|
||||
* still be handed to a request made under another, and on the EXTERNAL
|
||||
* switch path nothing wipes it — the app never initiated the switch, so
|
||||
* `clearOfflineCachesForRecordSwitch` never runs. On that path `Vary` is
|
||||
* the only thing standing between a cached delegated response and the next
|
||||
* request for the same URL.
|
||||
*
|
||||
* So this asks the Cache API the question a service worker asks it:
|
||||
* `cache.match(request)` honours the stored response's `Vary`, so a request
|
||||
* whose fence headers differ from the ones the entry was stored with does
|
||||
* not match at all. Remove the `Vary` block from `attachRecordContextEcho`
|
||||
* and the second lookup starts hitting.
|
||||
*/
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_OFFLINE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
if ((await banner.count()) > 0) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => navigator.serviceWorker?.controller !== null,
|
||||
undefined,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
// Enter the record and let its reads land on disk.
|
||||
await enterRecord(page);
|
||||
await page.goto("/measurements");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
|
||||
// The context those bytes were stored under, read off the entry itself.
|
||||
//
|
||||
// Polled, not sampled once. The service worker writes its `healthlog-data-*`
|
||||
// entry AFTER the response has been handed to the page, so
|
||||
// `expectShellReady` returning is not evidence that the write has landed —
|
||||
// it is evidence that the read arrived. Sampling here read `null` on a
|
||||
// loaded machine and failed the positive control below, which then said
|
||||
// "there is no cache entry" about a cache that was about to have one.
|
||||
// Waiting on the entry itself waits for the signal rather than for a
|
||||
// number of milliseconds, and it still fails if the entry never appears.
|
||||
const readStoredEntry = async () =>
|
||||
page.evaluate(async () => {
|
||||
for (const name of (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
)) {
|
||||
const cache = await caches.open(name);
|
||||
for (const request of await cache.keys()) {
|
||||
if (
|
||||
!new URL(request.url).pathname.startsWith("/api/measurements")
|
||||
)
|
||||
continue;
|
||||
const response = await cache.match(request);
|
||||
if (!response) continue;
|
||||
return {
|
||||
cacheName: name,
|
||||
url: request.url,
|
||||
vary: response.headers.get("Vary"),
|
||||
epoch: response.headers.get("x-healthlog-record-epoch"),
|
||||
scope: response.headers.get("x-healthlog-record-scope"),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// POSITIVE CONTROL: there really is an entry, stored with a context.
|
||||
await expect.poll(readStoredEntry, { timeout: 20_000 }).not.toBeNull();
|
||||
const stored = await readStoredEntry();
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.epoch).toMatch(/^\d+$/);
|
||||
expect(stored!.scope).not.toBe("self");
|
||||
|
||||
// The EXTERNAL switch: driven from a request context, so the app never
|
||||
// runs its cache wipe and the entry above survives.
|
||||
const left = await context.request.post("/api/account/switch", {
|
||||
data: { accountId: null },
|
||||
});
|
||||
expect(left.status()).toBe(200);
|
||||
const me = await context.request.get("/api/auth/me");
|
||||
const now = (await me.json()).data.recordSession as {
|
||||
epoch: number;
|
||||
scope: string | null;
|
||||
};
|
||||
// The context genuinely moved, or the comparison below is vacuous.
|
||||
expect(String(now.epoch)).not.toBe(stored!.epoch);
|
||||
|
||||
const lookups = await page.evaluate(
|
||||
async ({
|
||||
cacheName,
|
||||
url,
|
||||
storedEpoch,
|
||||
storedScope,
|
||||
nowEpoch,
|
||||
nowScope,
|
||||
}) => {
|
||||
const cache = await caches.open(cacheName);
|
||||
const ask = async (epoch: string, scope: string) =>
|
||||
(await cache.match(
|
||||
new Request(url, {
|
||||
headers: {
|
||||
"x-healthlog-record-epoch": epoch,
|
||||
"x-healthlog-record-scope": scope,
|
||||
},
|
||||
}),
|
||||
)) !== undefined;
|
||||
return {
|
||||
underStoredContext: await ask(storedEpoch, storedScope),
|
||||
underNewContext: await ask(nowEpoch, nowScope),
|
||||
withNoAssertion:
|
||||
(await cache.match(new Request(url))) !== undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
cacheName: stored!.cacheName,
|
||||
url: stored!.url,
|
||||
storedEpoch: stored!.epoch!,
|
||||
storedScope: stored!.scope!,
|
||||
nowEpoch: String(now.epoch),
|
||||
nowScope: now.scope ?? "self",
|
||||
},
|
||||
);
|
||||
|
||||
// POSITIVE CONTROL: the entry IS servable to the context it belongs to.
|
||||
// Without this, "the other context misses" would pass for an entry that
|
||||
// was simply not there.
|
||||
expect(lookups.underStoredContext).toBe(true);
|
||||
// The claim. Remove the `Vary` and this starts hitting.
|
||||
expect(lookups.underNewContext).toBe(false);
|
||||
expect(lookups.withNoAssertion).toBe(false);
|
||||
} finally {
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,615 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { FENCE_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
/**
|
||||
* The record-session fence, in a real browser.
|
||||
*
|
||||
* `e2e/v137-sharing-cross-tab-session.spec.ts` proves the COOPERATIVE layer:
|
||||
* a peer tab holds while the initiator's switch is in flight, because the
|
||||
* initiator told it to. This file proves the cases that layer cannot reach —
|
||||
* the ones where nobody tells anybody anything:
|
||||
*
|
||||
* * FENCE-AC-01 peers reconcile to the exact new epoch and scope, not merely
|
||||
* to "something changed";
|
||||
* * FENCE-AC-02 the initiator's response never arrives, because the tab is
|
||||
* torn off mid-flight;
|
||||
* * FENCE-AC-03 the initiator is CLOSED after the server committed, so no
|
||||
* commit broadcast is ever published;
|
||||
* * FENCE-AC-06 a record response delayed across the switch is discarded by
|
||||
* the client rather than painted;
|
||||
* * FENCE-AC-07 the disk layers — CacheStorage and the service worker's
|
||||
* `healthlog-data-*` — cannot serve one record's bytes inside another.
|
||||
*
|
||||
* ## Why every absence assertion here is paired
|
||||
*
|
||||
* "The peer never showed the owner's data" passes on a page that is
|
||||
* permanently wedged, which is a worse outcome than the bug. So each of those
|
||||
* legs additionally asserts the peer REACHES a usable state on the target
|
||||
* scope once `/me` resolves. An absence-only assertion in this file would be a
|
||||
* check that cannot fail.
|
||||
*
|
||||
* Uses the existing synthetic `e2e/setup` fixtures; introduces no new account
|
||||
* anchors.
|
||||
*/
|
||||
|
||||
const RECORD_READS = [
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/insights/targets",
|
||||
"/api/medications",
|
||||
"/api/medications/compliance",
|
||||
"/api/labs",
|
||||
] as const;
|
||||
|
||||
const EPOCH_HEADER = "x-healthlog-record-epoch";
|
||||
const SCOPE_HEADER = "x-healthlog-record-scope";
|
||||
|
||||
/**
|
||||
* A WHOLE-record READ grant, deliberately not a narrowed one.
|
||||
*
|
||||
* The narrowed `e2e-scope-labs` fixture opens only its labs section, so the
|
||||
* nav drops the other destinations and the record reads this file drives are
|
||||
* refused as out-of-scope before the fence ever matters. Section narrowing is
|
||||
* `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that
|
||||
* hides the thing under test.
|
||||
*/
|
||||
const TARGET_USERNAME = "e2e-level-read";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
}
|
||||
|
||||
function switcherEntry(page: Page, username = TARGET_USERNAME) {
|
||||
return page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave any record this session is already inside.
|
||||
*
|
||||
* These specs each begin by switching INTO a record, so they need to start
|
||||
* outside one — and the session is shared with the other sharing journeys, so
|
||||
* "outside one" is not something a fresh page can assume. Without this the
|
||||
* file passes in isolation and fails whenever it runs after a spec that left
|
||||
* the session switched, which is the least useful kind of failure.
|
||||
*/
|
||||
async function ensureOwnRecord(page: Page) {
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
if ((await banner.count()) === 0) return;
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0, { timeout: 30_000 });
|
||||
// And WAIT for the reload to finish.
|
||||
//
|
||||
// Leaving a record is a full document navigation, and the banner vanishing
|
||||
// only means that navigation started. Returning there hands the next step a
|
||||
// page that has not re-read `/api/auth/me` yet — so its adopted epoch is
|
||||
// still `bootstrap`, its switch sends `expectedEpoch: 0`, the compare-and-set
|
||||
// loses against the epoch the exit itself just moved, and the switch quietly
|
||||
// does not happen. The symptom is a banner that never appears, thirty
|
||||
// seconds later, with nothing wrong in the product.
|
||||
await expectShellReady(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the target record, retrying the click if the switch did not land.
|
||||
*
|
||||
* Not a workaround for a flaky product — a fixture acknowledging a contract the
|
||||
* product chose deliberately. `POST /api/account/switch` is a compare-and-set
|
||||
* on the session's record epoch, and the client reconciles and retries exactly
|
||||
* ONCE; a second lost CAS surfaces as a failed toast rather than being retried
|
||||
* forever, which is the right call for a person at a keyboard.
|
||||
*
|
||||
* These tests are not a person at a keyboard. Some drive two tabs of ONE
|
||||
* session at machine speed while the peer is reloading and re-adopting, which
|
||||
* is precisely the "the browser is racing itself" case that contract names —
|
||||
* so the switcher click legitimately loses sometimes, and the product is
|
||||
* behaving as designed when it does. The fixture re-presses the button, which
|
||||
* is what the person would do.
|
||||
*
|
||||
* Bounded and asserted: if three attempts cannot enter the record, that is a
|
||||
* real failure and it fails.
|
||||
*/
|
||||
async function enterRecord(page: Page, username = TARGET_USERNAME) {
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
let accountId: string | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await openSwitcher(page);
|
||||
const entry = switcherEntry(page, username);
|
||||
await expect(entry).toBeVisible();
|
||||
accountId ??= await entry.getAttribute("data-account-id");
|
||||
await entry.click();
|
||||
try {
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
return accountId;
|
||||
} catch {
|
||||
// The switcher menu may still be open over the page; start clean.
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
}
|
||||
}
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
return accountId;
|
||||
}
|
||||
|
||||
/** Wait for the shell to be usable again — the paired positive control. */
|
||||
async function expectShellReady(page: Page) {
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
// A real budget, not the 10 s default. The gate IS the cross-tab hold: a
|
||||
// peer sits behind it until its own `/api/auth/me` resolves, and that read
|
||||
// queues behind whatever the other worker's browser is doing. Ten seconds
|
||||
// is a statement about machine load; thirty is about the hold actually
|
||||
// never releasing, which is the failure worth reporting.
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "User menu" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The furthest-along assertion in a collected set.
|
||||
*
|
||||
* The epoch is monotonic, so the highest one is the freshest context this
|
||||
* browser has reached — independent of the order the requests happened to be
|
||||
* observed in.
|
||||
*/
|
||||
function latestAssertion(
|
||||
collected: { epoch: string; scope: string }[],
|
||||
): { epoch: string; scope: string } | null {
|
||||
let best: { epoch: string; scope: string } | null = null;
|
||||
for (const assertion of collected) {
|
||||
if (!/^\d+$/.test(assertion.epoch)) continue;
|
||||
if (best === null || Number(assertion.epoch) > Number(best.epoch)) {
|
||||
best = assertion;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The record context a page asserted on its most recent record read. */
|
||||
function collectAssertions(
|
||||
page: Page,
|
||||
into: { epoch: string; scope: string }[],
|
||||
) {
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (!(RECORD_READS as readonly string[]).includes(path)) return;
|
||||
const headers = request.headers();
|
||||
const epoch = headers[EPOCH_HEADER];
|
||||
const scope = headers[SCOPE_HEADER];
|
||||
if (epoch === undefined || scope === undefined) return;
|
||||
into.push({ epoch, scope });
|
||||
});
|
||||
}
|
||||
|
||||
test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
test("FENCE-AC-01 a peer reconciles to the exact new epoch and scope, in both directions", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Two full switches, two peer reloads each, and a poll that reloads until
|
||||
// the server write is visible to the second tab. That does not fit the
|
||||
// 30 s default, and a test that times out reports a failure that says
|
||||
// nothing about the fence.
|
||||
test.setTimeout(120_000);
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
const peerAssertions: { epoch: string; scope: string }[] = [];
|
||||
collectAssertions(peer, peerAssertions);
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(initiator);
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
|
||||
const accountId = await enterRecord(initiator);
|
||||
expect(accountId).not.toBeNull();
|
||||
|
||||
// Cleared HERE, not before the click: between the click and the reload
|
||||
// the peer is still issuing reads under the context it had, and counting
|
||||
// those made the assertion below compare a pre-switch `self` against the
|
||||
// post-switch account. Only what the peer asserts once it has reconciled
|
||||
// is evidence about reconciliation.
|
||||
peerAssertions.length = 0;
|
||||
|
||||
// The peer reaches a usable shell again — the positive control, without
|
||||
// which every assertion below would pass on a wedged page.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// And every record read it now issues names the record it is actually
|
||||
// in, not "something". A peer that reconciled to a guess would show up
|
||||
// here as a scope naming the wrong account or a stale epoch.
|
||||
// The HIGHEST-epoch assertion, not every collected one.
|
||||
//
|
||||
// The epoch is monotonic, and requests do not arrive in the order they
|
||||
// were issued — a read from the page before the reload can be observed
|
||||
// after one from the page after it. Requiring EVERY collected assertion
|
||||
// to name the new record is therefore a claim about network ordering
|
||||
// rather than about reconciliation, and it fails intermittently for a
|
||||
// reason that has nothing to do with the fence. What reconciliation
|
||||
// means is that the peer CONVERGES on the new context, which is exactly
|
||||
// the highest epoch it ever asserts.
|
||||
const inRecord = latestAssertion(peerAssertions);
|
||||
expect(inRecord).not.toBeNull();
|
||||
expect(inRecord!.scope).toBe(accountId);
|
||||
const inRecordEpoch = Number(inRecord!.epoch);
|
||||
expect(inRecordEpoch).toBeGreaterThan(0);
|
||||
|
||||
// ── and the same on the way out ───────────────────────────────────
|
||||
await initiator
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click();
|
||||
await expect(
|
||||
initiator.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
peerAssertions.length = 0;
|
||||
// Polled with reloads rather than reloaded once. The initiator's banner
|
||||
// disappearing means its own document navigated, not that the server
|
||||
// write has landed and become visible to another tab — asserting on one
|
||||
// reload made this leg a race against the switch-out commit.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
return peer.locator('[data-slot="shared-record-banner"]').count();
|
||||
},
|
||||
{ timeout: 40_000 },
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
const backHome = latestAssertion(peerAssertions);
|
||||
expect(backHome).not.toBeNull();
|
||||
expect(backHome!.scope).toBe("self");
|
||||
// Leaving moved the epoch too: a tab believing it is still inside the
|
||||
// record is the same defect as the reverse.
|
||||
expect(Number(backHome!.epoch)).toBeGreaterThan(inRecordEpoch);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-02 an aborted switch response leaves peers correct, and they still reach ready", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
const peerAssertions: { epoch: string; scope: string }[] = [];
|
||||
collectAssertions(peer, peerAssertions);
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(initiator);
|
||||
|
||||
// Drive the switch server-side and abort the response on the way back:
|
||||
// the write lands, the initiator never learns it did, and no commit
|
||||
// broadcast is ever published.
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
expect(accountId).not.toBeNull();
|
||||
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
await route.fetch();
|
||||
await route.abort();
|
||||
});
|
||||
await entry.click({ noWaitAfter: true });
|
||||
await initiator.waitForTimeout(500);
|
||||
|
||||
// The peer must never paint owner-ready state it guessed at …
|
||||
const guessed = peerAssertions.filter(
|
||||
(a) => a.scope === accountId && a.epoch === "bootstrap",
|
||||
);
|
||||
expect(guessed).toEqual([]);
|
||||
|
||||
// … and must still REACH ready on the target scope once `/me` answers.
|
||||
// Without this the leg above would pass on a permanently wedged page.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
expect(peerAssertions.some((a) => a.scope === accountId)).toBe(true);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-03 a peer reaches ready after the initiator is closed post-commit", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(initiator);
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
await expect(entry).toBeVisible();
|
||||
|
||||
// Hold the response, close the tab while it is in flight. The server has
|
||||
// committed; nothing will ever broadcast the commit.
|
||||
const held = deferred();
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
await route.fetch();
|
||||
await held.promise;
|
||||
await route.abort();
|
||||
});
|
||||
await entry.click({ noWaitAfter: true });
|
||||
await initiator.waitForTimeout(300);
|
||||
held.release();
|
||||
await initiator.close();
|
||||
|
||||
// The surviving tab is not permanently gated: `/me` is the bootstrap and
|
||||
// it answers regardless of who began the transition.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-04 an external switch refuses the stale peer's next record read", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const peer = await context.newPage();
|
||||
// Collected from the very start: the refusal can land on a poll the app
|
||||
// issues by itself, before this test navigates anywhere.
|
||||
const refusals: string[] = [];
|
||||
peer.on("response", (response) => {
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (response.status() !== 409) return;
|
||||
// ANY refused API read, not one of a named five. Which cells a page
|
||||
// mounts is a rendering decision that moves with the product, and a
|
||||
// hard-coded list turns "the fence refused the stale tab" into "the
|
||||
// fence refused one of the paths I happened to name" — which is how
|
||||
// this case first passed while observing nothing.
|
||||
if (!path.startsWith("/api/")) return;
|
||||
refusals.push(path);
|
||||
});
|
||||
|
||||
try {
|
||||
await peer.goto("/");
|
||||
await expectShellReady(peer);
|
||||
await ensureOwnRecord(peer);
|
||||
|
||||
// A switch driven from a request context that runs no client journal at
|
||||
// all — the "raw or external" case the cooperative layer cannot see.
|
||||
await openSwitcher(peer);
|
||||
const entry = switcherEntry(peer);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
expect(accountId).not.toBeNull();
|
||||
await peer.keyboard.press("Escape");
|
||||
|
||||
const response = await context.request.post("/api/account/switch", {
|
||||
data: { accountId },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The tab is now asserting a context the row has left — but observing
|
||||
// the refusal takes one deliberate step, and the reason is worth stating
|
||||
// because it is a property of the design rather than a quirk of the test.
|
||||
//
|
||||
// `GET /api/auth/me` is UNFENCED (it is the reconciliation bootstrap and
|
||||
// must answer while the context is unknown) and it is one of the two
|
||||
// responses a client may ADOPT from. So whenever the app happens to
|
||||
// re-read `/me` before it reads a record, it silently adopts the new
|
||||
// context and no stale assertion is ever sent. That is correct — and it
|
||||
// means a test that just navigates observes nothing, which is how the
|
||||
// first draft of this case passed while proving nothing.
|
||||
//
|
||||
// The window the fence exists for is the opposite ordering: a suspended
|
||||
// peer whose RECORD read goes out before any reconciliation. Holding
|
||||
// `/me` produces exactly that ordering, deterministically.
|
||||
// Held exactly once, and never unrouted: calling `unroute` while a
|
||||
// handler is still parked inside its `await` abandons that route, and
|
||||
// the `continue()` on the far side then throws "Route is already
|
||||
// handled". Letting the handler fall through after the first hold keeps
|
||||
// one owner for the route from start to finish.
|
||||
const heldMe: { release: (() => void) | null; done: boolean } = {
|
||||
release: null,
|
||||
done: false,
|
||||
};
|
||||
await peer.route("**/api/auth/me", async (route) => {
|
||||
if (heldMe.done) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
heldMe.done = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
heldMe.release = resolve;
|
||||
});
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const soft = peer.locator('aside[aria-label="Sidebar"]');
|
||||
await expect(soft).toBeVisible();
|
||||
await soft.getByRole("link", { name: "Measurements" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => refusals.length, { timeout: 20_000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Let the reconciliation through and confirm the tab recovers rather
|
||||
// than sitting on the refusal.
|
||||
heldMe.release?.();
|
||||
|
||||
// Reconciled, not evicted: the tab ends up INSIDE the record the
|
||||
// external switch put it in, with a usable shell.
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expectShellReady(peer);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-06 a record response delayed across the switch is discarded", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// The intercept goes on BEFORE the first navigation. Installed after the
|
||||
// page had already loaded, it matched nothing — the only request for that
|
||||
// path had come and gone, and the case passed while observing nothing.
|
||||
//
|
||||
// And it holds `/api/measurements`, a FENCED record read. The first
|
||||
// version held `/api/auth/me` because that one is alive on every boot —
|
||||
// but `/me` is an actor surface, the fence never runs on it, and it
|
||||
// therefore echoes no record context at all. Holding it captured a null
|
||||
// echo forever, which is the same "observed nothing" failure wearing a
|
||||
// different path.
|
||||
const held: {
|
||||
armed: boolean;
|
||||
release: (() => void) | null;
|
||||
epoch: string | null;
|
||||
scope: string | null;
|
||||
} = { armed: false, release: null, epoch: null, scope: null };
|
||||
|
||||
await page.route("**/api/measurements**", async (route) => {
|
||||
if (!held.armed) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
held.armed = false;
|
||||
const response = await route.fetch();
|
||||
held.epoch = response.headers()["x-healthlog-record-epoch"] ?? null;
|
||||
held.scope = response.headers()["x-healthlog-record-scope"] ?? null;
|
||||
await new Promise<void>((resolve) => {
|
||||
held.release = resolve;
|
||||
});
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
await ensureOwnRecord(page);
|
||||
|
||||
const accountId = await enterRecord(page);
|
||||
|
||||
// Arm the hold, then make the app issue the read inside the record.
|
||||
held.armed = true;
|
||||
const sidebar = page.locator('aside[aria-label="Sidebar"]');
|
||||
await expect(sidebar).toBeVisible();
|
||||
await sidebar
|
||||
.getByRole("link", { name: "Measurements" })
|
||||
.click({ noWaitAfter: true });
|
||||
|
||||
await expect
|
||||
.poll(() => held.release !== null, { timeout: 20_000 })
|
||||
.toBe(true);
|
||||
|
||||
// The response was admitted under the record's context and says so.
|
||||
// Proof the intercept saw a real fenced read rather than matching
|
||||
// nothing, which is the whole failure mode this case kept falling into.
|
||||
expect(held.epoch).toMatch(/^\d+$/);
|
||||
expect(held.scope).toBe(accountId);
|
||||
|
||||
// Now move the context out from under it, and only then let it land.
|
||||
const leaving = page
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click({ noWaitAfter: true });
|
||||
await page.waitForTimeout(500);
|
||||
held.release?.();
|
||||
await leaving.catch(() => {});
|
||||
|
||||
// The page settles on the record it is actually in — its own — without
|
||||
// painting the one the held response described.
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0, { timeout: 20_000 });
|
||||
await expectShellReady(page);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { CROSS_TAB_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
test.describe.serial("cross-tab shared-record session switches", () => {
|
||||
test("holds a peer tab before both switch-in and switch-out change the shared session", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: CROSS_TAB_STORAGE_STATE_PATH,
|
||||
});
|
||||
const switchingPage = await context.newPage();
|
||||
const peerPage = await context.newPage();
|
||||
const pausedSwitches: ReturnType<typeof deferred>[] = [];
|
||||
const peerRecordReads: string[] = [];
|
||||
|
||||
await switchingPage.route("**/api/account/switch", async (route) => {
|
||||
const pause = pausedSwitches.shift();
|
||||
if (pause) await pause.promise;
|
||||
await route.continue();
|
||||
});
|
||||
peerPage.on("request", (request) => {
|
||||
if (request.method() !== "GET") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/insights/targets",
|
||||
"/api/medications",
|
||||
"/api/medications/compliance",
|
||||
"/api/labs",
|
||||
"/api/labs/ocr/capability",
|
||||
"/api/insights/coach/nudge-status",
|
||||
].includes(path)
|
||||
) {
|
||||
peerRecordReads.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([switchingPage.goto("/"), peerPage.goto("/")]);
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
|
||||
await openSwitcher(switchingPage);
|
||||
const entry = switchingPage.locator(
|
||||
'[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]',
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
const switchIn = deferred();
|
||||
pausedSwitches.push(switchIn);
|
||||
peerRecordReads.length = 0;
|
||||
const switchInClick = entry.click({ noWaitAfter: true });
|
||||
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toHaveCount(0);
|
||||
await peerPage.waitForTimeout(200);
|
||||
expect(peerRecordReads).toEqual([]);
|
||||
|
||||
switchIn.release();
|
||||
await switchInClick;
|
||||
await expect(
|
||||
switchingPage.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
const switchOut = deferred();
|
||||
pausedSwitches.push(switchOut);
|
||||
peerRecordReads.length = 0;
|
||||
const switchOutClick = switchingPage
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click({ noWaitAfter: true });
|
||||
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
await peerPage.waitForTimeout(200);
|
||||
expect(peerRecordReads).toEqual([]);
|
||||
|
||||
switchOut.release();
|
||||
await switchOutClick;
|
||||
await expect(
|
||||
switchingPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { SCOPE_A11Y_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
/**
|
||||
* The five states of a shared record, each proved usable rather than merely
|
||||
* reachable.
|
||||
*
|
||||
* Accessibility work concentrates on the state where everything worked,
|
||||
* because that is the state the person building the screen is looking at. The
|
||||
* other four are where somebody who cannot see the screen is most stranded: a
|
||||
* spinner nobody announces, an error card that reads as an empty page, a
|
||||
* refusal whose only way out is a control no keyboard reaches. This file had
|
||||
* four tests for five states, success and empty shared one, and neither of
|
||||
* them entered an empty state at all.
|
||||
*
|
||||
* Each test now names its state in brackets, enters it, and runs four proofs:
|
||||
* an axe scan, the reading order, either a keyboard reach or an announcement,
|
||||
* and — where the state has copy somebody has to act on — that the copy is
|
||||
* content rather than muted meta.
|
||||
*
|
||||
* `src/__tests__/sharing-accessibility-states.test.tsx` reads this file back
|
||||
* against `tests/fixtures/v137/e2e-journeys.ts` and fails if a state loses its
|
||||
* test or one of its proofs. A browser suite reports what it ran, never what
|
||||
* it did not, so the enumeration lives where a fast test can check it.
|
||||
*/
|
||||
|
||||
/** WCAG 2.1 AA, as the rest of the suite scans it. */
|
||||
async function scanForViolations(page: Page) {
|
||||
const result = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(result.violations).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The named elements appear in this order in the document.
|
||||
*
|
||||
* Reading order is what a screen reader follows and what a keyboard walks, and
|
||||
* CSS can put a control visually above an explanation while the DOM keeps it
|
||||
* below. Every selector must resolve: an absent element would otherwise make
|
||||
* an order of one trivially correct, which is the shape of check that passes
|
||||
* for the whole life of a regression.
|
||||
*/
|
||||
async function expectReaderOrder(page: Page, selectors: string[]) {
|
||||
expect(selectors.length).toBeGreaterThan(1);
|
||||
const positions = await page.evaluate((list) => {
|
||||
const all = Array.from(document.querySelectorAll("*"));
|
||||
return list.map((selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
return element ? all.indexOf(element) : -1;
|
||||
});
|
||||
}, selectors);
|
||||
|
||||
const missing = selectors.filter((_, i) => positions[i] === -1);
|
||||
expect(missing, `not on the page: ${missing.join(", ")}`).toEqual([]);
|
||||
expect(positions, `reading order is ${selectors.join(" then ")}`).toEqual(
|
||||
[...positions].sort((a, b) => a - b),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab from the top of the document until focus lands on the target.
|
||||
*
|
||||
* Real Tab presses rather than `element.focus()`, for two reasons: a control
|
||||
* that is present but not in the tab order fails here, which is the defect
|
||||
* this proves the absence of; and `:focus-visible` only applies to keyboard
|
||||
* focus, so the ring can be asserted at the same time. The invitation form's
|
||||
* own radios shipped `sr-only` inputs whose focus ring landed on a zero-size
|
||||
* box — the whole class of defect is invisible to `element.focus()`.
|
||||
*/
|
||||
async function expectKeyboardReach(page: Page, selector: string) {
|
||||
await expect(page.locator(selector).first()).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
const MAX_TABS = 120;
|
||||
let reached = false;
|
||||
for (let i = 0; i < MAX_TABS && !reached; i += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
reached = await page.evaluate(
|
||||
(sel) => document.activeElement?.matches(sel) ?? false,
|
||||
selector,
|
||||
);
|
||||
}
|
||||
expect(reached, `${selector} is not reachable by Tab`).toBe(true);
|
||||
|
||||
// And the person can see where they are. A focusable control with no
|
||||
// visible indicator is WCAG 2.4.7 and is exactly what an `sr-only` input
|
||||
// produces.
|
||||
const outlined = await page.evaluate((sel) => {
|
||||
const element = document.querySelector(sel);
|
||||
if (!element) return false;
|
||||
if (element.matches(":focus-visible")) return true;
|
||||
// Some rings are painted by an ancestor via `has-[:focus-visible]`.
|
||||
return Boolean(element.closest(":has(:focus-visible)"));
|
||||
}, selector);
|
||||
expect(outlined, `${selector} takes focus without showing it`).toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The state says out loud that it is happening.
|
||||
*
|
||||
* For the one state with nothing to press. A spinner is a picture; without a
|
||||
* live region and a label it is a blank page to a screen reader, and the hold
|
||||
* before the active record resolves is precisely when somebody must not be
|
||||
* told nothing.
|
||||
*/
|
||||
async function expectAnnounced(page: Page, selector: string) {
|
||||
const region = page.locator(selector).first();
|
||||
await expect(region).toBeVisible();
|
||||
await expect(region).toHaveAttribute("role", /status|alert/);
|
||||
const label = await region.evaluate((node) =>
|
||||
(node.textContent ?? "").trim(),
|
||||
);
|
||||
expect(label.length, `${selector} announces nothing`).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy somebody has to act on is content, not meta.
|
||||
*
|
||||
* Two halves, and both are needed. The class check is the design standard —
|
||||
* UI-STANDARDS §3 reserves `text-muted-foreground` for meta, and consent or
|
||||
* safety text rendered as meta is text people skip. The ratio check is the
|
||||
* measurable one: it walks up for an opaque background and asserts the AA
|
||||
* floor for body text, so a future token change that keeps the class and
|
||||
* loses the contrast still fails.
|
||||
*/
|
||||
async function expectContentContrast(page: Page, selector: string) {
|
||||
const result = await page.evaluate((sel) => {
|
||||
const element = document.querySelector(sel);
|
||||
if (!element) return null;
|
||||
|
||||
const parse = (value: string): [number, number, number, number] | null => {
|
||||
const nums = value.match(/[\d.]+/g);
|
||||
if (!nums || nums.length < 3) return null;
|
||||
return [
|
||||
Number(nums[0]),
|
||||
Number(nums[1]),
|
||||
Number(nums[2]),
|
||||
nums.length > 3 ? Number(nums[3]) : 1,
|
||||
];
|
||||
};
|
||||
|
||||
const luminance = ([r, g, b]: [number, number, number, number]): number => {
|
||||
const channel = (c: number) => {
|
||||
const v = c / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
};
|
||||
|
||||
const foreground = parse(getComputedStyle(element).color);
|
||||
let node: Element | null = element;
|
||||
let background: [number, number, number, number] | null = null;
|
||||
while (node && !background) {
|
||||
const parsed = parse(getComputedStyle(node).backgroundColor);
|
||||
if (parsed && parsed[3] > 0) background = parsed;
|
||||
node = node.parentElement;
|
||||
}
|
||||
if (!foreground || !background) return null;
|
||||
|
||||
const light = Math.max(luminance(foreground), luminance(background));
|
||||
const dark = Math.min(luminance(foreground), luminance(background));
|
||||
|
||||
// The class list of the element and of any descendant carrying the text:
|
||||
// the copy is sometimes a wrapper's child.
|
||||
const classes = [element, ...Array.from(element.querySelectorAll("*"))]
|
||||
.map((n) => n.className)
|
||||
.filter((c): c is string => typeof c === "string")
|
||||
.join(" ");
|
||||
|
||||
return { ratio: (light + 0.05) / (dark + 0.05), classes };
|
||||
}, selector);
|
||||
|
||||
expect(result, `${selector} is not on the page`).not.toBeNull();
|
||||
expect(
|
||||
result!.classes,
|
||||
`${selector} renders material copy in the muted token`,
|
||||
).not.toContain("text-muted-foreground");
|
||||
expect(
|
||||
result!.ratio,
|
||||
`${selector} is below the AA floor for body text`,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
|
||||
async function openProfileRecord(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
const entry = page.locator(
|
||||
'[data-slot="account-switcher-entry"][data-account-username="e2e-scope-profile"]',
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
await entry.click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
}
|
||||
|
||||
async function leaveRecord(page: Page) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("shared record accessibility states", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.use({ storageState: SCOPE_A11Y_STORAGE_STATE_PATH });
|
||||
|
||||
test("[success] the shared profile is scannable, ordered and escapable", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openProfileRecord(page);
|
||||
await expect(page.locator('[data-slot="profile-summary"]')).toBeVisible();
|
||||
|
||||
await scanForViolations(page);
|
||||
// Whose record this is comes before what is in it. A reader who meets the
|
||||
// record before the banner has already read somebody else's health data
|
||||
// believing it is their own.
|
||||
await expectReaderOrder(page, [
|
||||
'[data-slot="shared-record-banner"]',
|
||||
'[data-slot="shared-record-banner-context"]',
|
||||
'[data-slot="profile-summary"]',
|
||||
]);
|
||||
await expectContentContrast(
|
||||
page,
|
||||
'[data-slot="shared-record-banner-context"]',
|
||||
);
|
||||
await expectKeyboardReach(page, '[data-slot="shared-record-banner-exit"]');
|
||||
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("[empty] nothing shared yet reads as an answer, not a failure", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The delegate's own shared-access panel. They hold grants and have given
|
||||
// none, so the "who can open my record" card is genuinely empty — which is
|
||||
// a state the suite claimed to cover and never entered.
|
||||
await page.goto("/settings/access");
|
||||
await expect(
|
||||
page.locator('[data-slot="grants-given-card"] [data-slot="empty-state"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await scanForViolations(page);
|
||||
await expectReaderOrder(page, [
|
||||
'[data-slot="grant-invite-card"]',
|
||||
'[data-slot="grants-given-card"]',
|
||||
'[data-slot="grants-given-card"] [data-slot="empty-state"]',
|
||||
]);
|
||||
await expectContentContrast(
|
||||
page,
|
||||
'[data-slot="grants-given-card"] [data-slot="empty-state"] p',
|
||||
);
|
||||
// The way out of an empty state is the act that fills it.
|
||||
await expectKeyboardReach(page, '[data-slot="grant-invite-identifier"]');
|
||||
});
|
||||
|
||||
test("[loading] the hold before the active record is known is announced", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gate = '[data-slot="record-scope-hydration-gate"]';
|
||||
|
||||
// Hold `/api/auth/me` open for exactly as long as the assertions need,
|
||||
// then release it once.
|
||||
//
|
||||
// A fixed sleep in the handler cannot do that, and it was the cause of a
|
||||
// CI-only failure here. Three things go wrong with it. The hold ends 1500
|
||||
// ms after the FIRST request whatever the axe scan is doing, so on a slow
|
||||
// runner the state being scanned is not the state the test names. Every
|
||||
// retry and every refetch queues another sleeping handler, so the number
|
||||
// of live handlers is a function of machine speed. And a handler still
|
||||
// sleeping when its request is cancelled — a navigation, a React Query
|
||||
// abort, teardown — answers `route.continue()` with "Route is already
|
||||
// handled", which is what the runner reported.
|
||||
//
|
||||
// An explicit gate fixes all three: one release, at a point the test
|
||||
// chooses, after which the hold is provably over.
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let holds = 0;
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
holds += 1;
|
||||
await held;
|
||||
// Continuing a route the browser has already given up on is not a
|
||||
// failure of anything this test asserts, and it is the one call here
|
||||
// that can reject for a reason that has nothing to do with the subject.
|
||||
await route.continue().catch(() => {});
|
||||
});
|
||||
|
||||
await page.goto("/measurements");
|
||||
await expect(page.locator(gate)).toBeVisible();
|
||||
|
||||
await scanForViolations(page);
|
||||
await expectAnnounced(page, gate);
|
||||
// The label lives inside the region, so a reader that reaches the region
|
||||
// reaches the sentence. A label rendered as a sibling would be announced
|
||||
// separately or not at all.
|
||||
await expectReaderOrder(page, [gate, `${gate} .sr-only`]);
|
||||
|
||||
// The hold was real. Without this the whole test passes on a page that was
|
||||
// merely slow, and the state it claims to have scanned would be a
|
||||
// coincidence rather than something it caused.
|
||||
expect(
|
||||
holds,
|
||||
"nothing was held, so the gate was not this test's doing",
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
release();
|
||||
await page.unroute("**/api/auth/me");
|
||||
// And the gate was waiting on that hold rather than on something else:
|
||||
// releasing it ends the state. An entry assertion with no exit assertion
|
||||
// cannot tell a hold apart from a stuck page.
|
||||
await expect(page.locator(gate)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("[error] a failed read says so and hands the retry to a keyboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/profile/summary**", (route) =>
|
||||
route.fulfill({ status: 500, body: "{}" }),
|
||||
);
|
||||
await openProfileRecord(page);
|
||||
await expect(page.locator('[data-slot="query-error-card"]')).toBeVisible();
|
||||
|
||||
await scanForViolations(page);
|
||||
await expectReaderOrder(page, [
|
||||
'[data-slot="shared-record-banner"]',
|
||||
'[data-slot="query-error-card"]',
|
||||
'[data-slot="query-error-retry"]',
|
||||
]);
|
||||
// The sentence that says the read failed. Muted here would put "this did
|
||||
// not load" in the same register as a timestamp.
|
||||
await expectContentContrast(page, '[data-slot="query-error-card"] p');
|
||||
await expectKeyboardReach(page, '[data-slot="query-error-retry"]');
|
||||
|
||||
await page.unroute("**/api/profile/summary**");
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("[refusal] a surface sharing does not cover keeps a way back", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openProfileRecord(page);
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await scanForViolations(page);
|
||||
// The explanation before the button. A reader who meets "leave this
|
||||
// record" first is being offered an act before being told why.
|
||||
await expectReaderOrder(page, [
|
||||
'[data-slot="shared-record-unavailable"]',
|
||||
'[data-slot="shared-record-unavailable"] p',
|
||||
'[data-slot="shared-record-unavailable-leave"]',
|
||||
]);
|
||||
await expectContentContrast(
|
||||
page,
|
||||
'[data-slot="shared-record-unavailable"] p',
|
||||
);
|
||||
await expectKeyboardReach(
|
||||
page,
|
||||
'[data-slot="shared-record-unavailable-leave"]',
|
||||
);
|
||||
|
||||
await page.locator('[data-slot="shared-record-unavailable-leave"]').click();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,713 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
E2E_LEVEL_RECORDS,
|
||||
E2E_SCOPE_DELEGATE,
|
||||
E2E_SCOPE_RECORDS,
|
||||
GUARDIAN_STORAGE_STATE_PATH,
|
||||
SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
} from "./setup/test-helpers";
|
||||
import {
|
||||
clearGuardianProfiles,
|
||||
refreshGuardianStepUp,
|
||||
} from "./setup/managed-profile-fixture";
|
||||
|
||||
const DOMAIN_READS = {
|
||||
measurements: "/api/measurements",
|
||||
medications: "/api/medications",
|
||||
labs: "/api/labs",
|
||||
profile: "/api/profile/summary",
|
||||
illness: "/api/illness/episodes",
|
||||
mind: "/api/mood-entries",
|
||||
cycle: "/api/cycle/calendar",
|
||||
documents: "/api/documents/inbound",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.37.0 — the record-session assertion the app is currently sending.
|
||||
*
|
||||
* The two API probes in this file are deliberate: they check a status code the
|
||||
* UI does not surface (a MANAGE-only generated read) and a payload the UI
|
||||
* paginates. But a bare `fetch` inside `page.evaluate` carries no
|
||||
* record-session headers, and the fence refuses an unasserted request on any
|
||||
* session that has been inside a shared record — so the probes started failing
|
||||
* with 403 for a reason that had nothing to do with what they test.
|
||||
*
|
||||
* They are NOT an old-bundle simulation and must not become one: that case is
|
||||
* covered deliberately in `src/__tests__/record-session-fence-compat.test.ts`
|
||||
* and `tests/integration/record-session-fence.test.ts`, where the 403 and the
|
||||
* recovery are the subject. Here the subject is the grant level, so the probe
|
||||
* asserts exactly what the app beside it asserts — copied off a real app
|
||||
* request rather than computed, so it cannot drift from what the client
|
||||
* actually believes.
|
||||
*/
|
||||
function trackFenceAssertion(page: Page): () => {
|
||||
"x-healthlog-record-epoch": string;
|
||||
"x-healthlog-record-scope": string;
|
||||
} {
|
||||
// The HIGHEST epoch seen, not the most recent one observed.
|
||||
//
|
||||
// The counter is monotonic, and requests do not arrive in the order they
|
||||
// were issued: a request from the page before a switch can be seen after one
|
||||
// from the page after it, and last-writer-wins then leaves the probe holding
|
||||
// a context the session has already left — a 409, intermittently, on a test
|
||||
// about grant levels. Taking the maximum removes the ordering question
|
||||
// rather than trying to win it.
|
||||
let latest = { epoch: "bootstrap", scope: "bootstrap", seen: -1 };
|
||||
page.on("request", (request) => {
|
||||
const headers = request.headers();
|
||||
const epoch = headers["x-healthlog-record-epoch"];
|
||||
const scope = headers["x-healthlog-record-scope"];
|
||||
if (epoch === undefined || scope === undefined) return;
|
||||
if (!/^\d+$/.test(epoch)) return;
|
||||
const value = Number(epoch);
|
||||
if (value < latest.seen) return;
|
||||
latest = { epoch, scope, seen: value };
|
||||
});
|
||||
return () => ({
|
||||
"x-healthlog-record-epoch": latest.epoch,
|
||||
"x-healthlog-record-scope": latest.scope,
|
||||
});
|
||||
}
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
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();
|
||||
}
|
||||
|
||||
async function openRecord(page: Page, username: string) {
|
||||
await page.goto("/");
|
||||
await openSwitcher(page);
|
||||
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
if (accountId === null) {
|
||||
throw new Error(`Account switcher entry for ${username} has no account id`);
|
||||
}
|
||||
const accessLevel = await entry.getAttribute("data-access-level");
|
||||
const recordKind = await entry.getAttribute("data-record-kind");
|
||||
await entry.click();
|
||||
return { accountId, accessLevel, recordKind };
|
||||
}
|
||||
|
||||
async function leaveRecord(page: Page) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function withDivergentActiveAccountAccess(body: unknown): unknown {
|
||||
if (body === null || typeof body !== "object") {
|
||||
throw new Error("Expected an object from /api/auth/me");
|
||||
}
|
||||
|
||||
const envelope = body as { data?: unknown };
|
||||
const payload = "data" in envelope ? envelope.data : envelope;
|
||||
if (payload === null || typeof payload !== "object") {
|
||||
throw new Error("Expected an auth payload from /api/auth/me");
|
||||
}
|
||||
|
||||
const auth = payload as {
|
||||
accountAccess?: {
|
||||
active?: {
|
||||
access?: unknown;
|
||||
level?: unknown;
|
||||
canWrite?: unknown;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
if (!auth.accountAccess?.active) {
|
||||
throw new Error("Expected a switched account-access payload");
|
||||
}
|
||||
|
||||
const corrupted = {
|
||||
...auth,
|
||||
accountAccess: {
|
||||
...auth.accountAccess,
|
||||
active: {
|
||||
...auth.accountAccess.active,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
return "data" in envelope ? { ...envelope, data: corrupted } : corrupted;
|
||||
}
|
||||
|
||||
test.describe.serial("scoped sharing browser journeys", () => {
|
||||
test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH });
|
||||
|
||||
for (const record of E2E_SCOPE_RECORDS) {
|
||||
test(`opens only the ${record.domain} record doorway`, async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openSwitcher(page);
|
||||
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${record.username}"]`,
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
|
||||
// The labs doorway must not pull the OCR capability probe: OCR is an
|
||||
// owner surface and a scoped READ grant does not reach it.
|
||||
//
|
||||
// Proved with a listener rather than `waitForRequest(…, { timeout })`,
|
||||
// and the difference is not stylistic. A timed wait is an instrument
|
||||
// that FAILS by its own mechanics: the rejection it produces is the
|
||||
// pass condition, so it has to be created early and awaited late, and in
|
||||
// between it is an unhandled rejection that lands on whichever test
|
||||
// happens to be finishing. Its window is wrong too — the 500 ms starts
|
||||
// before the switch is even clicked, so on a slow runner it can expire
|
||||
// before the labs page has loaded, which is the one moment it exists to
|
||||
// watch.
|
||||
//
|
||||
// A listener has no timer to lose. It covers everything from before the
|
||||
// click to after the record read has landed, which is strictly more than
|
||||
// 500 ms, and it is asserted below with a paired positive control: the
|
||||
// domain's own read must appear in the same set, so a matcher that
|
||||
// stopped seeing requests fails instead of reporting an absence.
|
||||
const seenPaths = new Set<string>();
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "GET") return;
|
||||
seenPaths.add(new URL(request.url()).pathname);
|
||||
});
|
||||
|
||||
const read = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === DOMAIN_READS[record.domain],
|
||||
);
|
||||
await entry.click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${record.href}$`));
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toContainText(record.username);
|
||||
await expect(
|
||||
page.locator(`a[href="${record.href}"]`).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toHaveCount(0);
|
||||
await read;
|
||||
|
||||
// The positive control first: if this path is missing the listener saw
|
||||
// nothing useful and the absence below would be a statement about the
|
||||
// matcher rather than about the product.
|
||||
expect(
|
||||
[...seenPaths],
|
||||
`the ${record.domain} read never reached the request listener`,
|
||||
).toContain(DOMAIN_READS[record.domain]);
|
||||
if (record.domain === "labs") {
|
||||
expect(
|
||||
[...seenPaths],
|
||||
"a scoped labs grant pulled the owner-only OCR capability probe",
|
||||
).not.toContain("/api/labs/ocr/capability");
|
||||
}
|
||||
|
||||
await leaveRecord(page);
|
||||
});
|
||||
}
|
||||
|
||||
test("refuses a malformed switched payload before target or owner reads mount", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openSwitcher(page);
|
||||
|
||||
const entry = page.locator(
|
||||
'[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]',
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
|
||||
// The switch normally writes this mirror before its full reload. Remove it
|
||||
// in the new document so the global preloaders cannot guess an own-record
|
||||
// scope while `/api/auth/me` is still deciding whether the target is safe.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.removeItem("healthlog-record-scope");
|
||||
});
|
||||
|
||||
let corruptAuthPayload = true;
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
const response = await route.fetch();
|
||||
if (!corruptAuthPayload) {
|
||||
await route.fulfill({ response });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: withDivergentActiveAccountAccess(await response.json()),
|
||||
});
|
||||
});
|
||||
|
||||
const ownerOnlyReads: string[] = [];
|
||||
const trackOwnerOnlyRead = (request: {
|
||||
method(): string;
|
||||
url(): string;
|
||||
}) => {
|
||||
if (request.method() !== "GET") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/labs/ocr/capability",
|
||||
"/api/insights/coach/nudge-status",
|
||||
"/api/coach/about-me/questions",
|
||||
"/api/auth/me/notification-prefs",
|
||||
].includes(path)
|
||||
) {
|
||||
ownerOnlyReads.push(path);
|
||||
}
|
||||
};
|
||||
page.on("request", trackOwnerOnlyRead);
|
||||
const targetPreloads: string[] = [];
|
||||
const trackTargetPreload = (request: {
|
||||
method(): string;
|
||||
url(): string;
|
||||
}) => {
|
||||
if (request.method() !== "GET") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/medications",
|
||||
"/api/medications/compliance",
|
||||
].includes(path)
|
||||
) {
|
||||
targetPreloads.push(path);
|
||||
}
|
||||
};
|
||||
page.on("request", trackTargetPreload);
|
||||
try {
|
||||
await entry.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/labs$/);
|
||||
await expect(
|
||||
page.locator('[data-slot="invalid-record-access-refusal"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable-leave"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-tour-id="labs-hero"]')).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Both global preloader routes must remain inert while the malformed
|
||||
// response is refused, even though the scope mirror was intentionally
|
||||
// cleared before each reload.
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator('[data-slot="invalid-record-access-refusal"]'),
|
||||
).toBeVisible();
|
||||
await page.goto("/medications");
|
||||
await expect(
|
||||
page.locator('[data-slot="invalid-record-access-refusal"]'),
|
||||
).toBeVisible();
|
||||
expect(ownerOnlyReads).toEqual([]);
|
||||
expect(targetPreloads).toEqual([]);
|
||||
|
||||
// Leave the refused record, then demand fresh own-record reads. This
|
||||
// catches a response that was parked under the owner hash while the
|
||||
// refusal was on screen and would otherwise paint without a new request.
|
||||
corruptAuthPayload = false;
|
||||
const freshDashboard = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/dashboard/snapshot",
|
||||
);
|
||||
await page
|
||||
.locator('[data-slot="shared-record-unavailable-leave"]')
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await freshDashboard;
|
||||
|
||||
const freshMedications = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/medications",
|
||||
);
|
||||
await page.goto("/medications");
|
||||
await freshMedications;
|
||||
} finally {
|
||||
page.off("request", trackOwnerOnlyRead);
|
||||
page.off("request", trackTargetPreload);
|
||||
await page.unroute("**/api/auth/me");
|
||||
}
|
||||
});
|
||||
|
||||
test("[J1-adult-levels-lifecycle] keeps an adult READ grant read-only and outside Settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = E2E_LEVEL_RECORDS.find(
|
||||
(candidate) => candidate.access === "READ",
|
||||
);
|
||||
if (!record) throw new Error("READ fixture is missing");
|
||||
|
||||
const { accessLevel, recordKind } = await openRecord(page, record.username);
|
||||
expect(accessLevel).toBe("view");
|
||||
expect(recordKind).toBe("shared");
|
||||
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-access-level", "view");
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "shared");
|
||||
await expect(banner).toContainText(record.username);
|
||||
|
||||
await page.goto("/measurements");
|
||||
await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0);
|
||||
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("[J1-adult-levels-lifecycle] lets an adult WRITE grant add only to the selected record", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = E2E_LEVEL_RECORDS.find(
|
||||
(candidate) => candidate.access === "WRITE",
|
||||
);
|
||||
if (!record) throw new Error("WRITE fixture is missing");
|
||||
|
||||
const { accountId, accessLevel, recordKind } = await openRecord(
|
||||
page,
|
||||
record.username,
|
||||
);
|
||||
expect(accessLevel).toBe("view-and-add");
|
||||
expect(recordKind).toBe("shared");
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-account-id", accountId);
|
||||
await expect(banner).toHaveAttribute("data-access-level", "view-and-add");
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "shared");
|
||||
|
||||
await page.goto("/measurements");
|
||||
await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible();
|
||||
const post = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === "/api/measurements",
|
||||
);
|
||||
await page.locator('[data-slot="measurement-add"]').click();
|
||||
await page.locator("#sys").fill("124");
|
||||
await page.locator("#dia").fill("78");
|
||||
await page.getByRole("button", { name: /^save$/i }).click();
|
||||
expect((await post).status()).toBeLessThan(300);
|
||||
|
||||
// Read the value back THROUGH THE APP rather than with a hand-rolled
|
||||
// probe.
|
||||
//
|
||||
// The probe copied the fence headers off a live request so it would not be
|
||||
// refused — and then raced them: requests do not arrive in the order they
|
||||
// were issued, so it could pick up a context the session had already left
|
||||
// and answer 409 on a test about grant levels. Copying the app's state to
|
||||
// stand beside the app is the wrong shape; asking the app is the right one,
|
||||
// and it is also closer to what this test claims — that the reading landed
|
||||
// in the SELECTED record and is visible there.
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible();
|
||||
await expect(page.getByText("124", { exact: false }).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(banner).toHaveAttribute("data-account-id", accountId);
|
||||
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("[J2-manage-edits-without-settings] opens MANAGE-only generated reads without opening adult Settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fenceHeaders = trackFenceAssertion(page);
|
||||
const record = E2E_LEVEL_RECORDS.find(
|
||||
(candidate) =>
|
||||
candidate.access === "MANAGE" && candidate.recordKind === "shared",
|
||||
);
|
||||
if (!record) throw new Error("adult MANAGE fixture is missing");
|
||||
|
||||
const { accessLevel, recordKind } = await openRecord(page, record.username);
|
||||
expect(accessLevel).toBe("manage");
|
||||
expect(recordKind).toBe("shared");
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "shared");
|
||||
|
||||
await page.goto("/");
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
const generatedRead = await page.evaluate(async (headers) => {
|
||||
const response = await fetch("/api/dashboard/summary", { headers });
|
||||
return response.status;
|
||||
}, fenceHeaders());
|
||||
expect(generatedRead).toBeLessThan(300);
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("[J2-manage-edits-without-settings] keeps managed Settings on the active record and integrations status-only", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = E2E_LEVEL_RECORDS.find(
|
||||
(candidate) => candidate.recordKind === "managed",
|
||||
);
|
||||
if (!record) throw new Error("managed record fixture is missing");
|
||||
|
||||
const { accountId, accessLevel, recordKind } = await openRecord(
|
||||
page,
|
||||
record.username,
|
||||
);
|
||||
expect(accessLevel).toBe("manage");
|
||||
expect(recordKind).toBe("managed");
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-account-id", accountId);
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "managed");
|
||||
|
||||
await page.goto("/settings/account");
|
||||
const profile = page.locator('[data-record-settings-family="profile"]');
|
||||
await expect(profile).toBeVisible();
|
||||
await expect(profile).toHaveAttribute("data-record-id", accountId);
|
||||
|
||||
const integrationRead = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname ===
|
||||
"/api/record-settings/integrations",
|
||||
);
|
||||
await page.goto("/settings/integrations");
|
||||
const integrationResponse = await integrationRead;
|
||||
expect(integrationResponse.status()).toBeLessThan(300);
|
||||
const integrationPayload = (await integrationResponse.json()) as {
|
||||
data?: { recordId?: string };
|
||||
};
|
||||
expect(integrationPayload.data?.recordId).toBe(accountId);
|
||||
const integrations = page.locator(
|
||||
'[data-record-settings-family="integrations"]',
|
||||
);
|
||||
await expect(integrations).toBeVisible();
|
||||
await expect(integrations).toHaveAttribute("data-record-id", accountId);
|
||||
await expect(
|
||||
integrations.locator("button, input, select, textarea"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goBack();
|
||||
await expect(profile).toHaveAttribute("data-record-id", accountId);
|
||||
await page.goForward();
|
||||
await expect(integrations).toHaveAttribute("data-record-id", accountId);
|
||||
await expect(banner).toHaveAttribute("data-account-id", accountId);
|
||||
|
||||
await leaveRecord(page);
|
||||
await page.goto("/measurements");
|
||||
await expect(banner).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("[J1-adult-levels-lifecycle] refuses direct ungranted routes before coach reads start", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openRecord(page, "e2e-scope-measurements");
|
||||
const protectedCoachReads: string[] = [];
|
||||
const trackCoachRead = (request: { url(): string }) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/insights/coach/nudge-status",
|
||||
"/api/coach/about-me/questions",
|
||||
"/api/auth/me/notification-prefs",
|
||||
].includes(path)
|
||||
) {
|
||||
protectedCoachReads.push(path);
|
||||
}
|
||||
};
|
||||
page.on("request", trackCoachRead);
|
||||
try {
|
||||
await page.goto("/coach");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
expect(protectedCoachReads).toEqual([]);
|
||||
|
||||
await page.goto("/settings/access");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
expect(protectedCoachReads).toEqual([]);
|
||||
} finally {
|
||||
page.off("request", trackCoachRead);
|
||||
}
|
||||
await leaveRecord(page);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* [J3-managed-profile-lifecycle] — the half of the release goal that had no
|
||||
* browser at all until this plan.
|
||||
*
|
||||
* The routes have shipped with integration coverage since the profile family
|
||||
* landed, and nothing under `src/components` or `src/app` called any of them.
|
||||
* These two tests are the person's side of it: an adult with no managed profile
|
||||
* creates one, finds it in the switcher, enters it, leaves it, invites a second
|
||||
* Guardian and is told — before acting rather than by a refusal afterwards —
|
||||
* that the invitation does not count until it is accepted.
|
||||
*
|
||||
* Its own session jar and its own account, because this is the only account in
|
||||
* the suite with a second factor enrolled: every route here resolves
|
||||
* `requireFreshMfa`, which refuses an unenrolled account outright.
|
||||
*/
|
||||
test.describe.serial("managed profile browser journey", () => {
|
||||
test.use({ storageState: GUARDIAN_STORAGE_STATE_PATH });
|
||||
|
||||
const PROFILE_NAME = "E2E managed record";
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await clearGuardianProfiles();
|
||||
await refreshGuardianStepUp();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await clearGuardianProfiles();
|
||||
});
|
||||
|
||||
async function createProfile(page: Page): Promise<void> {
|
||||
await page.goto("/settings/access");
|
||||
const card = page.locator('[data-slot="managed-profile-card"]');
|
||||
await expect(card).toBeVisible();
|
||||
await card.locator('[data-slot="managed-profile-name"]').fill(PROFILE_NAME);
|
||||
await card.locator('[data-slot="managed-profile-create-submit"]').click();
|
||||
await expect(
|
||||
card.locator('[data-slot="managed-profile-created"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test("[J3-managed-profile-lifecycle] creates a record with no login and reaches it from the switcher", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createProfile(page);
|
||||
|
||||
// The row appears without a reload, which is the account-payload
|
||||
// invalidation doing its job rather than a lucky refetch.
|
||||
const row = page.locator('[data-slot="managed-profile-row"]');
|
||||
await expect(row).toHaveCount(1);
|
||||
const profileId = await row.getAttribute("data-managed-profile-id");
|
||||
expect(profileId).not.toBeNull();
|
||||
|
||||
// The switcher lists it as a managed record. Asserted on the stable
|
||||
// attribute rather than on the label, so the copy stays free to change.
|
||||
await openSwitcher(page);
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-id="${profileId}"]`,
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
await expect(entry).toHaveAttribute("data-record-kind", "managed");
|
||||
await entry.click();
|
||||
|
||||
// Inside it, the banner says which kind of record this is.
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "managed");
|
||||
// The WORDING, not only the attribute, and this is the one place in the
|
||||
// suite where pinning copy is the right call rather than the lazy one: the
|
||||
// claim is that the banner NAMES it as a managed record, and the attribute
|
||||
// is not what a person reads. `recordKindLabel`'s ternary chooses between
|
||||
// two sentences that mean different things, and inverting it left every
|
||||
// attribute assertion green while telling a Guardian they were inside an
|
||||
// ordinary shared record. The pure function is pinned in
|
||||
// `src/components/layout/__tests__/shared-record-banner-label.test.tsx`;
|
||||
// this line proves the rendered banner really calls it.
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner-context"]'),
|
||||
).toContainText("Managed profile");
|
||||
|
||||
// And back out, with the record still on the list.
|
||||
//
|
||||
// Leaving ends in a full document reload, and a `goto` issued while that
|
||||
// reload is still in flight is aborted by Chromium — `net::ERR_ABORTED`,
|
||||
// reproducibly at CI's worker count and not at one. The banner being gone
|
||||
// is satisfied by the pre-reload DOM, so it cannot be the thing waited on.
|
||||
// Retrying the navigation is the honest fix: the claim is that the record
|
||||
// survives the round trip, not that the first navigation attempt wins a
|
||||
// race with the app's own.
|
||||
await leaveRecord(page);
|
||||
await expect(async () => {
|
||||
await page.goto("/settings/access");
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`,
|
||||
),
|
||||
).toHaveCount(1);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("[J3-managed-profile-lifecycle] states the last-guardian floor before an invitation is accepted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createProfile(page);
|
||||
|
||||
const guardians = page.locator('[data-slot="managed-profile-guardians"]');
|
||||
await expect(guardians).toBeVisible();
|
||||
|
||||
// One guardian, and it is the creator: no access here can be ended, and
|
||||
// the card says so rather than offering a control that would 404.
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-guardian"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-guardian-remove"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-sole-guardian"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Invite a second guardian.
|
||||
await guardians
|
||||
.locator('[data-slot="managed-profile-guardian-identifier"]')
|
||||
.fill(E2E_SCOPE_DELEGATE.username);
|
||||
await guardians
|
||||
.locator('[data-slot="managed-profile-guardian-submit"]')
|
||||
.click();
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-guardian-invited"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The roster shows them, and shows them as PENDING — which is the release
|
||||
// goal's sentence in the only form a browser can falsify: the invitation
|
||||
// reached the server, came back as a grant that confers nothing yet, and
|
||||
// the panel says which of the two states it is in. Delete the invitation
|
||||
// feature and this line goes red.
|
||||
await expect(
|
||||
guardians.locator(
|
||||
'[data-slot="managed-profile-guardian"][data-guardian-state="PENDING"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
|
||||
// And the floor is unchanged BECAUSE of that state: the sole-guardian
|
||||
// sentence is still on screen with a second person already invited.
|
||||
//
|
||||
// The `managed-profile-pending-note` line that used to sit here was
|
||||
// removed rather than kept. That node renders unconditionally inside the
|
||||
// invite form, so the assertion passed with the invitation feature deleted
|
||||
// — it proved the copy exists, which is a claim about a string and not
|
||||
// about anything the journey did. The note's own leg lives in
|
||||
// `managed-profile-affordances.test.tsx`, where deleting the node makes it
|
||||
// fail; here it would only have been a second look at the same string.
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-sole-guardian"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
+336
-4
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Vorsorge",
|
||||
"illness": "Krankheit",
|
||||
"documents": "Dokumente",
|
||||
@@ -4433,6 +4434,121 @@
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"displayName": "Anzeigename",
|
||||
"height": "Größe (cm)",
|
||||
"dateOfBirth": "Geburtsdatum",
|
||||
"gender": "Geschlecht",
|
||||
"notSpecified": "Nicht angegeben",
|
||||
"male": "Männlich",
|
||||
"female": "Weiblich",
|
||||
"other": "Andere Angabe",
|
||||
"locale": "Sprache",
|
||||
"systemDefault": "Systemstandard",
|
||||
"timezone": "Zeitzone",
|
||||
"units": "Einheiten",
|
||||
"metric": "Metrisch",
|
||||
"imperial": "Imperial",
|
||||
"timeFormat": "Zeitformat",
|
||||
"automatic": "Automatisch",
|
||||
"hour12": "12 Stunden",
|
||||
"hour24": "24 Stunden",
|
||||
"dateFormat": "Datumsformat",
|
||||
"dateDmy": "Tag / Monat / Jahr",
|
||||
"dateMdy": "Monat / Tag / Jahr",
|
||||
"dateYmd": "Jahr / Monat / Tag"
|
||||
},
|
||||
"modules": {
|
||||
"title": "Module"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"moodReminder": "Stimmungserinnerung aktivieren",
|
||||
"reminderHour": "Uhrzeit der Stimmungserinnerung",
|
||||
"lowStockRunwayDays": "Tage bis zum niedrigen Bestand",
|
||||
"reorderLeadDays": "Vorlaufzeit für Nachbestellung"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Zielwerte",
|
||||
"metric": "Messwert",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"disable": "Coach deaktivieren",
|
||||
"tone": "Ton",
|
||||
"warm": "Herzlich",
|
||||
"neutral": "Neutral",
|
||||
"concise": "Knapp",
|
||||
"verbosity": "Ausführlichkeit",
|
||||
"brief": "Kurz",
|
||||
"default": "Standard",
|
||||
"detailed": "Ausführlich",
|
||||
"defaultWindow": "Standardzeitraum",
|
||||
"last7days": "Letzte 7 Tage",
|
||||
"last30days": "Letzte 30 Tage",
|
||||
"last90days": "Letzte 90 Tage",
|
||||
"allTime": "Gesamter Zeitraum",
|
||||
"showEvidence": "Evidenz standardmäßig anzeigen",
|
||||
"excludedMetrics": "Ausgeschlossene Messwerte",
|
||||
"dataClusters": "Datenbereiche",
|
||||
"customClusters": "Eigene Auswahl von Datenbereichen verwenden",
|
||||
"metrics": {
|
||||
"bp": "Blutdruck",
|
||||
"weight": "Gewicht",
|
||||
"pulse": "Puls",
|
||||
"mood": "Stimmung",
|
||||
"compliance": "Medikamenteneinnahme",
|
||||
"hrv": "Herzfrequenzvariabilität",
|
||||
"sleep": "Schlaf",
|
||||
"restingHr": "Ruhepuls",
|
||||
"steps": "Schritte",
|
||||
"medications": "Medikamente",
|
||||
"anthropometrics": "Körpermaße"
|
||||
},
|
||||
"clusters": {
|
||||
"cardio": "Herz-Kreislauf",
|
||||
"body": "Körperzusammensetzung",
|
||||
"activity": "Aktivität",
|
||||
"workouts": "Trainings",
|
||||
"sleep": "Schlaf",
|
||||
"mood": "Stimmung",
|
||||
"glucose": "Glukose",
|
||||
"medication": "Medikation",
|
||||
"mobility": "Mobilität",
|
||||
"environment": "Umgebung"
|
||||
}
|
||||
},
|
||||
"insights": {
|
||||
"title": "Erkenntnisse",
|
||||
"sections": "Übersichtsbereiche",
|
||||
"tiles": "Navigationskacheln",
|
||||
"order": "Reihenfolge",
|
||||
"unknownItem": "Nicht verfügbare Erkenntnis",
|
||||
"sectionLabels": {
|
||||
"wellnessScores": "Wellnesswerte",
|
||||
"dailyBriefing": "Tagesüberblick",
|
||||
"vitals": "Vitalwerte",
|
||||
"trends": "Trends",
|
||||
"periodReview": "Periodenrückblick",
|
||||
"cycleSummary": "Zyklusübersicht",
|
||||
"signals": "Signale",
|
||||
"rhythmEvents": "Rhythmusereignisse",
|
||||
"healthStatus": "Gesundheitsstatus",
|
||||
"breathing": "Atmung",
|
||||
"labsChanges": "Laborveränderungen",
|
||||
"ecg": "EKG"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
|
||||
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
|
||||
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz. Verbindungssteuerungen sind nicht verfügbar."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
@@ -9406,7 +9522,7 @@
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Gehört nicht zum geteilten Zugriff",
|
||||
"description": "Geteilt wird die Gesundheitsakte selbst. Einstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.",
|
||||
"description": "Geteilt wird die Gesundheitsakte selbst, einschließlich der Vorgeschichte unter Anamnese. Kontoeinstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.",
|
||||
"leave": "Zurück zu meiner Akte"
|
||||
},
|
||||
"state": {
|
||||
@@ -9418,6 +9534,10 @@
|
||||
"row": {
|
||||
"readOnly": "Darf die Akte lesen",
|
||||
"canWrite": "Darf lesen und Einträge anlegen",
|
||||
"canManage": "Darf lesen, Einträge anlegen sowie Einträge ändern und löschen",
|
||||
"entireRecord": "Umfasst die gesamte Akte",
|
||||
"sections": "Umfasst: {sections}",
|
||||
"noSections": "Umfasst nichts: dieser Zugriff öffnet keinen Teil der Akte",
|
||||
"invited": "Eingeladen",
|
||||
"accepted": "Angenommen",
|
||||
"expires": "Läuft ab",
|
||||
@@ -9439,6 +9559,19 @@
|
||||
"accessReadCapability": "Sie kann alles lesen, was von dieser Akte geteilt wird. Hinzufügen oder ändern kann sie nichts.",
|
||||
"accessWriteLabel": "Darf lesen und hinzufügen",
|
||||
"accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.",
|
||||
"accessManageLabel": "Darf verwalten",
|
||||
"accessManageCapability": "Sie kann Einträge in deiner Akte anlegen, ändern und löschen, auch solche, die du selbst erfasst hast, und sie kann die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.",
|
||||
"manageStepUpNote": "Dafür wird dein zweiter Faktor abgefragt, deshalb geht das nur im Browser.",
|
||||
"scopeLegend": "Was die Person öffnen darf",
|
||||
"scopeAllLabel": "Gesamte Akte",
|
||||
"scopeAllCapability": "Jeder Teil davon, so wie das Teilen bisher funktioniert hat.",
|
||||
"scopeSomeLabel": "Nur die Bereiche, die du auswählst",
|
||||
"scopeSomeCapability": "Alles, was du nicht auswählst, bleibt für sie verschlossen.",
|
||||
"scopeManageNote": "Verwaltender Zugriff umfasst immer die gesamte Akte.",
|
||||
"scopeLeakNote": "Ein Bereich kann in seinen Notizen und Bezeichnungen auch den Rest deiner Gesundheit erwähnen. Wer einen Bereich bekommt, bekommt alles, was darin steht.",
|
||||
"scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.",
|
||||
"scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.",
|
||||
"scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.",
|
||||
"expiresLabel": "Zugriff endet am (optional)",
|
||||
"expiresHint": "Leer lassen für Zugriff, der gilt, bis ihn jemand beendet. Ein Datum wird bei jeder Anfrage geprüft, der Zugriff endet also am Ende dieses Tages von selbst.",
|
||||
"submit": "Einladung senden",
|
||||
@@ -9449,7 +9582,11 @@
|
||||
"errorSelf": "Das ist dein eigenes Konto.",
|
||||
"errorInvalid": "Bitte einen Benutzernamen oder eine E-Mail-Adresse eingeben.",
|
||||
"errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.",
|
||||
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen."
|
||||
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.",
|
||||
"errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.",
|
||||
"errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben, weil dabei dein zweiter Faktor abgefragt wird.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier — prüfe deine Verbindung und versuche es erneut."
|
||||
},
|
||||
"given": {
|
||||
"title": "Wer meine Akte öffnen kann",
|
||||
@@ -9474,6 +9611,7 @@
|
||||
"accept": "Annehmen",
|
||||
"accepting": "Wird angenommen…",
|
||||
"writeConsent": "Wenn du annimmst, wird das, was du hinzufügst, Teil der Akte von {name}. Ändern oder entfernen kannst du deine Einträge danach nicht mehr; das kann nur {name}.",
|
||||
"manageConsent": "Wenn du annimmst, kannst du die Akte von {name} ergänzen und außerdem ändern oder löschen, was schon darin steht, auch Einträge von {name} selbst. Alles, was du tust, steht mit deinem Namen in der Aktivitätsübersicht, und {name} kann deinen Zugriff jederzeit beenden.",
|
||||
"open": "Öffnen",
|
||||
"renounce": "Zurückgeben",
|
||||
"renounceTitle": "Akte von {name} zurückgeben?",
|
||||
@@ -9489,7 +9627,8 @@
|
||||
"loadError": "Die Aktivität konnte nicht geladen werden.",
|
||||
"opened": "{name} hat deine Akte {count}× geöffnet",
|
||||
"acted": "{name} hat etwas in deiner Akte geändert",
|
||||
"deletedAccount": "Ein gelöschtes Konto"
|
||||
"deletedAccount": "Ein gelöschtes Konto",
|
||||
"truncated": "Diese Liste zeigt die neueste Aktivität, nicht die gesamte."
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}."
|
||||
@@ -9499,8 +9638,65 @@
|
||||
"description": "Akten, die andere mit dir geteilt haben.",
|
||||
"levelRead": "Darf lesen",
|
||||
"levelWrite": "Darf lesen und hinzufügen",
|
||||
"kindShared": "Geteilte Akte",
|
||||
"kindManaged": "Verwaltetes Profil",
|
||||
"open": "Akte von {name} öffnen"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profile, die du betreust",
|
||||
"description": "Gesundheitsakten ohne eigenen Login.",
|
||||
"explainer": "Ein verwaltetes Profil ist eine Gesundheitsakte für jemanden, der sich nicht selbst anmeldet — ein Kind oder eine Person, um die du dich kümmerst. Es hat keinen Login und keine E-Mail-Adresse, und du wirst die erste betreuende Person. Später kannst du weitere hinzufügen, und ihr alle könnt in die Akte wechseln, um sie zu pflegen.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Der Name, an dem du diese Akte erkennst",
|
||||
"nameHint": "Bis zu 80 Zeichen. Wird überall dort angezeigt, wo diese Akte auftaucht.",
|
||||
"nameRequired": "Gib der Akte einen Namen.",
|
||||
"nameTooLong": "Dieser Name ist länger als 80 Zeichen.",
|
||||
"dobLabel": "Geburtsdatum (optional)",
|
||||
"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.",
|
||||
"timezoneHint": "Die Zeitzone, in der die Tage dieser Akte gezählt werden.",
|
||||
"timezoneInvalid": "Diese Zeitzone kennt der Browser nicht.",
|
||||
"create": "Profil anlegen",
|
||||
"creating": "Wird angelegt…",
|
||||
"created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"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.",
|
||||
"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.",
|
||||
"deleteGuardiansOne": "{count} Person betreut diese Akte derzeit und verliert sie.",
|
||||
"deleteGuardiansFew": "{count} Personen betreuen diese Akte derzeit und verlieren sie.",
|
||||
"deleteGuardiansOther": "{count} Personen betreuen diese Akte derzeit und verlieren sie.",
|
||||
"deleteFloorNote": "Das ist auch der Ausweg, wenn du die letzte betreuende Person bist: Du darfst die Akte beenden, aber nicht ohne Betreuung zurücklassen.",
|
||||
"deleteConfirm": "Profil löschen",
|
||||
"guardiansTitle": "Wer sich um {name} kümmert",
|
||||
"guardianYou": "(du)",
|
||||
"guardianSince": "Betreut diese Akte seit {date}",
|
||||
"guardianPending": "Eingeladen am {date}, noch nicht angenommen",
|
||||
"guardianRemove": "Zugriff beenden",
|
||||
"guardianRemoveTitle": "Zugriff von {name} beenden?",
|
||||
"guardianRemoveBody": "{name} kann {profile} dann nicht mehr öffnen. Was diese Person eingetragen hat, bleibt in der Akte.",
|
||||
"guardianWithdrawBody": "Die Einladung an {name} wird zurückgezogen. Die Person hat {profile} nie geöffnet, in der Akte ändert sich nichts.",
|
||||
"guardianRemoveConfirm": "Zugriff beenden",
|
||||
"soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert — hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.",
|
||||
"guardianInviteLabel": "Weitere betreuende Person einladen",
|
||||
"guardianInvite": "Einladung senden",
|
||||
"guardianInviting": "Wird gesendet…",
|
||||
"guardianInvited": "Einladung an {name} gesendet.",
|
||||
"guardianExpiresHint": "Optional. Der Zugriff endet am Ende dieses Tages in deiner Zeitzone.",
|
||||
"guardianPendingNote": "Eine eingeladene Person zählt erst, wenn sie annimmt. Bis dahin bist du weiterhin die letzte, und die Rückgabe deines eigenen Zugriffs wird weiterhin abgelehnt.",
|
||||
"guardianErrorDuplicate": "Dieses Konto hat bereits Zugriff auf diese Akte.",
|
||||
"guardianErrorManagedInvitee": "Ein verwaltetes Profil kann keine andere Akte betreuen. Lade ein Konto ein, das sich anmeldet.",
|
||||
"guardianErrorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen oder dieser E-Mail-Adresse.",
|
||||
"guardianErrorRateLimit": "Zu viele Einladungen in der letzten Stunde. Warte etwas und versuche es erneut.",
|
||||
"guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.",
|
||||
"errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.",
|
||||
"rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.",
|
||||
"errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "In der Akte von {name} gespeichert."
|
||||
},
|
||||
@@ -9515,7 +9711,143 @@
|
||||
"medicationSideEffectCreate": "{name} hat eine Nebenwirkung notiert",
|
||||
"medicationCreate": "{name} hat ein Medikament angelegt",
|
||||
"medicationIntake": "{name} hat eine Dosis eingetragen",
|
||||
"medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen"
|
||||
"medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen",
|
||||
"measurementUpdate": "{name} hat einen Messwert geändert",
|
||||
"measurementDelete": "{name} hat einen Messwert gelöscht",
|
||||
"measurementDeleteBulk": "{name} hat mehrere Messwerte auf einmal gelöscht",
|
||||
"measurementRestore": "{name} hat einen Messwert wiederhergestellt",
|
||||
"measurementReminderCreate": "{name} hat eine Messerinnerung angelegt",
|
||||
"measurementReminderUpdate": "{name} hat eine Messerinnerung geändert",
|
||||
"measurementReminderDelete": "{name} hat eine Messerinnerung gelöscht",
|
||||
"measurementReminderComplete": "{name} hat eine Messerinnerung erledigt",
|
||||
"measurementReminderSatisfy": "{name} hat eine Messerinnerung abgehakt",
|
||||
"labResultUpdate": "{name} hat einen Laborwert geändert",
|
||||
"labResultDelete": "{name} hat einen Laborwert gelöscht",
|
||||
"labResultRestore": "{name} hat einen Laborwert wiederhergestellt",
|
||||
"biomarkerUpdate": "{name} hat einen Biomarker geändert",
|
||||
"biomarkerDelete": "{name} hat einen Biomarker gelöscht",
|
||||
"allergyUpdate": "{name} hat eine Allergie geändert",
|
||||
"allergyDelete": "{name} hat eine Allergie gelöscht",
|
||||
"familyHistoryUpdate": "{name} hat einen Eintrag zur Familienanamnese geändert",
|
||||
"familyHistoryDelete": "{name} hat einen Eintrag zur Familienanamnese gelöscht",
|
||||
"illnessEpisodeUpdate": "{name} hat einen Krankheitseintrag geändert",
|
||||
"illnessEpisodeDelete": "{name} hat einen Krankheitseintrag gelöscht",
|
||||
"illnessEpisodeResolve": "{name} hat eine Erkrankung als beendet markiert",
|
||||
"illnessEpisodeRestore": "{name} hat einen Krankheitseintrag wiederhergestellt",
|
||||
"illnessDayLogUpsert": "{name} hat einen Krankheitstag erfasst",
|
||||
"medicationUpdate": "{name} hat ein Medikament geändert",
|
||||
"medicationDelete": "{name} hat ein Medikament gelöscht",
|
||||
"medicationGlp1Update": "{name} hat eine GLP-1-Therapie geändert",
|
||||
"medicationScheduleCreate": "{name} hat einen Einnahmeplan angelegt",
|
||||
"medicationScheduleUpdate": "{name} hat einen Einnahmeplan geändert",
|
||||
"medicationScheduleDelete": "{name} hat einen Einnahmeplan gelöscht",
|
||||
"medicationInventoryCreate": "{name} hat eine Packung angelegt",
|
||||
"medicationInventoryUpdate": "{name} hat eine Packung geändert",
|
||||
"medicationInventoryDelete": "{name} hat eine Packung gelöscht",
|
||||
"medicationSideEffectDelete": "{name} hat eine Nebenwirkung gelöscht",
|
||||
"medicationIntakeUpdate": "{name} hat eine Einnahme geändert",
|
||||
"medicationIntakeDelete": "{name} hat eine Einnahme gelöscht",
|
||||
"medicationIntakeBulkDelete": "{name} hat mehrere Einnahmen auf einmal gelöscht",
|
||||
"medicationIntakePurge": "{name} hat die gesamte Einnahmehistorie eines Medikaments gelöscht",
|
||||
"medicationIntakeImport": "{name} hat einen Einnahme-Import für ein Medikament gestartet",
|
||||
"medicationIntakeHistoryImport": "{name} hat einen Import der Einnahmehistorie gestartet",
|
||||
"moodEntryCreate": "{name} hat einen Stimmungseintrag angelegt",
|
||||
"moodEntryUpdate": "{name} hat einen Stimmungseintrag geändert",
|
||||
"moodEntryDelete": "{name} hat einen Stimmungseintrag gelöscht",
|
||||
"moodDeleteBulk": "{name} hat mehrere Stimmungseinträge auf einmal gelöscht",
|
||||
"moodRestore": "{name} hat einen Stimmungseintrag wiederhergestellt",
|
||||
"mentalHealthCreate": "{name} hat ein Fragebogenergebnis erfasst",
|
||||
"cycleDelete": "{name} hat einen Zyklus gelöscht",
|
||||
"cycleDayLogUpsert": "{name} hat einen Zyklustag erfasst",
|
||||
"cycleDayLogUpdate": "{name} hat einen Zyklustag geändert",
|
||||
"cycleDayLogDelete": "{name} hat einen Zyklustag gelöscht",
|
||||
"cyclePeriodBoundary": "{name} hat den Beginn oder das Ende einer Periode geändert",
|
||||
"cycleSymptomCreate": "{name} hat ein Zyklussymptom angelegt",
|
||||
"nutrientWaterWrite": "{name} hat die Trinkmenge erfasst",
|
||||
"healthRecordExport": "{name} hat die Akte exportiert",
|
||||
"rejectedMeasurementQuery": "{name} hat eine Messwert-Suche gesendet, die diese Akte nicht lesen konnte",
|
||||
"rejectedDoseQuery": "{name} hat eine Einnahme-Suche gesendet, die diese Akte nicht lesen konnte",
|
||||
"rejectedMoodQuery": "{name} hat eine Stimmungs-Suche gesendet, die diese Akte nicht lesen konnte",
|
||||
"rejectedMeasurementCreate": "{name} wollte einen Messwert eintragen und wurde abgewiesen",
|
||||
"rejectedMeasurementBatch": "{name} wollte mehrere Messwerte eintragen und wurde abgewiesen",
|
||||
"rejectedMeasurementUpdate": "{name} wollte einen Messwert ändern und wurde abgewiesen",
|
||||
"rejectedLabCreate": "{name} wollte einen Laborwert eintragen und wurde abgewiesen",
|
||||
"rejectedLabUpdate": "{name} wollte einen Laborwert ändern und wurde abgewiesen",
|
||||
"rejectedBiomarkerCreate": "{name} wollte einen Biomarker anlegen und wurde abgewiesen",
|
||||
"rejectedBiomarkerUpdate": "{name} wollte einen Biomarker ändern und wurde abgewiesen",
|
||||
"rejectedReminderCreate": "{name} wollte eine Messerinnerung anlegen und wurde abgewiesen",
|
||||
"rejectedReminderUpdate": "{name} wollte eine Messerinnerung ändern und wurde abgewiesen",
|
||||
"rejectedMoodCreate": "{name} wollte einen Stimmungseintrag anlegen und wurde abgewiesen",
|
||||
"rejectedMoodUpdate": "{name} wollte einen Stimmungseintrag ändern und wurde abgewiesen",
|
||||
"rejectedDoseCreate": "{name} wollte eine Einnahme eintragen und wurde abgewiesen",
|
||||
"rejectedDoseUpdate": "{name} wollte eine Einnahme ändern und wurde abgewiesen",
|
||||
"rejectedDoseImport": "{name} wollte Einnahmen importieren und wurde abgewiesen",
|
||||
"rejectedTrackedValue": "{name} wollte einen eigenen Messwert eintragen und wurde abgewiesen",
|
||||
"deniedDoseImport": "Ein von {name} gestarteter Einnahme-Import konnte nicht eingereiht werden",
|
||||
"anamnesisFactCreate": "{name} hat einen Fakt zur Vorgeschichte eingetragen",
|
||||
"customMetricCreate": "{name} hat eine eigene Messgröße angelegt",
|
||||
"customMetricUpdate": "{name} hat eine eigene Messgröße geändert",
|
||||
"customMetricDelete": "{name} hat eine eigene Messgröße gelöscht",
|
||||
"documentStore": "{name} hat ein Dokument hinzugefügt",
|
||||
"documentUpdate": "{name} hat ein Dokument geändert",
|
||||
"documentDiscard": "{name} hat ein Dokument verworfen",
|
||||
"medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig",
|
||||
"medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
"label": "Messwerte",
|
||||
"detail": "Gewicht, Blutdruck, Puls, Glukose, Schlaf, Training und die Werte, die du selbst verfolgst."
|
||||
},
|
||||
"medications": {
|
||||
"label": "Medikamente",
|
||||
"detail": "Die Medikamentenliste, ihre Zeitpläne, eingenommene Dosen, Nebenwirkungen und der Bestand."
|
||||
},
|
||||
"labs": {
|
||||
"label": "Laborwerte",
|
||||
"detail": "Laborergebnisse und die Liste der Kennwerte dahinter."
|
||||
},
|
||||
"profile": {
|
||||
"label": "Gesundheitliche Vorgeschichte",
|
||||
"detail": "Allergien, Familienanamnese und die Angaben in deinem Gesundheitsprofil."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Krankheiten",
|
||||
"detail": "Krankheitsverläufe und die Tageseinträge darunter."
|
||||
},
|
||||
"mind": {
|
||||
"label": "Stimmung und Psyche",
|
||||
"detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Zyklus",
|
||||
"detail": "Die Zyklusaufzeichnung."
|
||||
},
|
||||
"documents": {
|
||||
"label": "Dokumente",
|
||||
"detail": "Alles, was im Dokumententresor liegt."
|
||||
}
|
||||
},
|
||||
"actionError": {
|
||||
"retry": "Erneut versuchen",
|
||||
"offline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.",
|
||||
"rateLimited": "Zu viele Versuche hintereinander. Warte eine Minute und versuche es erneut.",
|
||||
"acceptExpired": "Diese Einladung ist abgelaufen. Bitte um eine neue.",
|
||||
"acceptWithdrawn": "Diese Einladung wurde zurückgezogen.",
|
||||
"acceptNotPending": "Diese Einladung wurde bereits angenommen.",
|
||||
"acceptNotFound": "Diese Einladung gibt es nicht mehr.",
|
||||
"acceptFailed": "Die Einladung konnte nicht angenommen werden.",
|
||||
"lastGuardian": "Diese Person kümmert sich als Einzige um dieses Profil, deshalb kann der Zugriff noch nicht enden. Füge zuerst eine weitere betreuende Person hinzu oder lösche das Profil.",
|
||||
"alreadyEnded": "Dieser Zugriff ist bereits beendet.",
|
||||
"notFound": "Diesen Zugriff gibt es nicht mehr.",
|
||||
"revokeFailed": "Der Zugriff konnte nicht beendet werden.",
|
||||
"renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden.",
|
||||
"profileNotFound": "Dieses Profil gibt es nicht mehr — es wurde vielleicht schon gelöscht, oder dein Zugriff darauf ist beendet.",
|
||||
"deleteProfileFailed": "Das Profil konnte nicht gelöscht werden.",
|
||||
"removeGuardianFailed": "Dieser Zugriff konnte nicht beendet werden."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte."
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
-4
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs",
|
||||
"profile": "Profile",
|
||||
"vorsorge": "Checkups",
|
||||
"illness": "Illness",
|
||||
"documents": "Documents",
|
||||
@@ -4433,6 +4434,121 @@
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"displayName": "Display name",
|
||||
"height": "Height (cm)",
|
||||
"dateOfBirth": "Date of birth",
|
||||
"gender": "Gender",
|
||||
"notSpecified": "Not specified",
|
||||
"male": "Male",
|
||||
"female": "Female",
|
||||
"other": "Other",
|
||||
"locale": "Locale",
|
||||
"systemDefault": "System default",
|
||||
"timezone": "Timezone",
|
||||
"units": "Units",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"timeFormat": "Time format",
|
||||
"automatic": "Automatic",
|
||||
"hour12": "12-hour",
|
||||
"hour24": "24-hour",
|
||||
"dateFormat": "Date format",
|
||||
"dateDmy": "Day / month / year",
|
||||
"dateMdy": "Month / day / year",
|
||||
"dateYmd": "Year / month / day"
|
||||
},
|
||||
"modules": {
|
||||
"title": "Modules"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"moodReminder": "Enable mood reminder",
|
||||
"reminderHour": "Mood reminder hour",
|
||||
"lowStockRunwayDays": "Low-stock runway days",
|
||||
"reorderLeadDays": "Reorder lead days"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Targets",
|
||||
"metric": "Metric",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"disable": "Disable Coach",
|
||||
"tone": "Tone",
|
||||
"warm": "Warm",
|
||||
"neutral": "Neutral",
|
||||
"concise": "Concise",
|
||||
"verbosity": "Verbosity",
|
||||
"brief": "Brief",
|
||||
"default": "Default",
|
||||
"detailed": "Detailed",
|
||||
"defaultWindow": "Default window",
|
||||
"last7days": "Last 7 days",
|
||||
"last30days": "Last 30 days",
|
||||
"last90days": "Last 90 days",
|
||||
"allTime": "All time",
|
||||
"showEvidence": "Show evidence by default",
|
||||
"excludedMetrics": "Excluded metrics",
|
||||
"dataClusters": "Data clusters",
|
||||
"customClusters": "Use a custom cluster selection",
|
||||
"metrics": {
|
||||
"bp": "Blood pressure",
|
||||
"weight": "Weight",
|
||||
"pulse": "Pulse",
|
||||
"mood": "Mood",
|
||||
"compliance": "Medication adherence",
|
||||
"hrv": "Heart rate variability",
|
||||
"sleep": "Sleep",
|
||||
"restingHr": "Resting heart rate",
|
||||
"steps": "Steps",
|
||||
"medications": "Medications",
|
||||
"anthropometrics": "Body measurements"
|
||||
},
|
||||
"clusters": {
|
||||
"cardio": "Cardiovascular health",
|
||||
"body": "Body composition",
|
||||
"activity": "Activity",
|
||||
"workouts": "Workouts",
|
||||
"sleep": "Sleep",
|
||||
"mood": "Mood",
|
||||
"glucose": "Glucose",
|
||||
"medication": "Medication",
|
||||
"mobility": "Mobility",
|
||||
"environment": "Environment"
|
||||
}
|
||||
},
|
||||
"insights": {
|
||||
"title": "Insights",
|
||||
"sections": "Overview sections",
|
||||
"tiles": "Navigation tiles",
|
||||
"order": "Order",
|
||||
"unknownItem": "Unavailable insight",
|
||||
"sectionLabels": {
|
||||
"wellnessScores": "Wellness scores",
|
||||
"dailyBriefing": "Daily briefing",
|
||||
"vitals": "Vitals",
|
||||
"trends": "Trends",
|
||||
"periodReview": "Period review",
|
||||
"cycleSummary": "Cycle summary",
|
||||
"signals": "Signals",
|
||||
"rhythmEvents": "Rhythm events",
|
||||
"healthStatus": "Health status",
|
||||
"breathing": "Breathing",
|
||||
"labsChanges": "Lab changes",
|
||||
"ecg": "ECG"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Unavailable in this shared record",
|
||||
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
|
||||
"integrationStatusDescription": "Connection status for this managed record. Connection controls are unavailable."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
@@ -9406,7 +9522,7 @@
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Not part of shared access",
|
||||
"description": "Sharing covers the health record itself. Settings, connected services, notifications and the AI features stay with the person who owns the account.",
|
||||
"description": "Sharing covers the health record itself, including the health background under Anamnesis. Account settings, connected services, notifications and the AI features stay with the person who owns the account.",
|
||||
"leave": "Back to my record"
|
||||
},
|
||||
"state": {
|
||||
@@ -9418,6 +9534,10 @@
|
||||
"row": {
|
||||
"readOnly": "Can read the record",
|
||||
"canWrite": "Can read and add entries",
|
||||
"canManage": "Can read the record, add to it, and change or remove what is in it",
|
||||
"entireRecord": "Covers the entire record",
|
||||
"sections": "Covers: {sections}",
|
||||
"noSections": "Covers nothing — this access opens no part of the record",
|
||||
"invited": "Invited",
|
||||
"accepted": "Accepted",
|
||||
"expires": "Lapses",
|
||||
@@ -9439,6 +9559,19 @@
|
||||
"accessReadCapability": "They can read everything this record shares. They cannot add or change anything.",
|
||||
"accessWriteLabel": "Can view and add",
|
||||
"accessWriteCapability": "They can also add to the record: readings, lab results, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.",
|
||||
"accessManageLabel": "Can manage",
|
||||
"accessManageCapability": "They can add, change and remove entries in your record, including ones you entered yourself, and they can read the insights generated from it. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.",
|
||||
"manageStepUpNote": "Offering this asks for your second factor, so it can only be done in a browser.",
|
||||
"scopeLegend": "What they can open",
|
||||
"scopeAllLabel": "Entire record",
|
||||
"scopeAllCapability": "Every part of it, the way sharing has always worked.",
|
||||
"scopeSomeLabel": "Only the sections you pick",
|
||||
"scopeSomeCapability": "Everything you do not pick stays closed to them.",
|
||||
"scopeManageNote": "Manage access always covers the entire record.",
|
||||
"scopeLeakNote": "A section can mention the rest of your health in its notes and names. Sharing a section shares everything written in it.",
|
||||
"scopeAggregateNote": "The dashboard overview, health score and daily digest only appear when the entire record is shared.",
|
||||
"scopeDocumentsNote": "A document can contain anything. Share this only if they may read all of it.",
|
||||
"scopeEmptyHint": "Pick at least one section, or go back to sharing the entire record.",
|
||||
"expiresLabel": "Access ends on (optional)",
|
||||
"expiresHint": "Leave this empty for access that lasts until somebody ends it. A date here is checked on every request, so access stops on its own at the end of that day.",
|
||||
"submit": "Send invitation",
|
||||
@@ -9449,7 +9582,11 @@
|
||||
"errorSelf": "That is your own account.",
|
||||
"errorInvalid": "Please enter a username or e-mail address.",
|
||||
"errorRateLimited": "Too many invitations. Please try again later.",
|
||||
"errorFailed": "The invitation could not be sent. Please try again."
|
||||
"errorFailed": "The invitation could not be sent. Please try again.",
|
||||
"errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.",
|
||||
"errorManageBrowserOnly": "Manage access can only be offered in a browser, because it asks for your second factor.",
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"errorOffline": "That did not reach the server. Your invitation is still here — check your connection and try again."
|
||||
},
|
||||
"given": {
|
||||
"title": "Who can open my record",
|
||||
@@ -9474,6 +9611,7 @@
|
||||
"accept": "Accept",
|
||||
"accepting": "Accepting…",
|
||||
"writeConsent": "If you accept, what you add becomes part of {name}'s record. You will not be able to change or remove entries afterwards; only {name} can.",
|
||||
"manageConsent": "If you accept, you can add to {name}'s record and also change or remove what is already in it, including entries {name} made themselves. Everything you do is recorded under your name in their activity view, and {name} can end your access at any time.",
|
||||
"open": "Open",
|
||||
"renounce": "Hand back",
|
||||
"renounceTitle": "Hand {name}'s record back?",
|
||||
@@ -9489,7 +9627,8 @@
|
||||
"loadError": "The activity could not be loaded.",
|
||||
"opened": "{name} opened your record {count}×",
|
||||
"acted": "{name} made a change in your record",
|
||||
"deletedAccount": "A deleted account"
|
||||
"deletedAccount": "A deleted account",
|
||||
"truncated": "This list shows the most recent activity, not all of it."
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do."
|
||||
@@ -9499,8 +9638,65 @@
|
||||
"description": "Records other people have shared with you.",
|
||||
"levelRead": "Can view",
|
||||
"levelWrite": "Can view and add",
|
||||
"kindShared": "Shared record",
|
||||
"kindManaged": "Managed profile",
|
||||
"open": "Open {name}'s record"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Records you look after",
|
||||
"description": "Health records with no login of their own.",
|
||||
"explainer": "A managed profile is a health record for somebody who does not sign in themselves — a child, or a person you care for. It has no login and no e-mail address, and you become its first guardian. You can add more guardians later, and any of you can switch into the record to keep it up to date.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "The name you will recognise this record by",
|
||||
"nameHint": "Up to 80 characters. Shown wherever this record appears.",
|
||||
"nameRequired": "Give the record a name.",
|
||||
"nameTooLong": "That name is longer than 80 characters.",
|
||||
"dobLabel": "Date of birth (optional)",
|
||||
"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.",
|
||||
"timezoneHint": "The zone this record's days are measured in.",
|
||||
"timezoneInvalid": "This browser does not know that timezone.",
|
||||
"create": "Create profile",
|
||||
"creating": "Creating…",
|
||||
"created": "{name} is set up. You can switch into that record from the account menu.",
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"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.",
|
||||
"delete": "Delete profile",
|
||||
"deleteTitle": "Delete {name}?",
|
||||
"deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.",
|
||||
"deleteGuardiansOne": "{count} person looks after this record today and will lose it.",
|
||||
"deleteGuardiansFew": "{count} people look after this record today and will lose it.",
|
||||
"deleteGuardiansOther": "{count} people look after this record today and will lose it.",
|
||||
"deleteFloorNote": "This is also the way out when you are the last guardian: you may end the record, but you may not leave it with nobody looking after it.",
|
||||
"deleteConfirm": "Delete profile",
|
||||
"guardiansTitle": "Who looks after {name}",
|
||||
"guardianYou": "(you)",
|
||||
"guardianSince": "Looking after this record since {date}",
|
||||
"guardianPending": "Invited {date}, not accepted yet",
|
||||
"guardianRemove": "End access",
|
||||
"guardianRemoveTitle": "End {name}'s access?",
|
||||
"guardianRemoveBody": "{name} stops being able to open {profile}. Anything they entered stays in the record.",
|
||||
"guardianWithdrawBody": "The invitation to {name} is withdrawn. They never opened {profile}, so nothing changes in the record.",
|
||||
"guardianRemoveConfirm": "End access",
|
||||
"soleGuardian": "You are the only person looking after {name}, so there is no access here you can end. Invite somebody and wait for them to accept, hand your own access back under “Records I can open”, or delete the profile.",
|
||||
"guardianInviteLabel": "Invite another guardian",
|
||||
"guardianInvite": "Send invitation",
|
||||
"guardianInviting": "Sending…",
|
||||
"guardianInvited": "Invitation sent to {name}.",
|
||||
"guardianExpiresHint": "Optional. Access ends at the end of that day in your timezone.",
|
||||
"guardianPendingNote": "An invited guardian counts only once they accept. Until then you are still the last one, and handing your own access back is still refused.",
|
||||
"guardianErrorDuplicate": "That account already has access to this record.",
|
||||
"guardianErrorManagedInvitee": "A managed profile cannot look after another record. Invite an account that signs in.",
|
||||
"guardianErrorNoAccount": "No account on this instance carries that name or e-mail.",
|
||||
"guardianErrorRateLimit": "Too many invitations in the last hour. Wait a while and try again.",
|
||||
"guardianErrorFailed": "The invitation could not be sent.",
|
||||
"errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.",
|
||||
"rosterLoadError": "Could not load who looks after this record.",
|
||||
"errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Saved to {name}'s record."
|
||||
},
|
||||
@@ -9515,7 +9711,143 @@
|
||||
"medicationSideEffectCreate": "{name} noted a side effect",
|
||||
"medicationCreate": "{name} added a medication",
|
||||
"medicationIntake": "{name} marked a dose",
|
||||
"medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused"
|
||||
"medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused",
|
||||
"measurementUpdate": "{name} changed a reading",
|
||||
"measurementDelete": "{name} removed a reading",
|
||||
"measurementDeleteBulk": "{name} removed several readings at once",
|
||||
"measurementRestore": "{name} restored a reading",
|
||||
"measurementReminderCreate": "{name} added a measurement reminder",
|
||||
"measurementReminderUpdate": "{name} changed a measurement reminder",
|
||||
"measurementReminderDelete": "{name} removed a measurement reminder",
|
||||
"measurementReminderComplete": "{name} completed a measurement reminder",
|
||||
"measurementReminderSatisfy": "{name} cleared a measurement reminder",
|
||||
"labResultUpdate": "{name} changed a lab result",
|
||||
"labResultDelete": "{name} removed a lab result",
|
||||
"labResultRestore": "{name} restored a lab result",
|
||||
"biomarkerUpdate": "{name} changed a biomarker",
|
||||
"biomarkerDelete": "{name} removed a biomarker",
|
||||
"allergyUpdate": "{name} changed an allergy",
|
||||
"allergyDelete": "{name} removed an allergy",
|
||||
"familyHistoryUpdate": "{name} changed a family history entry",
|
||||
"familyHistoryDelete": "{name} removed a family history entry",
|
||||
"illnessEpisodeUpdate": "{name} changed an illness entry",
|
||||
"illnessEpisodeDelete": "{name} removed an illness entry",
|
||||
"illnessEpisodeResolve": "{name} marked an illness as over",
|
||||
"illnessEpisodeRestore": "{name} restored an illness entry",
|
||||
"illnessDayLogUpsert": "{name} recorded an illness day",
|
||||
"medicationUpdate": "{name} changed a medication",
|
||||
"medicationDelete": "{name} removed a medication",
|
||||
"medicationGlp1Update": "{name} changed a GLP-1 course",
|
||||
"medicationScheduleCreate": "{name} added a dosing schedule",
|
||||
"medicationScheduleUpdate": "{name} changed a dosing schedule",
|
||||
"medicationScheduleDelete": "{name} removed a dosing schedule",
|
||||
"medicationInventoryCreate": "{name} added a medication package",
|
||||
"medicationInventoryUpdate": "{name} changed a medication package",
|
||||
"medicationInventoryDelete": "{name} removed a medication package",
|
||||
"medicationSideEffectDelete": "{name} removed a side effect",
|
||||
"medicationIntakeUpdate": "{name} changed a dose",
|
||||
"medicationIntakeDelete": "{name} removed a dose",
|
||||
"medicationIntakeBulkDelete": "{name} removed several doses at once",
|
||||
"medicationIntakePurge": "{name} deleted the whole dose history of a medication",
|
||||
"medicationIntakeImport": "{name} started a dose import for one medication",
|
||||
"medicationIntakeHistoryImport": "{name} started a dose history import",
|
||||
"moodEntryCreate": "{name} added a mood entry",
|
||||
"moodEntryUpdate": "{name} changed a mood entry",
|
||||
"moodEntryDelete": "{name} removed a mood entry",
|
||||
"moodDeleteBulk": "{name} removed several mood entries at once",
|
||||
"moodRestore": "{name} restored a mood entry",
|
||||
"mentalHealthCreate": "{name} recorded a questionnaire result",
|
||||
"cycleDelete": "{name} removed a cycle",
|
||||
"cycleDayLogUpsert": "{name} recorded a cycle day",
|
||||
"cycleDayLogUpdate": "{name} changed a cycle day",
|
||||
"cycleDayLogDelete": "{name} removed a cycle day",
|
||||
"cyclePeriodBoundary": "{name} changed the start or end of a period",
|
||||
"cycleSymptomCreate": "{name} added a cycle symptom",
|
||||
"nutrientWaterWrite": "{name} recorded water intake",
|
||||
"healthRecordExport": "{name} exported the record",
|
||||
"rejectedMeasurementQuery": "{name} sent a reading search this record could not read",
|
||||
"rejectedDoseQuery": "{name} sent a dose search this record could not read",
|
||||
"rejectedMoodQuery": "{name} sent a mood search this record could not read",
|
||||
"rejectedMeasurementCreate": "{name} tried to add a reading and was refused",
|
||||
"rejectedMeasurementBatch": "{name} tried to add several readings and was refused",
|
||||
"rejectedMeasurementUpdate": "{name} tried to change a reading and was refused",
|
||||
"rejectedLabCreate": "{name} tried to add a lab result and was refused",
|
||||
"rejectedLabUpdate": "{name} tried to change a lab result and was refused",
|
||||
"rejectedBiomarkerCreate": "{name} tried to add a biomarker and was refused",
|
||||
"rejectedBiomarkerUpdate": "{name} tried to change a biomarker and was refused",
|
||||
"rejectedReminderCreate": "{name} tried to add a measurement reminder and was refused",
|
||||
"rejectedReminderUpdate": "{name} tried to change a measurement reminder and was refused",
|
||||
"rejectedMoodCreate": "{name} tried to add a mood entry and was refused",
|
||||
"rejectedMoodUpdate": "{name} tried to change a mood entry and was refused",
|
||||
"rejectedDoseCreate": "{name} tried to record a dose and was refused",
|
||||
"rejectedDoseUpdate": "{name} tried to change a dose and was refused",
|
||||
"rejectedDoseImport": "{name} tried to import doses and was refused",
|
||||
"rejectedTrackedValue": "{name} tried to log a tracked value and was refused",
|
||||
"deniedDoseImport": "A dose import {name} started could not be queued",
|
||||
"anamnesisFactCreate": "{name} added a health background fact",
|
||||
"customMetricCreate": "{name} added a tracked value type",
|
||||
"customMetricUpdate": "{name} changed a tracked value type",
|
||||
"customMetricDelete": "{name} removed a tracked value type",
|
||||
"documentStore": "{name} added a document",
|
||||
"documentUpdate": "{name} changed a document",
|
||||
"documentDiscard": "{name} discarded a document",
|
||||
"medicationIntakeImportFinished": "The dose import {name} started has finished",
|
||||
"medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
"label": "Readings",
|
||||
"detail": "Weight, blood pressure, pulse, glucose, sleep, workouts and the values you track."
|
||||
},
|
||||
"medications": {
|
||||
"label": "Medications",
|
||||
"detail": "The medication list, its schedules, the doses taken, side effects and stock."
|
||||
},
|
||||
"labs": {
|
||||
"label": "Lab results",
|
||||
"detail": "Lab results and the list of analytes behind them."
|
||||
},
|
||||
"profile": {
|
||||
"label": "Health background",
|
||||
"detail": "Allergies, family history and the facts in your health profile."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Illness",
|
||||
"detail": "Illness episodes and the day logs kept under them."
|
||||
},
|
||||
"mind": {
|
||||
"label": "Mood and mind",
|
||||
"detail": "Mood entries and the history of the mental-health questionnaires."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cycle",
|
||||
"detail": "Cycle tracking."
|
||||
},
|
||||
"documents": {
|
||||
"label": "Documents",
|
||||
"detail": "Everything stored in the document vault."
|
||||
}
|
||||
},
|
||||
"actionError": {
|
||||
"retry": "Try again",
|
||||
"offline": "That did not reach the server. Nothing has changed — check your connection and try again.",
|
||||
"rateLimited": "Too many attempts in a row. Wait a minute and try again.",
|
||||
"acceptExpired": "That invitation has lapsed. Ask for a new one.",
|
||||
"acceptWithdrawn": "That invitation was withdrawn.",
|
||||
"acceptNotPending": "That invitation has already been accepted.",
|
||||
"acceptNotFound": "That invitation is no longer there.",
|
||||
"acceptFailed": "The invitation could not be accepted.",
|
||||
"lastGuardian": "This is the only person looking after that profile, so this access cannot end yet. Add another guardian first, or delete the profile.",
|
||||
"alreadyEnded": "That access has already ended.",
|
||||
"notFound": "That access is no longer there.",
|
||||
"revokeFailed": "The access could not be ended.",
|
||||
"renounceFailed": "The access could not be handed back.",
|
||||
"profileNotFound": "That profile is not there any more — it may already have been deleted, or your access to it may have ended.",
|
||||
"deleteProfileFailed": "The profile could not be deleted.",
|
||||
"removeGuardianFailed": "That access could not be ended."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here."
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
-4
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis",
|
||||
"profile": "Perfil",
|
||||
"vorsorge": "Revisiones",
|
||||
"illness": "Enfermedad",
|
||||
"documents": "Documentos",
|
||||
@@ -4433,6 +4434,121 @@
|
||||
"system": "Sistema"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Perfil",
|
||||
"displayName": "Nombre visible",
|
||||
"height": "Altura (cm)",
|
||||
"dateOfBirth": "Fecha de nacimiento",
|
||||
"gender": "Género",
|
||||
"notSpecified": "No especificado",
|
||||
"male": "Hombre",
|
||||
"female": "Mujer",
|
||||
"other": "Otro",
|
||||
"locale": "Idioma",
|
||||
"systemDefault": "Predeterminado del sistema",
|
||||
"timezone": "Zona horaria",
|
||||
"units": "Unidades",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"timeFormat": "Formato de hora",
|
||||
"automatic": "Automático",
|
||||
"hour12": "12 horas",
|
||||
"hour24": "24 horas",
|
||||
"dateFormat": "Formato de fecha",
|
||||
"dateDmy": "Día / mes / año",
|
||||
"dateMdy": "Mes / día / año",
|
||||
"dateYmd": "Año / mes / día"
|
||||
},
|
||||
"modules": {
|
||||
"title": "Módulos"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"moodReminder": "Activar recordatorio de ánimo",
|
||||
"reminderHour": "Hora del recordatorio de ánimo",
|
||||
"lowStockRunwayDays": "Días hasta existencias bajas",
|
||||
"reorderLeadDays": "Días de margen para reponer"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Objetivos",
|
||||
"metric": "Métrica",
|
||||
"minimum": "Mínimo",
|
||||
"maximum": "Máximo"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Asistente",
|
||||
"disable": "Desactivar asistente",
|
||||
"tone": "Tono",
|
||||
"warm": "Cálido",
|
||||
"neutral": "Neutro",
|
||||
"concise": "Conciso",
|
||||
"verbosity": "Nivel de detalle",
|
||||
"brief": "Breve",
|
||||
"default": "Predeterminado",
|
||||
"detailed": "Detallado",
|
||||
"defaultWindow": "Periodo predeterminado",
|
||||
"last7days": "Últimos 7 días",
|
||||
"last30days": "Últimos 30 días",
|
||||
"last90days": "Últimos 90 días",
|
||||
"allTime": "Todo el tiempo",
|
||||
"showEvidence": "Mostrar evidencia de forma predeterminada",
|
||||
"excludedMetrics": "Métricas excluidas",
|
||||
"dataClusters": "Grupos de datos",
|
||||
"customClusters": "Usar una selección personalizada de grupos",
|
||||
"metrics": {
|
||||
"bp": "Presión arterial",
|
||||
"weight": "Peso",
|
||||
"pulse": "Pulso",
|
||||
"mood": "Ánimo",
|
||||
"compliance": "Adherencia a la medicación",
|
||||
"hrv": "Variabilidad de la frecuencia cardíaca",
|
||||
"sleep": "Sueño",
|
||||
"restingHr": "Frecuencia cardíaca en reposo",
|
||||
"steps": "Pasos",
|
||||
"medications": "Medicamentos",
|
||||
"anthropometrics": "Medidas corporales"
|
||||
},
|
||||
"clusters": {
|
||||
"cardio": "Salud cardiovascular",
|
||||
"body": "Composición corporal",
|
||||
"activity": "Actividad",
|
||||
"workouts": "Entrenamientos",
|
||||
"sleep": "Sueño",
|
||||
"mood": "Ánimo",
|
||||
"glucose": "Glucosa",
|
||||
"medication": "Medicación",
|
||||
"mobility": "Movilidad",
|
||||
"environment": "Entorno"
|
||||
}
|
||||
},
|
||||
"insights": {
|
||||
"title": "Análisis",
|
||||
"sections": "Secciones del resumen",
|
||||
"tiles": "Mosaicos de navegación",
|
||||
"order": "Orden",
|
||||
"unknownItem": "Análisis no disponible",
|
||||
"sectionLabels": {
|
||||
"wellnessScores": "Puntuaciones de bienestar",
|
||||
"dailyBriefing": "Resumen diario",
|
||||
"vitals": "Signos vitales",
|
||||
"trends": "Tendencias",
|
||||
"periodReview": "Revisión del período",
|
||||
"cycleSummary": "Resumen del ciclo",
|
||||
"signals": "Señales",
|
||||
"rhythmEvents": "Eventos de ritmo",
|
||||
"healthStatus": "Estado de salud",
|
||||
"breathing": "Respiración",
|
||||
"labsChanges": "Cambios de laboratorio",
|
||||
"ecg": "ECG"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "No disponible en este registro compartido",
|
||||
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
|
||||
"integrationStatusDescription": "Estado de conexión de este registro gestionado. Los controles de conexión no están disponibles."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Cuenta",
|
||||
@@ -9406,7 +9522,7 @@
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "No forma parte del acceso compartido",
|
||||
"description": "Se comparte el historial de salud en sí. Los ajustes, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.",
|
||||
"description": "Se comparte el historial de salud en sí, incluidos los antecedentes que hay en Anamnesis. Los ajustes de la cuenta, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.",
|
||||
"leave": "Volver a mi historial"
|
||||
},
|
||||
"state": {
|
||||
@@ -9418,6 +9534,10 @@
|
||||
"row": {
|
||||
"readOnly": "Puede leer el historial",
|
||||
"canWrite": "Puede leer y añadir entradas",
|
||||
"canManage": "Puede leer el historial, añadir entradas y cambiar o eliminar lo que hay en él",
|
||||
"entireRecord": "Cubre todo el historial",
|
||||
"sections": "Cubre: {sections}",
|
||||
"noSections": "No cubre nada: este acceso no abre ninguna parte del historial",
|
||||
"invited": "Invitado",
|
||||
"accepted": "Aceptado",
|
||||
"expires": "Caduca",
|
||||
@@ -9439,6 +9559,19 @@
|
||||
"accessReadCapability": "Puede leer todo lo que este historial comparte. No puede añadir ni cambiar nada.",
|
||||
"accessWriteLabel": "Puede ver y añadir",
|
||||
"accessWriteCapability": "También puede añadir a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.",
|
||||
"accessManageLabel": "Puede gestionar",
|
||||
"accessManageCapability": "Puede añadir, cambiar y eliminar entradas de tu ficha, incluidas las que has escrito tú, y puede leer los análisis generados a partir de ella. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.",
|
||||
"manageStepUpNote": "Ofrecerlo pide tu segundo factor, así que solo se puede hacer en un navegador.",
|
||||
"scopeLegend": "Qué puede abrir",
|
||||
"scopeAllLabel": "Todo el historial",
|
||||
"scopeAllCapability": "Todas sus partes, tal y como ha funcionado siempre el uso compartido.",
|
||||
"scopeSomeLabel": "Solo las secciones que elijas",
|
||||
"scopeSomeCapability": "Todo lo que no elijas le queda cerrado.",
|
||||
"scopeManageNote": "El acceso de gestión siempre cubre todo el historial.",
|
||||
"scopeLeakNote": "Una sección puede mencionar el resto de tu salud en sus notas y en sus nombres. Compartir una sección comparte todo lo que hay escrito en ella.",
|
||||
"scopeAggregateNote": "El resumen del panel, la puntuación de salud y el resumen diario solo aparecen cuando se comparte todo el historial.",
|
||||
"scopeDocumentsNote": "Un documento puede contener cualquier cosa. Comparte esta sección solo si esa persona puede leerlo todo.",
|
||||
"scopeEmptyHint": "Elige al menos una sección o vuelve a compartir todo el historial.",
|
||||
"expiresLabel": "El acceso termina el (opcional)",
|
||||
"expiresHint": "Déjalo vacío para un acceso que dure hasta que alguien lo termine. Una fecha aquí se comprueba en cada petición, así que el acceso se detiene solo al final de ese día.",
|
||||
"submit": "Enviar invitación",
|
||||
@@ -9449,7 +9582,11 @@
|
||||
"errorSelf": "Esa es tu propia cuenta.",
|
||||
"errorInvalid": "Introduce un usuario o una dirección de correo.",
|
||||
"errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.",
|
||||
"errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo."
|
||||
"errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.",
|
||||
"errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.",
|
||||
"errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador, porque pide tu segundo factor.",
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo."
|
||||
},
|
||||
"given": {
|
||||
"title": "Quién puede abrir mi historial",
|
||||
@@ -9474,6 +9611,7 @@
|
||||
"accept": "Aceptar",
|
||||
"accepting": "Aceptando…",
|
||||
"writeConsent": "Si aceptas, lo que añadas pasa a formar parte del historial de {name}. Después no podrás cambiar ni eliminar entradas; solo {name} puede.",
|
||||
"manageConsent": "Si aceptas, podrás añadir cosas al historial de {name} y también cambiar o eliminar lo que ya hay, incluidas las entradas del propio {name}. Todo lo que hagas queda registrado con tu nombre en su vista de actividad, y {name} puede terminar tu acceso cuando quiera.",
|
||||
"open": "Abrir",
|
||||
"renounce": "Devolver",
|
||||
"renounceTitle": "¿Devolver el historial de {name}?",
|
||||
@@ -9489,7 +9627,8 @@
|
||||
"loadError": "No se pudo cargar la actividad.",
|
||||
"opened": "{name} abrió tu historial {count}×",
|
||||
"acted": "{name} hizo un cambio en tu historial",
|
||||
"deletedAccount": "Una cuenta eliminada"
|
||||
"deletedAccount": "Una cuenta eliminada",
|
||||
"truncated": "Esta lista muestra la actividad más reciente, no toda."
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}."
|
||||
@@ -9499,8 +9638,65 @@
|
||||
"description": "Historiales que otras personas han compartido contigo.",
|
||||
"levelRead": "Puede ver",
|
||||
"levelWrite": "Puede ver y añadir",
|
||||
"kindShared": "Registro compartido",
|
||||
"kindManaged": "Perfil administrado",
|
||||
"open": "Abrir el historial de {name}"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Perfiles de los que te ocupas",
|
||||
"description": "Historiales de salud sin inicio de sesión propio.",
|
||||
"explainer": "Un perfil administrado es un historial de salud para alguien que no inicia sesión — una criatura, o una persona a la que cuidas. No tiene inicio de sesión ni dirección de correo, y tú pasas a ser su primera persona responsable. Más adelante puedes añadir otras, y cualquiera de vosotras puede entrar en el historial para mantenerlo al día.",
|
||||
"nameLabel": "Nombre",
|
||||
"namePlaceholder": "El nombre con el que reconocerás este historial",
|
||||
"nameHint": "Hasta 80 caracteres. Se muestra allá donde aparezca este historial.",
|
||||
"nameRequired": "Dale un nombre al historial.",
|
||||
"nameTooLong": "Ese nombre tiene más de 80 caracteres.",
|
||||
"dobLabel": "Fecha de nacimiento (opcional)",
|
||||
"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.",
|
||||
"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",
|
||||
"creating": "Creando…",
|
||||
"created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.",
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"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.",
|
||||
"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.",
|
||||
"deleteGuardiansOne": "{count} persona se ocupa hoy de este historial y lo perderá.",
|
||||
"deleteGuardiansFew": "{count} personas se ocupan hoy de este historial y lo perderán.",
|
||||
"deleteGuardiansOther": "{count} personas se ocupan hoy de este historial y lo perderán.",
|
||||
"deleteFloorNote": "También es la salida cuando eres la última persona responsable: puedes terminar el historial, pero no dejarlo sin nadie que se ocupe de él.",
|
||||
"deleteConfirm": "Eliminar perfil",
|
||||
"guardiansTitle": "Quién se ocupa de {name}",
|
||||
"guardianYou": "(tú)",
|
||||
"guardianSince": "Se ocupa de este historial desde el {date}",
|
||||
"guardianPending": "Invitada el {date}, aún sin aceptar",
|
||||
"guardianRemove": "Terminar acceso",
|
||||
"guardianRemoveTitle": "¿Terminar el acceso de {name}?",
|
||||
"guardianRemoveBody": "{name} dejará de poder abrir {profile}. Lo que haya registrado se queda en el historial.",
|
||||
"guardianWithdrawBody": "Se retira la invitación a {name}. Nunca abrió {profile}, así que en el historial no cambia nada.",
|
||||
"guardianRemoveConfirm": "Terminar acceso",
|
||||
"soleGuardian": "Eres la única persona que se ocupa de {name}, así que aquí no hay ningún acceso que puedas terminar. Invita a alguien y espera a que acepte, devuelve tu propio acceso en «Historiales que puedo abrir», o elimina el perfil.",
|
||||
"guardianInviteLabel": "Invitar a otra persona responsable",
|
||||
"guardianInvite": "Enviar invitación",
|
||||
"guardianInviting": "Enviando…",
|
||||
"guardianInvited": "Invitación enviada a {name}.",
|
||||
"guardianExpiresHint": "Opcional. El acceso termina al final de ese día en tu zona horaria.",
|
||||
"guardianPendingNote": "Una persona invitada solo cuenta cuando acepta. Hasta entonces sigues siendo la última, y devolver tu propio acceso se sigue rechazando.",
|
||||
"guardianErrorDuplicate": "Esa cuenta ya tiene acceso a este historial.",
|
||||
"guardianErrorManagedInvitee": "Un perfil administrado no puede ocuparse de otro historial. Invita a una cuenta que inicie sesión.",
|
||||
"guardianErrorNoAccount": "En esta instancia no hay ninguna cuenta con ese nombre o correo.",
|
||||
"guardianErrorRateLimit": "Demasiadas invitaciones en la última hora. Espera un poco y vuelve a intentarlo.",
|
||||
"guardianErrorFailed": "No se pudo enviar la invitación.",
|
||||
"errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.",
|
||||
"rosterLoadError": "No se pudo cargar quién se ocupa de este historial.",
|
||||
"errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Guardado en el historial de {name}."
|
||||
},
|
||||
@@ -9515,7 +9711,143 @@
|
||||
"medicationSideEffectCreate": "{name} anotó un efecto secundario",
|
||||
"medicationCreate": "{name} añadió un medicamento",
|
||||
"medicationIntake": "{name} registró una dosis",
|
||||
"medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado"
|
||||
"medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado",
|
||||
"measurementUpdate": "{name} cambió una medición",
|
||||
"measurementDelete": "{name} eliminó una medición",
|
||||
"measurementDeleteBulk": "{name} eliminó varias mediciones a la vez",
|
||||
"measurementRestore": "{name} restauró una medición",
|
||||
"measurementReminderCreate": "{name} añadió un recordatorio de medición",
|
||||
"measurementReminderUpdate": "{name} cambió un recordatorio de medición",
|
||||
"measurementReminderDelete": "{name} eliminó un recordatorio de medición",
|
||||
"measurementReminderComplete": "{name} completó un recordatorio de medición",
|
||||
"measurementReminderSatisfy": "{name} cerró un recordatorio de medición",
|
||||
"labResultUpdate": "{name} cambió un resultado de laboratorio",
|
||||
"labResultDelete": "{name} eliminó un resultado de laboratorio",
|
||||
"labResultRestore": "{name} restauró un resultado de laboratorio",
|
||||
"biomarkerUpdate": "{name} cambió un biomarcador",
|
||||
"biomarkerDelete": "{name} eliminó un biomarcador",
|
||||
"allergyUpdate": "{name} cambió una alergia",
|
||||
"allergyDelete": "{name} eliminó una alergia",
|
||||
"familyHistoryUpdate": "{name} cambió un antecedente familiar",
|
||||
"familyHistoryDelete": "{name} eliminó un antecedente familiar",
|
||||
"illnessEpisodeUpdate": "{name} cambió una enfermedad",
|
||||
"illnessEpisodeDelete": "{name} eliminó una enfermedad",
|
||||
"illnessEpisodeResolve": "{name} marcó una enfermedad como terminada",
|
||||
"illnessEpisodeRestore": "{name} restauró una enfermedad",
|
||||
"illnessDayLogUpsert": "{name} registró un día de enfermedad",
|
||||
"medicationUpdate": "{name} cambió un medicamento",
|
||||
"medicationDelete": "{name} eliminó un medicamento",
|
||||
"medicationGlp1Update": "{name} cambió un tratamiento con GLP-1",
|
||||
"medicationScheduleCreate": "{name} añadió una pauta de dosificación",
|
||||
"medicationScheduleUpdate": "{name} cambió una pauta de dosificación",
|
||||
"medicationScheduleDelete": "{name} eliminó una pauta de dosificación",
|
||||
"medicationInventoryCreate": "{name} añadió un envase de medicamento",
|
||||
"medicationInventoryUpdate": "{name} cambió un envase de medicamento",
|
||||
"medicationInventoryDelete": "{name} eliminó un envase de medicamento",
|
||||
"medicationSideEffectDelete": "{name} eliminó un efecto secundario",
|
||||
"medicationIntakeUpdate": "{name} cambió una dosis",
|
||||
"medicationIntakeDelete": "{name} eliminó una dosis",
|
||||
"medicationIntakeBulkDelete": "{name} eliminó varias dosis a la vez",
|
||||
"medicationIntakePurge": "{name} borró todo el historial de dosis de un medicamento",
|
||||
"medicationIntakeImport": "{name} inició una importación de dosis de un medicamento",
|
||||
"medicationIntakeHistoryImport": "{name} inició una importación del historial de dosis",
|
||||
"moodEntryCreate": "{name} añadió una entrada de ánimo",
|
||||
"moodEntryUpdate": "{name} cambió una entrada de ánimo",
|
||||
"moodEntryDelete": "{name} eliminó una entrada de ánimo",
|
||||
"moodDeleteBulk": "{name} eliminó varias entradas de ánimo a la vez",
|
||||
"moodRestore": "{name} restauró una entrada de ánimo",
|
||||
"mentalHealthCreate": "{name} registró el resultado de un cuestionario",
|
||||
"cycleDelete": "{name} eliminó un ciclo",
|
||||
"cycleDayLogUpsert": "{name} registró un día del ciclo",
|
||||
"cycleDayLogUpdate": "{name} cambió un día del ciclo",
|
||||
"cycleDayLogDelete": "{name} eliminó un día del ciclo",
|
||||
"cyclePeriodBoundary": "{name} cambió el inicio o el final de una regla",
|
||||
"cycleSymptomCreate": "{name} añadió un síntoma del ciclo",
|
||||
"nutrientWaterWrite": "{name} registró la ingesta de agua",
|
||||
"healthRecordExport": "{name} exportó el historial",
|
||||
"rejectedMeasurementQuery": "{name} envió una búsqueda de mediciones que este historial no pudo leer",
|
||||
"rejectedDoseQuery": "{name} envió una búsqueda de dosis que este historial no pudo leer",
|
||||
"rejectedMoodQuery": "{name} envió una búsqueda de ánimo que este historial no pudo leer",
|
||||
"rejectedMeasurementCreate": "{name} intentó añadir una medición y fue rechazado",
|
||||
"rejectedMeasurementBatch": "{name} intentó añadir varias mediciones y fue rechazado",
|
||||
"rejectedMeasurementUpdate": "{name} intentó cambiar una medición y fue rechazado",
|
||||
"rejectedLabCreate": "{name} intentó añadir un resultado de laboratorio y fue rechazado",
|
||||
"rejectedLabUpdate": "{name} intentó cambiar un resultado de laboratorio y fue rechazado",
|
||||
"rejectedBiomarkerCreate": "{name} intentó añadir un biomarcador y fue rechazado",
|
||||
"rejectedBiomarkerUpdate": "{name} intentó cambiar un biomarcador y fue rechazado",
|
||||
"rejectedReminderCreate": "{name} intentó añadir un recordatorio de medición y fue rechazado",
|
||||
"rejectedReminderUpdate": "{name} intentó cambiar un recordatorio de medición y fue rechazado",
|
||||
"rejectedMoodCreate": "{name} intentó añadir una entrada de ánimo y fue rechazado",
|
||||
"rejectedMoodUpdate": "{name} intentó cambiar una entrada de ánimo y fue rechazado",
|
||||
"rejectedDoseCreate": "{name} intentó registrar una dosis y fue rechazado",
|
||||
"rejectedDoseUpdate": "{name} intentó cambiar una dosis y fue rechazado",
|
||||
"rejectedDoseImport": "{name} intentó importar dosis y fue rechazado",
|
||||
"rejectedTrackedValue": "{name} intentó registrar un valor propio y fue rechazado",
|
||||
"deniedDoseImport": "Una importación de dosis iniciada por {name} no se pudo poner en cola",
|
||||
"anamnesisFactCreate": "{name} añadió un dato de antecedentes de salud",
|
||||
"customMetricCreate": "{name} añadió un tipo de valor propio",
|
||||
"customMetricUpdate": "{name} cambió un tipo de valor propio",
|
||||
"customMetricDelete": "{name} eliminó un tipo de valor propio",
|
||||
"documentStore": "{name} añadió un documento",
|
||||
"documentUpdate": "{name} cambió un documento",
|
||||
"documentDiscard": "{name} descartó un documento",
|
||||
"medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado",
|
||||
"medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
"label": "Mediciones",
|
||||
"detail": "Peso, tensión arterial, pulso, glucosa, sueño, entrenamientos y los valores que sigues."
|
||||
},
|
||||
"medications": {
|
||||
"label": "Medicamentos",
|
||||
"detail": "La lista de medicamentos, sus pautas, las dosis tomadas, los efectos secundarios y las existencias."
|
||||
},
|
||||
"labs": {
|
||||
"label": "Resultados de laboratorio",
|
||||
"detail": "Los resultados de laboratorio y la lista de analitos que hay detrás."
|
||||
},
|
||||
"profile": {
|
||||
"label": "Antecedentes de salud",
|
||||
"detail": "Alergias, antecedentes familiares y los datos de tu perfil de salud."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Enfermedades",
|
||||
"detail": "Los episodios de enfermedad y los registros diarios asociados."
|
||||
},
|
||||
"mind": {
|
||||
"label": "Ánimo y bienestar mental",
|
||||
"detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Ciclo",
|
||||
"detail": "El seguimiento del ciclo."
|
||||
},
|
||||
"documents": {
|
||||
"label": "Documentos",
|
||||
"detail": "Todo lo que está guardado en el archivo de documentos."
|
||||
}
|
||||
},
|
||||
"actionError": {
|
||||
"retry": "Inténtalo de nuevo",
|
||||
"offline": "Esto no llegó al servidor. No ha cambiado nada: comprueba tu conexión e inténtalo de nuevo.",
|
||||
"rateLimited": "Demasiados intentos seguidos. Espera un minuto e inténtalo de nuevo.",
|
||||
"acceptExpired": "Esa invitación ha caducado. Pide una nueva.",
|
||||
"acceptWithdrawn": "Esa invitación fue retirada.",
|
||||
"acceptNotPending": "Esa invitación ya se aceptó.",
|
||||
"acceptNotFound": "Esa invitación ya no existe.",
|
||||
"acceptFailed": "No se pudo aceptar la invitación.",
|
||||
"lastGuardian": "Esta es la única persona que cuida de ese perfil, así que este acceso todavía no puede terminar. Añade otra persona responsable primero, o elimina el perfil.",
|
||||
"alreadyEnded": "Ese acceso ya ha terminado.",
|
||||
"notFound": "Ese acceso ya no existe.",
|
||||
"revokeFailed": "No se pudo terminar el acceso.",
|
||||
"renounceFailed": "No se pudo devolver el acceso.",
|
||||
"profileNotFound": "Ese perfil ya no está — puede que se haya eliminado, o que tu acceso haya terminado.",
|
||||
"deleteProfileFailed": "No se pudo eliminar el perfil.",
|
||||
"removeGuardianFailed": "No se pudo terminar ese acceso."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí."
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
-4
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Examens",
|
||||
"illness": "Maladie",
|
||||
"documents": "Documents",
|
||||
@@ -4433,6 +4434,121 @@
|
||||
"system": "Système"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"displayName": "Nom affiché",
|
||||
"height": "Taille (cm)",
|
||||
"dateOfBirth": "Date de naissance",
|
||||
"gender": "Genre",
|
||||
"notSpecified": "Non précisé",
|
||||
"male": "Homme",
|
||||
"female": "Femme",
|
||||
"other": "Autre",
|
||||
"locale": "Langue",
|
||||
"systemDefault": "Valeur système",
|
||||
"timezone": "Fuseau horaire",
|
||||
"units": "Unités",
|
||||
"metric": "Métrique",
|
||||
"imperial": "Impérial",
|
||||
"timeFormat": "Format de l’heure",
|
||||
"automatic": "Automatique",
|
||||
"hour12": "12 heures",
|
||||
"hour24": "24 heures",
|
||||
"dateFormat": "Format de date",
|
||||
"dateDmy": "Jour / mois / année",
|
||||
"dateMdy": "Mois / jour / année",
|
||||
"dateYmd": "Année / mois / jour"
|
||||
},
|
||||
"modules": {
|
||||
"title": "Modules"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"moodReminder": "Activer le rappel d’humeur",
|
||||
"reminderHour": "Heure du rappel d’humeur",
|
||||
"lowStockRunwayDays": "Jours avant stock faible",
|
||||
"reorderLeadDays": "Délai de réapprovisionnement"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Objectifs",
|
||||
"metric": "Mesure",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"disable": "Désactiver le Coach",
|
||||
"tone": "Ton",
|
||||
"warm": "Chaleureux",
|
||||
"neutral": "Neutre",
|
||||
"concise": "Concis",
|
||||
"verbosity": "Niveau de détail",
|
||||
"brief": "Bref",
|
||||
"default": "Par défaut",
|
||||
"detailed": "Détaillé",
|
||||
"defaultWindow": "Période par défaut",
|
||||
"last7days": "7 derniers jours",
|
||||
"last30days": "30 derniers jours",
|
||||
"last90days": "90 derniers jours",
|
||||
"allTime": "Depuis toujours",
|
||||
"showEvidence": "Afficher les sources par défaut",
|
||||
"excludedMetrics": "Mesures exclues",
|
||||
"dataClusters": "Groupes de données",
|
||||
"customClusters": "Utiliser une sélection personnalisée de groupes",
|
||||
"metrics": {
|
||||
"bp": "Tension artérielle",
|
||||
"weight": "Poids",
|
||||
"pulse": "Pouls",
|
||||
"mood": "Humeur",
|
||||
"compliance": "Adhérence aux médicaments",
|
||||
"hrv": "Variabilité de la fréquence cardiaque",
|
||||
"sleep": "Sommeil",
|
||||
"restingHr": "Fréquence cardiaque au repos",
|
||||
"steps": "Pas",
|
||||
"medications": "Médicaments",
|
||||
"anthropometrics": "Mesures corporelles"
|
||||
},
|
||||
"clusters": {
|
||||
"cardio": "Santé cardiovasculaire",
|
||||
"body": "Composition corporelle",
|
||||
"activity": "Activité",
|
||||
"workouts": "Entraînements",
|
||||
"sleep": "Sommeil",
|
||||
"mood": "Humeur",
|
||||
"glucose": "Glucose",
|
||||
"medication": "Médicaments",
|
||||
"mobility": "Mobilité",
|
||||
"environment": "Environnement"
|
||||
}
|
||||
},
|
||||
"insights": {
|
||||
"title": "Analyses",
|
||||
"sections": "Sections d’aperçu",
|
||||
"tiles": "Tuiles de navigation",
|
||||
"order": "Ordre",
|
||||
"unknownItem": "Analyse indisponible",
|
||||
"sectionLabels": {
|
||||
"wellnessScores": "Scores de bien-être",
|
||||
"dailyBriefing": "Brief quotidien",
|
||||
"vitals": "Signes vitaux",
|
||||
"trends": "Tendances",
|
||||
"periodReview": "Bilan de période",
|
||||
"cycleSummary": "Résumé du cycle",
|
||||
"signals": "Signaux",
|
||||
"rhythmEvents": "Événements de rythme",
|
||||
"healthStatus": "État de santé",
|
||||
"breathing": "Respiration",
|
||||
"labsChanges": "Évolutions de laboratoire",
|
||||
"ecg": "ECG"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Indisponible dans ce dossier partagé",
|
||||
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
|
||||
"integrationStatusDescription": "État de connexion pour ce dossier géré. Les commandes de connexion ne sont pas disponibles."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Compte",
|
||||
@@ -9406,7 +9522,7 @@
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Ne fait pas partie de l'accès partagé",
|
||||
"description": "Le partage porte sur le dossier de santé lui-même. Les réglages, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.",
|
||||
"description": "Le partage porte sur le dossier de santé lui-même, y compris les antécédents dans Anamnèse. Les réglages du compte, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.",
|
||||
"leave": "Revenir à mon dossier"
|
||||
},
|
||||
"state": {
|
||||
@@ -9418,6 +9534,10 @@
|
||||
"row": {
|
||||
"readOnly": "Peut lire le dossier",
|
||||
"canWrite": "Peut lire et ajouter des entrées",
|
||||
"canManage": "Peut lire le dossier, y ajouter des entrées et modifier ou supprimer ce qui s'y trouve",
|
||||
"entireRecord": "Couvre l'intégralité du dossier",
|
||||
"sections": "Couvre : {sections}",
|
||||
"noSections": "Ne couvre rien : cet accès n'ouvre aucune partie du dossier",
|
||||
"invited": "Invité le",
|
||||
"accepted": "Accepté le",
|
||||
"expires": "Expire le",
|
||||
@@ -9439,6 +9559,19 @@
|
||||
"accessReadCapability": "Elle peut lire tout ce que ce dossier partage. Elle ne peut rien ajouter ni rien modifier.",
|
||||
"accessWriteLabel": "Peut consulter et ajouter",
|
||||
"accessWriteCapability": "Elle peut aussi ajouter au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.",
|
||||
"accessManageLabel": "Peut gérer",
|
||||
"accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées de votre dossier, y compris celles que vous avez saisies vous-même, et lire les analyses qui en sont tirées. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.",
|
||||
"manageStepUpNote": "Cette offre demande votre deuxième facteur, elle n'est donc possible que dans un navigateur.",
|
||||
"scopeLegend": "Ce que cette personne peut ouvrir",
|
||||
"scopeAllLabel": "Dossier complet",
|
||||
"scopeAllCapability": "Toutes ses parties, comme le partage a toujours fonctionné.",
|
||||
"scopeSomeLabel": "Seulement les rubriques que vous choisissez",
|
||||
"scopeSomeCapability": "Tout ce que vous ne choisissez pas lui reste fermé.",
|
||||
"scopeManageNote": "Un accès de gestion couvre toujours l'intégralité du dossier.",
|
||||
"scopeLeakNote": "Une rubrique peut évoquer le reste de votre santé dans ses notes et ses intitulés. Partager une rubrique, c'est partager tout ce qui y est écrit.",
|
||||
"scopeAggregateNote": "La vue d'ensemble du tableau de bord, le score de santé et le résumé quotidien n'apparaissent que si le dossier entier est partagé.",
|
||||
"scopeDocumentsNote": "Un document peut contenir n'importe quoi. Ne partagez cette rubrique que si la personne peut tout lire.",
|
||||
"scopeEmptyHint": "Choisissez au moins une rubrique, ou revenez au partage du dossier complet.",
|
||||
"expiresLabel": "L'accès prend fin le (facultatif)",
|
||||
"expiresHint": "Laissez ce champ vide pour un accès qui dure jusqu'à ce que quelqu'un y mette fin. Une date ici est vérifiée à chaque requête, l'accès s'arrête donc de lui-même à la fin de cette journée.",
|
||||
"submit": "Envoyer l'invitation",
|
||||
@@ -9449,7 +9582,11 @@
|
||||
"errorSelf": "C'est votre propre compte.",
|
||||
"errorInvalid": "Saisissez un nom d'utilisateur ou une adresse e-mail.",
|
||||
"errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.",
|
||||
"errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer."
|
||||
"errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.",
|
||||
"errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.",
|
||||
"errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur, car il demande votre deuxième facteur.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là — vérifiez votre connexion et réessayez."
|
||||
},
|
||||
"given": {
|
||||
"title": "Qui peut ouvrir mon dossier",
|
||||
@@ -9474,6 +9611,7 @@
|
||||
"accept": "Accepter",
|
||||
"accepting": "Acceptation…",
|
||||
"writeConsent": "Si vous acceptez, ce que vous ajoutez fait partie du dossier de {name}. Vous ne pourrez plus modifier ni supprimer vos entrées ensuite ; {name} est la seule personne à pouvoir le faire.",
|
||||
"manageConsent": "Si vous acceptez, vous pourrez compléter le dossier de {name} mais aussi modifier ou supprimer ce qui s'y trouve déjà, y compris les entrées de {name}. Tout ce que vous faites apparaît à votre nom dans son journal d'activité, et {name} peut mettre fin à votre accès à tout moment.",
|
||||
"open": "Ouvrir",
|
||||
"renounce": "Rendre",
|
||||
"renounceTitle": "Rendre le dossier de {name} ?",
|
||||
@@ -9489,7 +9627,8 @@
|
||||
"loadError": "L'activité n'a pas pu être chargée.",
|
||||
"opened": "{name} a ouvert votre dossier {count}×",
|
||||
"acted": "{name} a fait une modification dans votre dossier",
|
||||
"deletedAccount": "Un compte supprimé"
|
||||
"deletedAccount": "Un compte supprimé",
|
||||
"truncated": "Cette liste montre l'activité la plus récente, pas la totalité."
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées."
|
||||
@@ -9499,8 +9638,65 @@
|
||||
"description": "Les dossiers que d'autres personnes partagent avec vous.",
|
||||
"levelRead": "Peut consulter",
|
||||
"levelWrite": "Peut consulter et ajouter",
|
||||
"kindShared": "Dossier partagé",
|
||||
"kindManaged": "Profil géré",
|
||||
"open": "Ouvrir le dossier de {name}"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profils dont vous vous occupez",
|
||||
"description": "Dossiers de santé sans identifiants propres.",
|
||||
"explainer": "Un profil géré est un dossier de santé pour quelqu'un qui ne se connecte pas lui-même — un enfant, ou une personne dont vous vous occupez. Il n'a ni identifiants ni adresse e-mail, et vous en devenez la première personne responsable. Vous pourrez en ajouter d'autres plus tard, et chacune pourra entrer dans le dossier pour le tenir à jour.",
|
||||
"nameLabel": "Nom",
|
||||
"namePlaceholder": "Le nom sous lequel vous reconnaîtrez ce dossier",
|
||||
"nameHint": "Jusqu'à 80 caractères. Affiché partout où ce dossier apparaît.",
|
||||
"nameRequired": "Donnez un nom au dossier.",
|
||||
"nameTooLong": "Ce nom dépasse 80 caractères.",
|
||||
"dobLabel": "Date de naissance (facultatif)",
|
||||
"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.",
|
||||
"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",
|
||||
"creating": "Création…",
|
||||
"created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"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éé.",
|
||||
"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.",
|
||||
"deleteGuardiansOne": "{count} personne s'occupe aujourd'hui de ce dossier et le perdra.",
|
||||
"deleteGuardiansFew": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.",
|
||||
"deleteGuardiansOther": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.",
|
||||
"deleteFloorNote": "C'est aussi l'issue lorsque vous êtes la dernière personne responsable : vous pouvez mettre fin au dossier, mais pas le laisser sans personne.",
|
||||
"deleteConfirm": "Supprimer le profil",
|
||||
"guardiansTitle": "Qui s'occupe de {name}",
|
||||
"guardianYou": "(vous)",
|
||||
"guardianSince": "S'occupe de ce dossier depuis le {date}",
|
||||
"guardianPending": "Invitée le {date}, pas encore acceptée",
|
||||
"guardianRemove": "Mettre fin à l'accès",
|
||||
"guardianRemoveTitle": "Mettre fin à l'accès de {name} ?",
|
||||
"guardianRemoveBody": "{name} ne pourra plus ouvrir {profile}. Ce qu'elle a saisi reste dans le dossier.",
|
||||
"guardianWithdrawBody": "L'invitation adressée à {name} est retirée. Cette personne n'a jamais ouvert {profile}, rien ne change dans le dossier.",
|
||||
"guardianRemoveConfirm": "Mettre fin à l'accès",
|
||||
"soleGuardian": "Vous êtes la seule personne à vous occuper de {name} : il n'y a donc ici aucun accès auquel mettre fin. Invitez quelqu'un et attendez son acceptation, rendez votre propre accès sous « Dossiers que je peux ouvrir », ou supprimez le profil.",
|
||||
"guardianInviteLabel": "Inviter une autre personne responsable",
|
||||
"guardianInvite": "Envoyer l'invitation",
|
||||
"guardianInviting": "Envoi…",
|
||||
"guardianInvited": "Invitation envoyée à {name}.",
|
||||
"guardianExpiresHint": "Facultatif. L'accès prend fin à la fin de cette journée dans votre fuseau horaire.",
|
||||
"guardianPendingNote": "Une personne invitée ne compte qu'une fois qu'elle a accepté. D'ici là vous êtes toujours la dernière, et rendre votre propre accès reste refusé.",
|
||||
"guardianErrorDuplicate": "Ce compte a déjà accès à ce dossier.",
|
||||
"guardianErrorManagedInvitee": "Un profil géré ne peut pas s'occuper d'un autre dossier. Invitez un compte qui se connecte.",
|
||||
"guardianErrorNoAccount": "Aucun compte de cette instance ne porte ce nom ou cette adresse e-mail.",
|
||||
"guardianErrorRateLimit": "Trop d'invitations dans la dernière heure. Patientez un peu et réessayez.",
|
||||
"guardianErrorFailed": "L'invitation n'a pas pu être envoyée.",
|
||||
"errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.",
|
||||
"rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.",
|
||||
"errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Enregistré dans le dossier de {name}."
|
||||
},
|
||||
@@ -9515,7 +9711,143 @@
|
||||
"medicationSideEffectCreate": "{name} a noté un effet indésirable",
|
||||
"medicationCreate": "{name} a ajouté un médicament",
|
||||
"medicationIntake": "{name} a enregistré une prise",
|
||||
"medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé"
|
||||
"medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé",
|
||||
"measurementUpdate": "{name} a modifié une mesure",
|
||||
"measurementDelete": "{name} a supprimé une mesure",
|
||||
"measurementDeleteBulk": "{name} a supprimé plusieurs mesures d'un coup",
|
||||
"measurementRestore": "{name} a restauré une mesure",
|
||||
"measurementReminderCreate": "{name} a ajouté un rappel de mesure",
|
||||
"measurementReminderUpdate": "{name} a modifié un rappel de mesure",
|
||||
"measurementReminderDelete": "{name} a supprimé un rappel de mesure",
|
||||
"measurementReminderComplete": "{name} a terminé un rappel de mesure",
|
||||
"measurementReminderSatisfy": "{name} a clôturé un rappel de mesure",
|
||||
"labResultUpdate": "{name} a modifié un résultat de laboratoire",
|
||||
"labResultDelete": "{name} a supprimé un résultat de laboratoire",
|
||||
"labResultRestore": "{name} a restauré un résultat de laboratoire",
|
||||
"biomarkerUpdate": "{name} a modifié un biomarqueur",
|
||||
"biomarkerDelete": "{name} a supprimé un biomarqueur",
|
||||
"allergyUpdate": "{name} a modifié une allergie",
|
||||
"allergyDelete": "{name} a supprimé une allergie",
|
||||
"familyHistoryUpdate": "{name} a modifié un antécédent familial",
|
||||
"familyHistoryDelete": "{name} a supprimé un antécédent familial",
|
||||
"illnessEpisodeUpdate": "{name} a modifié une maladie",
|
||||
"illnessEpisodeDelete": "{name} a supprimé une maladie",
|
||||
"illnessEpisodeResolve": "{name} a marqué une maladie comme terminée",
|
||||
"illnessEpisodeRestore": "{name} a restauré une maladie",
|
||||
"illnessDayLogUpsert": "{name} a enregistré une journée de maladie",
|
||||
"medicationUpdate": "{name} a modifié un médicament",
|
||||
"medicationDelete": "{name} a supprimé un médicament",
|
||||
"medicationGlp1Update": "{name} a modifié un traitement GLP-1",
|
||||
"medicationScheduleCreate": "{name} a ajouté un schéma de prise",
|
||||
"medicationScheduleUpdate": "{name} a modifié un schéma de prise",
|
||||
"medicationScheduleDelete": "{name} a supprimé un schéma de prise",
|
||||
"medicationInventoryCreate": "{name} a ajouté une boîte de médicament",
|
||||
"medicationInventoryUpdate": "{name} a modifié une boîte de médicament",
|
||||
"medicationInventoryDelete": "{name} a supprimé une boîte de médicament",
|
||||
"medicationSideEffectDelete": "{name} a supprimé un effet indésirable",
|
||||
"medicationIntakeUpdate": "{name} a modifié une prise",
|
||||
"medicationIntakeDelete": "{name} a supprimé une prise",
|
||||
"medicationIntakeBulkDelete": "{name} a supprimé plusieurs prises d'un coup",
|
||||
"medicationIntakePurge": "{name} a effacé tout l'historique des prises d'un médicament",
|
||||
"medicationIntakeImport": "{name} a lancé un import des prises d'un médicament",
|
||||
"medicationIntakeHistoryImport": "{name} a lancé un import de l'historique des prises",
|
||||
"moodEntryCreate": "{name} a ajouté une entrée d'humeur",
|
||||
"moodEntryUpdate": "{name} a modifié une entrée d'humeur",
|
||||
"moodEntryDelete": "{name} a supprimé une entrée d'humeur",
|
||||
"moodDeleteBulk": "{name} a supprimé plusieurs entrées d'humeur d'un coup",
|
||||
"moodRestore": "{name} a restauré une entrée d'humeur",
|
||||
"mentalHealthCreate": "{name} a enregistré le résultat d'un questionnaire",
|
||||
"cycleDelete": "{name} a supprimé un cycle",
|
||||
"cycleDayLogUpsert": "{name} a enregistré un jour du cycle",
|
||||
"cycleDayLogUpdate": "{name} a modifié un jour du cycle",
|
||||
"cycleDayLogDelete": "{name} a supprimé un jour du cycle",
|
||||
"cyclePeriodBoundary": "{name} a modifié le début ou la fin de règles",
|
||||
"cycleSymptomCreate": "{name} a ajouté un symptôme de cycle",
|
||||
"nutrientWaterWrite": "{name} a enregistré la consommation d'eau",
|
||||
"healthRecordExport": "{name} a exporté le dossier",
|
||||
"rejectedMeasurementQuery": "{name} a envoyé une recherche de mesures que ce dossier n'a pas pu lire",
|
||||
"rejectedDoseQuery": "{name} a envoyé une recherche de prises que ce dossier n'a pas pu lire",
|
||||
"rejectedMoodQuery": "{name} a envoyé une recherche d'humeur que ce dossier n'a pas pu lire",
|
||||
"rejectedMeasurementCreate": "{name} a tenté d'ajouter une mesure et a été refusé",
|
||||
"rejectedMeasurementBatch": "{name} a tenté d'ajouter plusieurs mesures et a été refusé",
|
||||
"rejectedMeasurementUpdate": "{name} a tenté de modifier une mesure et a été refusé",
|
||||
"rejectedLabCreate": "{name} a tenté d'ajouter un résultat de laboratoire et a été refusé",
|
||||
"rejectedLabUpdate": "{name} a tenté de modifier un résultat de laboratoire et a été refusé",
|
||||
"rejectedBiomarkerCreate": "{name} a tenté d'ajouter un biomarqueur et a été refusé",
|
||||
"rejectedBiomarkerUpdate": "{name} a tenté de modifier un biomarqueur et a été refusé",
|
||||
"rejectedReminderCreate": "{name} a tenté d'ajouter un rappel de mesure et a été refusé",
|
||||
"rejectedReminderUpdate": "{name} a tenté de modifier un rappel de mesure et a été refusé",
|
||||
"rejectedMoodCreate": "{name} a tenté d'ajouter une entrée d'humeur et a été refusé",
|
||||
"rejectedMoodUpdate": "{name} a tenté de modifier une entrée d'humeur et a été refusé",
|
||||
"rejectedDoseCreate": "{name} a tenté d'enregistrer une prise et a été refusé",
|
||||
"rejectedDoseUpdate": "{name} a tenté de modifier une prise et a été refusé",
|
||||
"rejectedDoseImport": "{name} a tenté d'importer des prises et a été refusé",
|
||||
"rejectedTrackedValue": "{name} a tenté d'enregistrer une valeur personnalisée et a été refusé",
|
||||
"deniedDoseImport": "Un import de prises lancé par {name} n'a pas pu être mis en file",
|
||||
"anamnesisFactCreate": "{name} a ajouté un élément d'antécédents de santé",
|
||||
"customMetricCreate": "{name} a ajouté un type de valeur personnalisée",
|
||||
"customMetricUpdate": "{name} a modifié un type de valeur personnalisée",
|
||||
"customMetricDelete": "{name} a supprimé un type de valeur personnalisée",
|
||||
"documentStore": "{name} a ajouté un document",
|
||||
"documentUpdate": "{name} a modifié un document",
|
||||
"documentDiscard": "{name} a écarté un document",
|
||||
"medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé",
|
||||
"medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
"label": "Mesures",
|
||||
"detail": "Poids, tension artérielle, pouls, glycémie, sommeil, séances et les valeurs que vous suivez."
|
||||
},
|
||||
"medications": {
|
||||
"label": "Médicaments",
|
||||
"detail": "La liste des médicaments, leurs horaires, les prises, les effets indésirables et les stocks."
|
||||
},
|
||||
"labs": {
|
||||
"label": "Résultats de laboratoire",
|
||||
"detail": "Les résultats de laboratoire et la liste des analytes correspondants."
|
||||
},
|
||||
"profile": {
|
||||
"label": "Antécédents de santé",
|
||||
"detail": "Allergies, antécédents familiaux et les informations de votre profil de santé."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Maladies",
|
||||
"detail": "Les épisodes de maladie et les journaux quotidiens qui s'y rattachent."
|
||||
},
|
||||
"mind": {
|
||||
"label": "Humeur et santé mentale",
|
||||
"detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cycle",
|
||||
"detail": "Le suivi du cycle."
|
||||
},
|
||||
"documents": {
|
||||
"label": "Documents",
|
||||
"detail": "Tout ce qui est conservé dans le coffre à documents."
|
||||
}
|
||||
},
|
||||
"actionError": {
|
||||
"retry": "Réessayer",
|
||||
"offline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.",
|
||||
"rateLimited": "Trop de tentatives d'affilée. Attendez une minute et réessayez.",
|
||||
"acceptExpired": "Cette invitation a expiré. Demandez-en une nouvelle.",
|
||||
"acceptWithdrawn": "Cette invitation a été retirée.",
|
||||
"acceptNotPending": "Cette invitation a déjà été acceptée.",
|
||||
"acceptNotFound": "Cette invitation n'existe plus.",
|
||||
"acceptFailed": "L'invitation n'a pas pu être acceptée.",
|
||||
"lastGuardian": "Cette personne est la seule à s'occuper de ce profil, donc cet accès ne peut pas encore prendre fin. Ajoutez d'abord une autre personne responsable, ou supprimez le profil.",
|
||||
"alreadyEnded": "Cet accès a déjà pris fin.",
|
||||
"notFound": "Cet accès n'existe plus.",
|
||||
"revokeFailed": "L'accès n'a pas pu être terminé.",
|
||||
"renounceFailed": "L'accès n'a pas pu être rendu.",
|
||||
"profileNotFound": "Ce profil n'existe plus — il a peut-être été supprimé, ou votre accès a pris fin.",
|
||||
"deleteProfileFailed": "Le profil n'a pas pu être supprimé.",
|
||||
"removeGuardianFailed": "Cet accès n'a pas pu prendre fin."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici."
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
-4
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami",
|
||||
"profile": "Profilo",
|
||||
"vorsorge": "Controlli",
|
||||
"illness": "Malattia",
|
||||
"documents": "Documenti",
|
||||
@@ -4433,6 +4434,121 @@
|
||||
"system": "Sistema"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profilo",
|
||||
"displayName": "Nome visualizzato",
|
||||
"height": "Altezza (cm)",
|
||||
"dateOfBirth": "Data di nascita",
|
||||
"gender": "Genere",
|
||||
"notSpecified": "Non specificato",
|
||||
"male": "Uomo",
|
||||
"female": "Donna",
|
||||
"other": "Altro",
|
||||
"locale": "Lingua",
|
||||
"systemDefault": "Predefinito di sistema",
|
||||
"timezone": "Fuso orario",
|
||||
"units": "Unità",
|
||||
"metric": "Metrico",
|
||||
"imperial": "Imperiale",
|
||||
"timeFormat": "Formato ora",
|
||||
"automatic": "Automatico",
|
||||
"hour12": "12 ore",
|
||||
"hour24": "24 ore",
|
||||
"dateFormat": "Formato data",
|
||||
"dateDmy": "Giorno / mese / anno",
|
||||
"dateMdy": "Mese / giorno / anno",
|
||||
"dateYmd": "Anno / mese / giorno"
|
||||
},
|
||||
"modules": {
|
||||
"title": "Moduli"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"moodReminder": "Attiva promemoria dell’umore",
|
||||
"reminderHour": "Ora del promemoria dell’umore",
|
||||
"lowStockRunwayDays": "Giorni prima delle scorte basse",
|
||||
"reorderLeadDays": "Margine per il riordino"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Obiettivi",
|
||||
"metric": "Metrica",
|
||||
"minimum": "Minimo",
|
||||
"maximum": "Massimo"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"disable": "Disattiva Coach",
|
||||
"tone": "Tono",
|
||||
"warm": "Caldo",
|
||||
"neutral": "Neutro",
|
||||
"concise": "Conciso",
|
||||
"verbosity": "Livello di dettaglio",
|
||||
"brief": "Breve",
|
||||
"default": "Predefinito",
|
||||
"detailed": "Dettagliato",
|
||||
"defaultWindow": "Periodo predefinito",
|
||||
"last7days": "Ultimi 7 giorni",
|
||||
"last30days": "Ultimi 30 giorni",
|
||||
"last90days": "Ultimi 90 giorni",
|
||||
"allTime": "Tutto il periodo",
|
||||
"showEvidence": "Mostra le fonti per impostazione predefinita",
|
||||
"excludedMetrics": "Metriche escluse",
|
||||
"dataClusters": "Gruppi di dati",
|
||||
"customClusters": "Usa una selezione personalizzata di gruppi",
|
||||
"metrics": {
|
||||
"bp": "Pressione arteriosa",
|
||||
"weight": "Peso",
|
||||
"pulse": "Polso",
|
||||
"mood": "Umore",
|
||||
"compliance": "Aderenza ai farmaci",
|
||||
"hrv": "Variabilità della frequenza cardiaca",
|
||||
"sleep": "Sonno",
|
||||
"restingHr": "Frequenza cardiaca a riposo",
|
||||
"steps": "Passi",
|
||||
"medications": "Farmaci",
|
||||
"anthropometrics": "Misure corporee"
|
||||
},
|
||||
"clusters": {
|
||||
"cardio": "Salute cardiovascolare",
|
||||
"body": "Composizione corporea",
|
||||
"activity": "Attività",
|
||||
"workouts": "Allenamenti",
|
||||
"sleep": "Sonno",
|
||||
"mood": "Umore",
|
||||
"glucose": "Glucosio",
|
||||
"medication": "Farmaci",
|
||||
"mobility": "Mobilità",
|
||||
"environment": "Ambiente"
|
||||
}
|
||||
},
|
||||
"insights": {
|
||||
"title": "Analisi",
|
||||
"sections": "Sezioni della panoramica",
|
||||
"tiles": "Riquadri di navigazione",
|
||||
"order": "Ordine",
|
||||
"unknownItem": "Analisi non disponibile",
|
||||
"sectionLabels": {
|
||||
"wellnessScores": "Punteggi di benessere",
|
||||
"dailyBriefing": "Riepilogo giornaliero",
|
||||
"vitals": "Parametri vitali",
|
||||
"trends": "Tendenze",
|
||||
"periodReview": "Riepilogo del periodo",
|
||||
"cycleSummary": "Riepilogo del ciclo",
|
||||
"signals": "Segnali",
|
||||
"rhythmEvents": "Eventi del ritmo",
|
||||
"healthStatus": "Stato di salute",
|
||||
"breathing": "Respirazione",
|
||||
"labsChanges": "Variazioni di laboratorio",
|
||||
"ecg": "ECG"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Non disponibile in questo record condiviso",
|
||||
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
|
||||
"integrationStatusDescription": "Stato di connessione per questo record gestito. I controlli di connessione non sono disponibili."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
@@ -9406,7 +9522,7 @@
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Non fa parte dell'accesso condiviso",
|
||||
"description": "La condivisione riguarda la cartella sanitaria in sé. Impostazioni, servizi collegati, notifiche e funzioni di IA restano alla persona proprietaria dell'account.",
|
||||
"description": "La condivisione riguarda la cartella sanitaria in sé, compresa l'anamnesi. Le impostazioni dell'account, i servizi collegati, le notifiche e le funzioni di IA restano alla persona proprietaria dell'account.",
|
||||
"leave": "Torna alla mia cartella"
|
||||
},
|
||||
"state": {
|
||||
@@ -9418,6 +9534,10 @@
|
||||
"row": {
|
||||
"readOnly": "Può leggere la cartella",
|
||||
"canWrite": "Può leggere e aggiungere voci",
|
||||
"canManage": "Può leggere la cartella, aggiungere voci e modificare o eliminare ciò che contiene",
|
||||
"entireRecord": "Copre l'intera cartella",
|
||||
"sections": "Copre: {sections}",
|
||||
"noSections": "Non copre nulla: questo accesso non apre alcuna parte della cartella",
|
||||
"invited": "Invitato il",
|
||||
"accepted": "Accettato il",
|
||||
"expires": "Scade il",
|
||||
@@ -9439,6 +9559,19 @@
|
||||
"accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.",
|
||||
"accessWriteLabel": "Può vedere e aggiungere",
|
||||
"accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.",
|
||||
"accessManageLabel": "Può gestire",
|
||||
"accessManageCapability": "Può aggiungere, modificare ed eliminare voci della tua cartella, comprese quelle inserite da te, e può leggere le analisi generate a partire da essa. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.",
|
||||
"manageStepUpNote": "Per offrirlo viene richiesto il tuo secondo fattore, quindi si può fare solo da browser.",
|
||||
"scopeLegend": "Che cosa può aprire",
|
||||
"scopeAllLabel": "Intera cartella",
|
||||
"scopeAllCapability": "Ogni sua parte, come la condivisione ha sempre funzionato.",
|
||||
"scopeSomeLabel": "Solo le sezioni che scegli",
|
||||
"scopeSomeCapability": "Tutto ciò che non scegli resta chiuso per lei.",
|
||||
"scopeManageNote": "L'accesso di gestione copre sempre l'intera cartella.",
|
||||
"scopeLeakNote": "Una sezione può menzionare il resto della tua salute nelle note e nei nomi. Condividere una sezione significa condividere tutto ciò che vi è scritto.",
|
||||
"scopeAggregateNote": "La panoramica della dashboard, il punteggio di salute e il riepilogo giornaliero compaiono solo se viene condivisa l'intera cartella.",
|
||||
"scopeDocumentsNote": "Un documento può contenere qualsiasi cosa. Condividi questa sezione solo se quella persona può leggere tutto.",
|
||||
"scopeEmptyHint": "Scegli almeno una sezione oppure torna a condividere l'intera cartella.",
|
||||
"expiresLabel": "L'accesso termina il (facoltativo)",
|
||||
"expiresHint": "Lascia vuoto per un accesso che dura finché qualcuno non lo termina. Una data qui viene controllata a ogni richiesta, quindi l'accesso si interrompe da solo alla fine di quel giorno.",
|
||||
"submit": "Invia invito",
|
||||
@@ -9449,7 +9582,11 @@
|
||||
"errorSelf": "Questo è il tuo account.",
|
||||
"errorInvalid": "Inserisci un nome utente o un indirizzo e-mail.",
|
||||
"errorRateLimited": "Troppi inviti. Riprova più tardi.",
|
||||
"errorFailed": "Non è stato possibile inviare l'invito. Riprova."
|
||||
"errorFailed": "Non è stato possibile inviare l'invito. Riprova.",
|
||||
"errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.",
|
||||
"errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser, perché richiede il tuo secondo fattore.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova."
|
||||
},
|
||||
"given": {
|
||||
"title": "Chi può aprire la mia cartella",
|
||||
@@ -9474,6 +9611,7 @@
|
||||
"accept": "Accetta",
|
||||
"accepting": "Accettazione…",
|
||||
"writeConsent": "Se accetti, ciò che aggiungi entra a far parte della cartella di {name}. Dopo non potrai più modificare o rimuovere le voci; può farlo solo {name}.",
|
||||
"manageConsent": "Se accetti, potrai aggiungere voci alla cartella di {name} e anche modificare o eliminare quelle già presenti, comprese quelle inserite da {name}. Tutto ciò che fai viene registrato con il tuo nome nella sua vista attività, e {name} può terminare il tuo accesso in qualsiasi momento.",
|
||||
"open": "Apri",
|
||||
"renounce": "Restituisci",
|
||||
"renounceTitle": "Restituire la cartella di {name}?",
|
||||
@@ -9489,7 +9627,8 @@
|
||||
"loadError": "Non è stato possibile caricare l'attività.",
|
||||
"opened": "{name} ha aperto la tua cartella {count}×",
|
||||
"acted": "{name} ha fatto una modifica nella tua cartella",
|
||||
"deletedAccount": "Un account eliminato"
|
||||
"deletedAccount": "Un account eliminato",
|
||||
"truncated": "Questo elenco mostra l'attività più recente, non tutta."
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle."
|
||||
@@ -9499,8 +9638,65 @@
|
||||
"description": "Cartelle che altre persone hanno condiviso con te.",
|
||||
"levelRead": "Può consultare",
|
||||
"levelWrite": "Può consultare e aggiungere",
|
||||
"kindShared": "Cartella condivisa",
|
||||
"kindManaged": "Profilo gestito",
|
||||
"open": "Apri la cartella di {name}"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profili di cui ti occupi",
|
||||
"description": "Cartelle sanitarie senza credenziali proprie.",
|
||||
"explainer": "Un profilo gestito è una cartella sanitaria per qualcuno che non accede da sé — un bambino, o una persona di cui ti prendi cura. Non ha credenziali né indirizzo e-mail, e tu ne diventi la prima persona responsabile. Più avanti puoi aggiungerne altre, e ognuna può entrare nella cartella per tenerla aggiornata.",
|
||||
"nameLabel": "Nome",
|
||||
"namePlaceholder": "Il nome con cui riconoscerai questa cartella",
|
||||
"nameHint": "Fino a 80 caratteri. Compare ovunque appaia questa cartella.",
|
||||
"nameRequired": "Dai un nome alla cartella.",
|
||||
"nameTooLong": "Questo nome supera gli 80 caratteri.",
|
||||
"dobLabel": "Data di nascita (facoltativa)",
|
||||
"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.",
|
||||
"timezoneHint": "Il fuso in cui si contano le giornate di questa cartella.",
|
||||
"timezoneInvalid": "Questo browser non conosce quel fuso orario.",
|
||||
"create": "Crea profilo",
|
||||
"creating": "Creazione in corso…",
|
||||
"created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"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.",
|
||||
"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.",
|
||||
"deleteGuardiansOne": "{count} persona si occupa oggi di questa cartella e la perderà.",
|
||||
"deleteGuardiansFew": "{count} persone si occupano oggi di questa cartella e la perderanno.",
|
||||
"deleteGuardiansOther": "{count} persone si occupano oggi di questa cartella e la perderanno.",
|
||||
"deleteFloorNote": "È anche la via d'uscita quando sei l'ultima persona responsabile: puoi chiudere la cartella, ma non lasciarla senza nessuno.",
|
||||
"deleteConfirm": "Elimina profilo",
|
||||
"guardiansTitle": "Chi si occupa di {name}",
|
||||
"guardianYou": "(tu)",
|
||||
"guardianSince": "Si occupa di questa cartella dal {date}",
|
||||
"guardianPending": "Invitata il {date}, non ancora accettata",
|
||||
"guardianRemove": "Termina accesso",
|
||||
"guardianRemoveTitle": "Terminare l'accesso di {name}?",
|
||||
"guardianRemoveBody": "{name} non potrà più aprire {profile}. Quello che ha inserito resta nella cartella.",
|
||||
"guardianWithdrawBody": "L'invito a {name} viene ritirato. Non ha mai aperto {profile}, quindi nella cartella non cambia nulla.",
|
||||
"guardianRemoveConfirm": "Termina accesso",
|
||||
"soleGuardian": "Sei l'unica persona che si occupa di {name}, quindi qui non c'è nessun accesso da terminare. Invita qualcuno e aspetta che accetti, restituisci il tuo accesso in «Cartelle che posso aprire», oppure elimina il profilo.",
|
||||
"guardianInviteLabel": "Invita un'altra persona responsabile",
|
||||
"guardianInvite": "Invia invito",
|
||||
"guardianInviting": "Invio in corso…",
|
||||
"guardianInvited": "Invito inviato a {name}.",
|
||||
"guardianExpiresHint": "Facoltativo. L'accesso termina alla fine di quel giorno nel tuo fuso orario.",
|
||||
"guardianPendingNote": "Una persona invitata conta solo quando accetta. Fino ad allora sei ancora l'ultima, e restituire il tuo accesso viene comunque rifiutato.",
|
||||
"guardianErrorDuplicate": "Quell'account ha già accesso a questa cartella.",
|
||||
"guardianErrorManagedInvitee": "Un profilo gestito non può occuparsi di un'altra cartella. Invita un account che accede da sé.",
|
||||
"guardianErrorNoAccount": "Su questa istanza non esiste un account con quel nome o quella e-mail.",
|
||||
"guardianErrorRateLimit": "Troppi inviti nell'ultima ora. Aspetta un po' e riprova.",
|
||||
"guardianErrorFailed": "Non è stato possibile inviare l'invito.",
|
||||
"errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.",
|
||||
"rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.",
|
||||
"errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Salvato nella cartella di {name}."
|
||||
},
|
||||
@@ -9515,7 +9711,143 @@
|
||||
"medicationSideEffectCreate": "{name} ha annotato un effetto collaterale",
|
||||
"medicationCreate": "{name} ha aggiunto un farmaco",
|
||||
"medicationIntake": "{name} ha registrato una dose",
|
||||
"medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato"
|
||||
"medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato",
|
||||
"measurementUpdate": "{name} ha modificato una misurazione",
|
||||
"measurementDelete": "{name} ha eliminato una misurazione",
|
||||
"measurementDeleteBulk": "{name} ha eliminato più misurazioni in una volta",
|
||||
"measurementRestore": "{name} ha ripristinato una misurazione",
|
||||
"measurementReminderCreate": "{name} ha aggiunto un promemoria di misurazione",
|
||||
"measurementReminderUpdate": "{name} ha modificato un promemoria di misurazione",
|
||||
"measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione",
|
||||
"measurementReminderComplete": "{name} ha completato un promemoria di misurazione",
|
||||
"measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione",
|
||||
"labResultUpdate": "{name} ha modificato un risultato di laboratorio",
|
||||
"labResultDelete": "{name} ha eliminato un risultato di laboratorio",
|
||||
"labResultRestore": "{name} ha ripristinato un risultato di laboratorio",
|
||||
"biomarkerUpdate": "{name} ha modificato un biomarcatore",
|
||||
"biomarkerDelete": "{name} ha eliminato un biomarcatore",
|
||||
"allergyUpdate": "{name} ha modificato un'allergia",
|
||||
"allergyDelete": "{name} ha eliminato un'allergia",
|
||||
"familyHistoryUpdate": "{name} ha modificato una voce di anamnesi familiare",
|
||||
"familyHistoryDelete": "{name} ha eliminato una voce di anamnesi familiare",
|
||||
"illnessEpisodeUpdate": "{name} ha modificato una malattia",
|
||||
"illnessEpisodeDelete": "{name} ha eliminato una malattia",
|
||||
"illnessEpisodeResolve": "{name} ha segnato una malattia come conclusa",
|
||||
"illnessEpisodeRestore": "{name} ha ripristinato una malattia",
|
||||
"illnessDayLogUpsert": "{name} ha registrato una giornata di malattia",
|
||||
"medicationUpdate": "{name} ha modificato un farmaco",
|
||||
"medicationDelete": "{name} ha eliminato un farmaco",
|
||||
"medicationGlp1Update": "{name} ha modificato una terapia GLP-1",
|
||||
"medicationScheduleCreate": "{name} ha aggiunto uno schema di assunzione",
|
||||
"medicationScheduleUpdate": "{name} ha modificato uno schema di assunzione",
|
||||
"medicationScheduleDelete": "{name} ha eliminato uno schema di assunzione",
|
||||
"medicationInventoryCreate": "{name} ha aggiunto una confezione di farmaco",
|
||||
"medicationInventoryUpdate": "{name} ha modificato una confezione di farmaco",
|
||||
"medicationInventoryDelete": "{name} ha eliminato una confezione di farmaco",
|
||||
"medicationSideEffectDelete": "{name} ha eliminato un effetto collaterale",
|
||||
"medicationIntakeUpdate": "{name} ha modificato una dose",
|
||||
"medicationIntakeDelete": "{name} ha eliminato una dose",
|
||||
"medicationIntakeBulkDelete": "{name} ha eliminato più dosi in una volta",
|
||||
"medicationIntakePurge": "{name} ha cancellato tutto lo storico delle dosi di un farmaco",
|
||||
"medicationIntakeImport": "{name} ha avviato un'importazione delle dosi di un farmaco",
|
||||
"medicationIntakeHistoryImport": "{name} ha avviato un'importazione dello storico delle dosi",
|
||||
"moodEntryCreate": "{name} ha aggiunto una voce dell'umore",
|
||||
"moodEntryUpdate": "{name} ha modificato una voce dell'umore",
|
||||
"moodEntryDelete": "{name} ha eliminato una voce dell'umore",
|
||||
"moodDeleteBulk": "{name} ha eliminato più voci dell'umore in una volta",
|
||||
"moodRestore": "{name} ha ripristinato una voce dell'umore",
|
||||
"mentalHealthCreate": "{name} ha registrato il risultato di un questionario",
|
||||
"cycleDelete": "{name} ha eliminato un ciclo",
|
||||
"cycleDayLogUpsert": "{name} ha registrato un giorno del ciclo",
|
||||
"cycleDayLogUpdate": "{name} ha modificato un giorno del ciclo",
|
||||
"cycleDayLogDelete": "{name} ha eliminato un giorno del ciclo",
|
||||
"cyclePeriodBoundary": "{name} ha modificato l'inizio o la fine di una mestruazione",
|
||||
"cycleSymptomCreate": "{name} ha aggiunto un sintomo del ciclo",
|
||||
"nutrientWaterWrite": "{name} ha registrato l'assunzione di acqua",
|
||||
"healthRecordExport": "{name} ha esportato la cartella",
|
||||
"rejectedMeasurementQuery": "{name} ha inviato una ricerca di misurazioni che questa cartella non ha potuto leggere",
|
||||
"rejectedDoseQuery": "{name} ha inviato una ricerca di dosi che questa cartella non ha potuto leggere",
|
||||
"rejectedMoodQuery": "{name} ha inviato una ricerca dell'umore che questa cartella non ha potuto leggere",
|
||||
"rejectedMeasurementCreate": "{name} ha provato ad aggiungere una misurazione ed è stato rifiutato",
|
||||
"rejectedMeasurementBatch": "{name} ha provato ad aggiungere più misurazioni ed è stato rifiutato",
|
||||
"rejectedMeasurementUpdate": "{name} ha provato a modificare una misurazione ed è stato rifiutato",
|
||||
"rejectedLabCreate": "{name} ha provato ad aggiungere un risultato di laboratorio ed è stato rifiutato",
|
||||
"rejectedLabUpdate": "{name} ha provato a modificare un risultato di laboratorio ed è stato rifiutato",
|
||||
"rejectedBiomarkerCreate": "{name} ha provato ad aggiungere un biomarcatore ed è stato rifiutato",
|
||||
"rejectedBiomarkerUpdate": "{name} ha provato a modificare un biomarcatore ed è stato rifiutato",
|
||||
"rejectedReminderCreate": "{name} ha provato ad aggiungere un promemoria di misurazione ed è stato rifiutato",
|
||||
"rejectedReminderUpdate": "{name} ha provato a modificare un promemoria di misurazione ed è stato rifiutato",
|
||||
"rejectedMoodCreate": "{name} ha provato ad aggiungere una voce dell'umore ed è stato rifiutato",
|
||||
"rejectedMoodUpdate": "{name} ha provato a modificare una voce dell'umore ed è stato rifiutato",
|
||||
"rejectedDoseCreate": "{name} ha provato a registrare una dose ed è stato rifiutato",
|
||||
"rejectedDoseUpdate": "{name} ha provato a modificare una dose ed è stato rifiutato",
|
||||
"rejectedDoseImport": "{name} ha provato a importare delle dosi ed è stato rifiutato",
|
||||
"rejectedTrackedValue": "{name} ha provato a registrare un valore personalizzato ed è stato rifiutato",
|
||||
"deniedDoseImport": "Un'importazione di dosi avviata da {name} non è stata messa in coda",
|
||||
"anamnesisFactCreate": "{name} ha aggiunto un dato di anamnesi",
|
||||
"customMetricCreate": "{name} ha aggiunto un tipo di valore personalizzato",
|
||||
"customMetricUpdate": "{name} ha modificato un tipo di valore personalizzato",
|
||||
"customMetricDelete": "{name} ha eliminato un tipo di valore personalizzato",
|
||||
"documentStore": "{name} ha aggiunto un documento",
|
||||
"documentUpdate": "{name} ha modificato un documento",
|
||||
"documentDiscard": "{name} ha scartato un documento",
|
||||
"medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata",
|
||||
"medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
"label": "Misurazioni",
|
||||
"detail": "Peso, pressione, polso, glicemia, sonno, allenamenti e i valori che monitori."
|
||||
},
|
||||
"medications": {
|
||||
"label": "Farmaci",
|
||||
"detail": "L'elenco dei farmaci, gli orari, le dosi assunte, gli effetti collaterali e le scorte."
|
||||
},
|
||||
"labs": {
|
||||
"label": "Esami di laboratorio",
|
||||
"detail": "I risultati di laboratorio e l'elenco degli analiti che li compongono."
|
||||
},
|
||||
"profile": {
|
||||
"label": "Anamnesi",
|
||||
"detail": "Allergie, storia familiare e i dati del tuo profilo di salute."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Malattie",
|
||||
"detail": "Gli episodi di malattia e i diari giornalieri collegati."
|
||||
},
|
||||
"mind": {
|
||||
"label": "Umore e benessere mentale",
|
||||
"detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Ciclo",
|
||||
"detail": "Il monitoraggio del ciclo."
|
||||
},
|
||||
"documents": {
|
||||
"label": "Documenti",
|
||||
"detail": "Tutto ciò che è conservato nell'archivio documenti."
|
||||
}
|
||||
},
|
||||
"actionError": {
|
||||
"retry": "Riprova",
|
||||
"offline": "La richiesta non ha raggiunto il server. Non è cambiato nulla: controlla la connessione e riprova.",
|
||||
"rateLimited": "Troppi tentativi di fila. Aspetta un minuto e riprova.",
|
||||
"acceptExpired": "Questo invito è scaduto. Chiedine uno nuovo.",
|
||||
"acceptWithdrawn": "Questo invito è stato ritirato.",
|
||||
"acceptNotPending": "Questo invito è già stato accettato.",
|
||||
"acceptNotFound": "Questo invito non esiste più.",
|
||||
"acceptFailed": "Non è stato possibile accettare l'invito.",
|
||||
"lastGuardian": "Questa è l'unica persona che si occupa di quel profilo, quindi questo accesso non può ancora finire. Aggiungi prima un'altra persona responsabile, oppure elimina il profilo.",
|
||||
"alreadyEnded": "Questo accesso è già terminato.",
|
||||
"notFound": "Questo accesso non esiste più.",
|
||||
"revokeFailed": "Non è stato possibile terminare l'accesso.",
|
||||
"renounceFailed": "Non è stato possibile restituire l'accesso.",
|
||||
"profileNotFound": "Quel profilo non c'è più — potrebbe essere già stato eliminato, o il tuo accesso è terminato.",
|
||||
"deleteProfileFailed": "Non è stato possibile eliminare il profilo.",
|
||||
"removeGuardianFailed": "Non è stato possibile terminare quell'accesso."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui."
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
-4
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Badania",
|
||||
"illness": "Choroba",
|
||||
"documents": "Dokumenty",
|
||||
@@ -4433,6 +4434,121 @@
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"displayName": "Wyświetlana nazwa",
|
||||
"height": "Wzrost (cm)",
|
||||
"dateOfBirth": "Data urodzenia",
|
||||
"gender": "Płeć",
|
||||
"notSpecified": "Nie podano",
|
||||
"male": "Mężczyzna",
|
||||
"female": "Kobieta",
|
||||
"other": "Inna",
|
||||
"locale": "Język",
|
||||
"systemDefault": "Ustawienie systemowe",
|
||||
"timezone": "Strefa czasowa",
|
||||
"units": "Jednostki",
|
||||
"metric": "Metryczne",
|
||||
"imperial": "Imperialne",
|
||||
"timeFormat": "Format czasu",
|
||||
"automatic": "Automatyczny",
|
||||
"hour12": "12-godzinny",
|
||||
"hour24": "24-godzinny",
|
||||
"dateFormat": "Format daty",
|
||||
"dateDmy": "Dzień / miesiąc / rok",
|
||||
"dateMdy": "Miesiąc / dzień / rok",
|
||||
"dateYmd": "Rok / miesiąc / dzień"
|
||||
},
|
||||
"modules": {
|
||||
"title": "Moduły"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"moodReminder": "Włącz przypomnienie nastroju",
|
||||
"reminderHour": "Godzina przypomnienia nastroju",
|
||||
"lowStockRunwayDays": "Dni do niskiego stanu zapasów",
|
||||
"reorderLeadDays": "Wyprzedzenie ponownego zamówienia"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Cele",
|
||||
"metric": "Metryka",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maksimum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Trener",
|
||||
"disable": "Wyłącz trenera",
|
||||
"tone": "Ton",
|
||||
"warm": "Ciepły",
|
||||
"neutral": "Neutralny",
|
||||
"concise": "Zwięzły",
|
||||
"verbosity": "Szczegółowość",
|
||||
"brief": "Krótka",
|
||||
"default": "Domyślna",
|
||||
"detailed": "Szczegółowa",
|
||||
"defaultWindow": "Domyślny okres",
|
||||
"last7days": "Ostatnie 7 dni",
|
||||
"last30days": "Ostatnie 30 dni",
|
||||
"last90days": "Ostatnie 90 dni",
|
||||
"allTime": "Cały okres",
|
||||
"showEvidence": "Domyślnie pokazuj źródła",
|
||||
"excludedMetrics": "Wykluczone metryki",
|
||||
"dataClusters": "Grupy danych",
|
||||
"customClusters": "Użyj własnego wyboru grup",
|
||||
"metrics": {
|
||||
"bp": "Ciśnienie krwi",
|
||||
"weight": "Masa ciała",
|
||||
"pulse": "Tętno",
|
||||
"mood": "Nastrój",
|
||||
"compliance": "Przestrzeganie przyjmowania leków",
|
||||
"hrv": "Zmienność rytmu serca",
|
||||
"sleep": "Sen",
|
||||
"restingHr": "Tętno spoczynkowe",
|
||||
"steps": "Kroki",
|
||||
"medications": "Leki",
|
||||
"anthropometrics": "Wymiary ciała"
|
||||
},
|
||||
"clusters": {
|
||||
"cardio": "Zdrowie sercowo-naczyniowe",
|
||||
"body": "Skład ciała",
|
||||
"activity": "Aktywność",
|
||||
"workouts": "Treningi",
|
||||
"sleep": "Sen",
|
||||
"mood": "Nastrój",
|
||||
"glucose": "Glukoza",
|
||||
"medication": "Leki",
|
||||
"mobility": "Mobilność",
|
||||
"environment": "Środowisko"
|
||||
}
|
||||
},
|
||||
"insights": {
|
||||
"title": "Wnioski",
|
||||
"sections": "Sekcje przeglądu",
|
||||
"tiles": "Kafelki nawigacji",
|
||||
"order": "Kolejność",
|
||||
"unknownItem": "Niedostępna analiza",
|
||||
"sectionLabels": {
|
||||
"wellnessScores": "Wyniki dobrostanu",
|
||||
"dailyBriefing": "Codzienny przegląd",
|
||||
"vitals": "Parametry życiowe",
|
||||
"trends": "Trendy",
|
||||
"periodReview": "Przegląd okresu",
|
||||
"cycleSummary": "Podsumowanie cyklu",
|
||||
"signals": "Sygnały",
|
||||
"rhythmEvents": "Zdarzenia rytmu",
|
||||
"healthStatus": "Stan zdrowia",
|
||||
"breathing": "Oddychanie",
|
||||
"labsChanges": "Zmiany wyników laboratoryjnych",
|
||||
"ecg": "EKG"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
|
||||
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
|
||||
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
@@ -9406,7 +9522,7 @@
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Nie należy do udostępnionego dostępu",
|
||||
"description": "Udostępniana jest sama karta zdrowia. Ustawienia, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.",
|
||||
"description": "Udostępniana jest sama karta zdrowia, w tym wywiad zdrowotny w sekcji Anamneza. Ustawienia konta, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.",
|
||||
"leave": "Wróć do mojej karty"
|
||||
},
|
||||
"state": {
|
||||
@@ -9418,6 +9534,10 @@
|
||||
"row": {
|
||||
"readOnly": "Może czytać kartę",
|
||||
"canWrite": "Może czytać i dodawać wpisy",
|
||||
"canManage": "Może czytać kartotekę, dodawać wpisy oraz zmieniać i usuwać to, co w niej jest",
|
||||
"entireRecord": "Obejmuje całą kartotekę",
|
||||
"sections": "Obejmuje: {sections}",
|
||||
"noSections": "Nie obejmuje niczego: ten dostęp nie otwiera żadnej części kartoteki",
|
||||
"invited": "Zaproszono",
|
||||
"accepted": "Zaakceptowano",
|
||||
"expires": "Wygasa",
|
||||
@@ -9439,6 +9559,19 @@
|
||||
"accessReadCapability": "Może czytać wszystko, co ta karta udostępnia. Nie może niczego dodać ani zmienić.",
|
||||
"accessWriteLabel": "Może przeglądać i dodawać",
|
||||
"accessWriteCapability": "Może też dodawać do kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.",
|
||||
"accessManageLabel": "Może zarządzać",
|
||||
"accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy w Twojej kartotece, także te, które wprowadziłeś samodzielnie, oraz czytać wygenerowane na jej podstawie analizy. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.",
|
||||
"manageStepUpNote": "Przy takim zaproszeniu prosimy o drugi składnik logowania, dlatego można to zrobić tylko w przeglądarce.",
|
||||
"scopeLegend": "Co ta osoba może otworzyć",
|
||||
"scopeAllLabel": "Cała kartoteka",
|
||||
"scopeAllCapability": "Każda jej część, tak jak udostępnianie działało do tej pory.",
|
||||
"scopeSomeLabel": "Tylko wybrane przez Ciebie sekcje",
|
||||
"scopeSomeCapability": "Wszystko, czego nie wybierzesz, pozostaje dla niej zamknięte.",
|
||||
"scopeManageNote": "Dostęp zarządzający zawsze obejmuje całą kartotekę.",
|
||||
"scopeLeakNote": "Sekcja może wspominać o reszcie Twojego zdrowia w notatkach i nazwach. Udostępnienie sekcji oznacza udostępnienie wszystkiego, co w niej zapisano.",
|
||||
"scopeAggregateNote": "Przegląd na pulpicie, wynik zdrowia i dzienne podsumowanie pojawiają się tylko wtedy, gdy udostępniona jest cała kartoteka.",
|
||||
"scopeDocumentsNote": "Dokument może zawierać wszystko. Udostępnij tę sekcję tylko wtedy, gdy ta osoba może przeczytać całość.",
|
||||
"scopeEmptyHint": "Wybierz co najmniej jedną sekcję albo wróć do udostępniania całej kartoteki.",
|
||||
"expiresLabel": "Dostęp kończy się (opcjonalnie)",
|
||||
"expiresHint": "Zostaw puste, aby dostęp trwał, dopóki ktoś go nie zakończy. Data jest sprawdzana przy każdym żądaniu, więc dostęp kończy się sam z końcem tego dnia.",
|
||||
"submit": "Wyślij zaproszenie",
|
||||
@@ -9449,7 +9582,11 @@
|
||||
"errorSelf": "To twoje własne konto.",
|
||||
"errorInvalid": "Podaj nazwę użytkownika lub adres e-mail.",
|
||||
"errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.",
|
||||
"errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie."
|
||||
"errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.",
|
||||
"errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.",
|
||||
"errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce, ponieważ wymaga drugiego składnika logowania.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest — sprawdź połączenie i spróbuj ponownie."
|
||||
},
|
||||
"given": {
|
||||
"title": "Kto może otworzyć moją kartę",
|
||||
@@ -9474,6 +9611,7 @@
|
||||
"accept": "Akceptuj",
|
||||
"accepting": "Akceptowanie…",
|
||||
"writeConsent": "Jeśli zaakceptujesz, to, co dodasz, stanie się częścią karty użytkownika {name}. Później nie będziesz mieć możliwości zmiany ani usunięcia wpisów; może to zrobić tylko {name}.",
|
||||
"manageConsent": "Jeśli zaakceptujesz, będziesz mieć możliwość uzupełniania kartoteki użytkownika {name}, a także zmieniania i usuwania tego, co już w niej jest, łącznie z wpisami samego {name}. Wszystko, co zrobisz, zostanie zapisane pod Twoim imieniem w widoku aktywności, a {name} może zakończyć Twój dostęp w dowolnej chwili.",
|
||||
"open": "Otwórz",
|
||||
"renounce": "Zwróć",
|
||||
"renounceTitle": "Zwrócić kartę użytkownika {name}?",
|
||||
@@ -9489,7 +9627,8 @@
|
||||
"loadError": "Nie udało się wczytać aktywności.",
|
||||
"opened": "{name} otworzył twoją kartę {count}×",
|
||||
"acted": "{name} wprowadził zmianę w twojej karcie",
|
||||
"deletedAccount": "Usunięte konto"
|
||||
"deletedAccount": "Usunięte konto",
|
||||
"truncated": "Ta lista pokazuje najnowszą aktywność, a nie całą."
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}."
|
||||
@@ -9499,8 +9638,65 @@
|
||||
"description": "Karty, które udostępniły ci inne osoby.",
|
||||
"levelRead": "Może przeglądać",
|
||||
"levelWrite": "Może przeglądać i dodawać",
|
||||
"kindShared": "Udostępniona karta",
|
||||
"kindManaged": "Zarządzany profil",
|
||||
"open": "Otwórz kartę użytkownika {name}"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profile, którymi się opiekujesz",
|
||||
"description": "Karty zdrowia bez własnego logowania.",
|
||||
"explainer": "Zarządzany profil to karta zdrowia osoby, która sama się nie loguje — dziecka albo kogoś, kim się opiekujesz. Nie ma logowania ani adresu e-mail, a Ty stajesz się pierwszą osobą opiekującą się profilem. Później możesz dodać kolejne, a każda z was może wejść do karty i ją uzupełniać.",
|
||||
"nameLabel": "Nazwa",
|
||||
"namePlaceholder": "Nazwa, po której rozpoznasz tę kartę",
|
||||
"nameHint": "Do 80 znaków. Wyświetla się wszędzie tam, gdzie pojawia się ta karta.",
|
||||
"nameRequired": "Nadaj karcie nazwę.",
|
||||
"nameTooLong": "Ta nazwa ma więcej niż 80 znaków.",
|
||||
"dobLabel": "Data urodzenia (opcjonalnie)",
|
||||
"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.",
|
||||
"timezoneHint": "Strefa, w której liczone są dni tej karty.",
|
||||
"timezoneInvalid": "Ta przeglądarka nie zna takiej strefy czasowej.",
|
||||
"create": "Utwórz profil",
|
||||
"creating": "Tworzenie…",
|
||||
"created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"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.",
|
||||
"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ąć.",
|
||||
"deleteGuardiansOne": "{count} osoba opiekuje się dziś tą kartą i ją straci.",
|
||||
"deleteGuardiansFew": "{count} osoby opiekują się dziś tą kartą i ją stracą.",
|
||||
"deleteGuardiansOther": "{count} osób opiekuje się dziś tą kartą i ją straci.",
|
||||
"deleteFloorNote": "To także wyjście, gdy jesteś ostatnią osobą opiekującą się profilem: możesz zamknąć kartę, ale nie zostawić jej bez opieki.",
|
||||
"deleteConfirm": "Usuń profil",
|
||||
"guardiansTitle": "Kto opiekuje się kartą {name}",
|
||||
"guardianYou": "(Ty)",
|
||||
"guardianSince": "Opiekuje się tą kartą od {date}",
|
||||
"guardianPending": "Zaproszenie z {date}, jeszcze nieprzyjęte",
|
||||
"guardianRemove": "Zakończ dostęp",
|
||||
"guardianRemoveTitle": "Zakończyć dostęp osoby {name}?",
|
||||
"guardianRemoveBody": "{name} nie będzie już mogła otworzyć karty {profile}. To, co zapisała, zostaje w karcie.",
|
||||
"guardianWithdrawBody": "Zaproszenie dla osoby {name} zostaje wycofane. Nigdy nie otworzyła karty {profile}, więc w karcie nic się nie zmienia.",
|
||||
"guardianRemoveConfirm": "Zakończ dostęp",
|
||||
"soleGuardian": "Jesteś jedyną osobą, która opiekuje się kartą {name}, więc nie ma tu dostępu, który mógłbyś zakończyć. Zaproś kogoś i poczekaj na przyjęcie, oddaj własny dostęp w sekcji „Karty, które mogę otworzyć” albo usuń profil.",
|
||||
"guardianInviteLabel": "Zaproś kolejną osobę opiekującą się profilem",
|
||||
"guardianInvite": "Wyślij zaproszenie",
|
||||
"guardianInviting": "Wysyłanie…",
|
||||
"guardianInvited": "Zaproszenie wysłane do osoby {name}.",
|
||||
"guardianExpiresHint": "Opcjonalnie. Dostęp kończy się z końcem tego dnia w Twojej strefie czasowej.",
|
||||
"guardianPendingNote": "Zaproszona osoba liczy się dopiero po przyjęciu zaproszenia. Do tego czasu wciąż jesteś ostatnia, a oddanie własnego dostępu nadal będzie odrzucane.",
|
||||
"guardianErrorDuplicate": "To konto ma już dostęp do tej karty.",
|
||||
"guardianErrorManagedInvitee": "Zarządzany profil nie może opiekować się inną kartą. Zaproś konto, które samo się loguje.",
|
||||
"guardianErrorNoAccount": "W tej instancji nie ma konta o takiej nazwie ani takim adresie e-mail.",
|
||||
"guardianErrorRateLimit": "Zbyt wiele zaproszeń w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"guardianErrorFailed": "Nie udało się wysłać zaproszenia.",
|
||||
"errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.",
|
||||
"rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.",
|
||||
"errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Zapisano w karcie użytkownika {name}."
|
||||
},
|
||||
@@ -9515,7 +9711,143 @@
|
||||
"medicationSideEffectCreate": "{name} zanotował działanie niepożądane",
|
||||
"medicationCreate": "{name} dodał lek",
|
||||
"medicationIntake": "{name} zapisał przyjęcie dawki",
|
||||
"medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)"
|
||||
"medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)",
|
||||
"measurementUpdate": "{name} zmienił pomiar",
|
||||
"measurementDelete": "{name} usunął pomiar",
|
||||
"measurementDeleteBulk": "{name} usunął kilka pomiarów naraz",
|
||||
"measurementRestore": "{name} przywrócił pomiar",
|
||||
"measurementReminderCreate": "{name} dodał przypomnienie o pomiarze",
|
||||
"measurementReminderUpdate": "{name} zmienił przypomnienie o pomiarze",
|
||||
"measurementReminderDelete": "{name} usunął przypomnienie o pomiarze",
|
||||
"measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze",
|
||||
"measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze",
|
||||
"labResultUpdate": "{name} zmienił wynik badania",
|
||||
"labResultDelete": "{name} usunął wynik badania",
|
||||
"labResultRestore": "{name} przywrócił wynik badania",
|
||||
"biomarkerUpdate": "{name} zmienił biomarker",
|
||||
"biomarkerDelete": "{name} usunął biomarker",
|
||||
"allergyUpdate": "{name} zmienił alergię",
|
||||
"allergyDelete": "{name} usunął alergię",
|
||||
"familyHistoryUpdate": "{name} zmienił wpis w wywiadzie rodzinnym",
|
||||
"familyHistoryDelete": "{name} usunął wpis w wywiadzie rodzinnym",
|
||||
"illnessEpisodeUpdate": "{name} zmienił wpis o chorobie",
|
||||
"illnessEpisodeDelete": "{name} usunął wpis o chorobie",
|
||||
"illnessEpisodeResolve": "{name} oznaczył chorobę jako zakończoną",
|
||||
"illnessEpisodeRestore": "{name} przywrócił wpis o chorobie",
|
||||
"illnessDayLogUpsert": "{name} zapisał dzień choroby",
|
||||
"medicationUpdate": "{name} zmienił lek",
|
||||
"medicationDelete": "{name} usunął lek",
|
||||
"medicationGlp1Update": "{name} zmienił terapię GLP-1",
|
||||
"medicationScheduleCreate": "{name} dodał schemat dawkowania",
|
||||
"medicationScheduleUpdate": "{name} zmienił schemat dawkowania",
|
||||
"medicationScheduleDelete": "{name} usunął schemat dawkowania",
|
||||
"medicationInventoryCreate": "{name} dodał opakowanie leku",
|
||||
"medicationInventoryUpdate": "{name} zmienił opakowanie leku",
|
||||
"medicationInventoryDelete": "{name} usunął opakowanie leku",
|
||||
"medicationSideEffectDelete": "{name} usunął działanie niepożądane",
|
||||
"medicationIntakeUpdate": "{name} zmienił przyjęcie dawki",
|
||||
"medicationIntakeDelete": "{name} usunął przyjęcie dawki",
|
||||
"medicationIntakeBulkDelete": "{name} usunął kilka przyjęć dawek naraz",
|
||||
"medicationIntakePurge": "{name} skasował całą historię przyjmowania leku",
|
||||
"medicationIntakeImport": "{name} rozpoczął import przyjęć dla jednego leku",
|
||||
"medicationIntakeHistoryImport": "{name} rozpoczął import historii przyjmowania",
|
||||
"moodEntryCreate": "{name} dodał wpis nastroju",
|
||||
"moodEntryUpdate": "{name} zmienił wpis nastroju",
|
||||
"moodEntryDelete": "{name} usunął wpis nastroju",
|
||||
"moodDeleteBulk": "{name} usunął kilka wpisów nastroju naraz",
|
||||
"moodRestore": "{name} przywrócił wpis nastroju",
|
||||
"mentalHealthCreate": "{name} zapisał wynik kwestionariusza",
|
||||
"cycleDelete": "{name} usunął cykl",
|
||||
"cycleDayLogUpsert": "{name} zapisał dzień cyklu",
|
||||
"cycleDayLogUpdate": "{name} zmienił dzień cyklu",
|
||||
"cycleDayLogDelete": "{name} usunął dzień cyklu",
|
||||
"cyclePeriodBoundary": "{name} zmienił początek lub koniec miesiączki",
|
||||
"cycleSymptomCreate": "{name} dodał objaw cyklu",
|
||||
"nutrientWaterWrite": "{name} zapisał ilość wypitej wody",
|
||||
"healthRecordExport": "{name} wyeksportował kartę",
|
||||
"rejectedMeasurementQuery": "{name} wysłał zapytanie o pomiary, którego ta karta nie mogła odczytać",
|
||||
"rejectedDoseQuery": "{name} wysłał zapytanie o dawki, którego ta karta nie mogła odczytać",
|
||||
"rejectedMoodQuery": "{name} wysłał zapytanie o nastrój, którego ta karta nie mogła odczytać",
|
||||
"rejectedMeasurementCreate": "{name} próbował dodać pomiar i został odrzucony",
|
||||
"rejectedMeasurementBatch": "{name} próbował dodać kilka pomiarów i został odrzucony",
|
||||
"rejectedMeasurementUpdate": "{name} próbował zmienić pomiar i został odrzucony",
|
||||
"rejectedLabCreate": "{name} próbował dodać wynik badania i został odrzucony",
|
||||
"rejectedLabUpdate": "{name} próbował zmienić wynik badania i został odrzucony",
|
||||
"rejectedBiomarkerCreate": "{name} próbował dodać biomarker i został odrzucony",
|
||||
"rejectedBiomarkerUpdate": "{name} próbował zmienić biomarker i został odrzucony",
|
||||
"rejectedReminderCreate": "{name} próbował dodać przypomnienie o pomiarze i został odrzucony",
|
||||
"rejectedReminderUpdate": "{name} próbował zmienić przypomnienie o pomiarze i został odrzucony",
|
||||
"rejectedMoodCreate": "{name} próbował dodać wpis nastroju i został odrzucony",
|
||||
"rejectedMoodUpdate": "{name} próbował zmienić wpis nastroju i został odrzucony",
|
||||
"rejectedDoseCreate": "{name} próbował zapisać przyjęcie dawki i został odrzucony",
|
||||
"rejectedDoseUpdate": "{name} próbował zmienić przyjęcie dawki i został odrzucony",
|
||||
"rejectedDoseImport": "{name} próbował zaimportować przyjęcia dawek i został odrzucony",
|
||||
"rejectedTrackedValue": "{name} próbował zapisać własny pomiar i został odrzucony",
|
||||
"deniedDoseImport": "Import przyjęć rozpoczęty przez {name} nie mógł zostać zakolejkowany",
|
||||
"anamnesisFactCreate": "{name} dodał fakt z wywiadu zdrowotnego",
|
||||
"customMetricCreate": "{name} dodał własny typ pomiaru",
|
||||
"customMetricUpdate": "{name} zmienił własny typ pomiaru",
|
||||
"customMetricDelete": "{name} usunął własny typ pomiaru",
|
||||
"documentStore": "{name} dodał dokument",
|
||||
"documentUpdate": "{name} zmienił dokument",
|
||||
"documentDiscard": "{name} odrzucił dokument",
|
||||
"medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się",
|
||||
"medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
"label": "Pomiary",
|
||||
"detail": "Waga, ciśnienie, tętno, glukoza, sen, treningi i śledzone przez Ciebie wartości."
|
||||
},
|
||||
"medications": {
|
||||
"label": "Leki",
|
||||
"detail": "Lista leków, harmonogramy, przyjęte dawki, działania niepożądane i stan zapasów."
|
||||
},
|
||||
"labs": {
|
||||
"label": "Wyniki badań",
|
||||
"detail": "Wyniki laboratoryjne i lista analitów, które za nimi stoją."
|
||||
},
|
||||
"profile": {
|
||||
"label": "Historia zdrowia",
|
||||
"detail": "Alergie, wywiad rodzinny i dane z Twojego profilu zdrowia."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Choroby",
|
||||
"detail": "Epizody chorobowe i prowadzone przy nich wpisy dzienne."
|
||||
},
|
||||
"mind": {
|
||||
"label": "Nastrój i zdrowie psychiczne",
|
||||
"detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cykl",
|
||||
"detail": "Śledzenie cyklu."
|
||||
},
|
||||
"documents": {
|
||||
"label": "Dokumenty",
|
||||
"detail": "Wszystko, co jest przechowywane w sejfie dokumentów."
|
||||
}
|
||||
},
|
||||
"actionError": {
|
||||
"retry": "Spróbuj ponownie",
|
||||
"offline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.",
|
||||
"rateLimited": "Zbyt wiele prób pod rząd. Odczekaj minutę i spróbuj ponownie.",
|
||||
"acceptExpired": "To zaproszenie wygasło. Poproś o nowe.",
|
||||
"acceptWithdrawn": "To zaproszenie zostało wycofane.",
|
||||
"acceptNotPending": "To zaproszenie zostało już przyjęte.",
|
||||
"acceptNotFound": "Tego zaproszenia już nie ma.",
|
||||
"acceptFailed": "Nie udało się przyjąć zaproszenia.",
|
||||
"lastGuardian": "Ta osoba jako jedyna opiekuje się tym profilem, więc ten dostęp nie może się jeszcze zakończyć. Dodaj najpierw kolejną osobę opiekującą się profilem albo usuń profil.",
|
||||
"alreadyEnded": "Ten dostęp już się zakończył.",
|
||||
"notFound": "Tego dostępu już nie ma.",
|
||||
"revokeFailed": "Nie udało się zakończyć dostępu.",
|
||||
"renounceFailed": "Nie udało się zwrócić dostępu.",
|
||||
"profileNotFound": "Tego profilu już nie ma — mógł zostać usunięty albo Twój dostęp do niego się zakończył.",
|
||||
"deleteProfileFailed": "Nie udało się usunąć profilu.",
|
||||
"removeGuardianFailed": "Nie udało się zakończyć tego dostępu."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany."
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.36.1",
|
||||
"version": "1.37.0",
|
||||
"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",
|
||||
|
||||
@@ -75,14 +75,70 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
// The disk-layer spec needs a live service worker, which the shared
|
||||
// `use` block blocks for every other spec. It runs in its own project.
|
||||
testIgnore: ["v137-record-session-fence-offline.spec.ts"],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
colorScheme: "dark",
|
||||
},
|
||||
},
|
||||
{
|
||||
// v1.37.0 — the one project that lets the service worker run.
|
||||
//
|
||||
// Every other project blocks it, and the reason is written in the `use`
|
||||
// block above: a worker-originated `fetch` is not subject to
|
||||
// `page.route`, so the SW would bypass the per-spec route mocks. The
|
||||
// record-session fence's disk-layer case is the exact inverse — its whole
|
||||
// claim is about what the `healthlog-data-*` cache can and cannot serve
|
||||
// across a switch, and with the worker blocked that cache is never
|
||||
// populated, so its positive control could never pass. A check that
|
||||
// cannot pass is worse than no check, so the spec gets a project rather
|
||||
// than a caveat.
|
||||
//
|
||||
// It uses no route mocks for exactly this reason, and it is the only
|
||||
// spec in this project.
|
||||
name: "chromium-service-worker",
|
||||
testMatch: ["v137-record-session-fence-offline.spec.ts"],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
colorScheme: "dark",
|
||||
serviceWorkers: "allow",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
// The scoped-sharing journeys each carry a persistent server-side
|
||||
// session stamp. They use isolated desktop jars and are not responsive
|
||||
// layout checks, so running a second project against the same stamps
|
||||
// would test session interference rather than the record journeys.
|
||||
testIgnore: [
|
||||
"v137-sharing-managed-profiles.spec.ts",
|
||||
"v137-sharing-managed-profiles-a11y.spec.ts",
|
||||
// v1.37.0 — every spec that MOVES a session's record selector runs in
|
||||
// exactly one project.
|
||||
//
|
||||
// Each of these now has its own login and its own session row (see
|
||||
// `FENCE_STORAGE_STATE_PATH` in `e2e/setup/global-setup.ts`), which is
|
||||
// what lets them run beside each other at any worker count. A second
|
||||
// PROJECT is the one thing a separate jar cannot fix: it would drive
|
||||
// the very same row from two browsers at once, moving the epoch under
|
||||
// itself. The scoped-sharing journeys above are excluded for the same
|
||||
// reason, one release earlier.
|
||||
"v137-record-session-fence.spec.ts",
|
||||
"v137-sharing-cross-tab-session.spec.ts",
|
||||
// The banner-geometry spec joined that list in v1.37.0, because it
|
||||
// stopped faking the shared-record strip and started switching for
|
||||
// real. It is the one entry here that measures a MOBILE property, so
|
||||
// it does not simply lose it: it measures both widths inside the
|
||||
// desktop project instead, where the shell below `md` renders from the
|
||||
// same width-driven rules. Its file comment carries the reasoning.
|
||||
"chrome-header-seam-banners.spec.ts",
|
||||
// Runs only in the service-worker project.
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
],
|
||||
use: {
|
||||
// Pixel 5 — Chromium-based mobile profile so CI only needs
|
||||
// `playwright install chromium` instead of also pulling
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- v1.37.0 — a grant can open part of a record, and a grant can hand it over.
|
||||
--
|
||||
-- Two additive changes, no backfill, no re-consent, and no existing row
|
||||
-- changes meaning:
|
||||
--
|
||||
-- * `scope_json` is NULL on every row that exists, and NULL means the entire
|
||||
-- record. That is not a default standing in for a missing answer — it is
|
||||
-- the answer every one of those rows was actually consented as, before
|
||||
-- narrowing was a thing anybody could ask for. A non-NULL value is a
|
||||
-- non-empty array of section keys from the closed vocabulary in
|
||||
-- `src/lib/sharing/scope.ts`.
|
||||
-- * `MANAGE` joins the level enum. No row becomes MANAGE here; the value
|
||||
-- exists so a row can state the level instead of implying it, the same
|
||||
-- reason `account_grant_access` was a type rather than a boolean from day
|
||||
-- one (migration 0292).
|
||||
--
|
||||
-- Json rather than a text[] or a join table. A join table would be the
|
||||
-- normalised answer and it would be the wrong one: the set is read on every
|
||||
-- delegated request as part of deciding an authorization, so it belongs on the
|
||||
-- row the resolver already loads, and it is written exactly once, at
|
||||
-- invitation, by a machine — never edited, never queried across grants. There
|
||||
-- is no in-place widening anywhere in this feature, so the set has no update
|
||||
-- path to keep consistent. A text[] would carry the same shape with a
|
||||
-- Postgres-specific type Prisma models less directly, for no read this
|
||||
-- product performs.
|
||||
--
|
||||
-- No CHECK constraint asserting the array is non-empty, well-formed, or absent
|
||||
-- on a MANAGE row. Those three conditions are enforced in
|
||||
-- `src/lib/sharing/grants.ts`, at the one function that writes the column, and
|
||||
-- the reason is the same one migrations 0290 and 0292 gave for declining their
|
||||
-- own CHECKs: the delete-all-data guard seeds a row into every table by
|
||||
-- walking `information_schema`, and a constraint it cannot satisfy breaks a
|
||||
-- fixture whose whole value is that nobody hand-writes it. The READ side is
|
||||
-- what actually protects the record anyway — `normaliseScope` resolves a
|
||||
-- malformed blob to the empty set and refuses every section, so a bad value
|
||||
-- that reached the column by any route grants nothing rather than everything.
|
||||
|
||||
ALTER TYPE "account_grant_access" ADD VALUE 'MANAGE';
|
||||
|
||||
ALTER TABLE "account_grants" ADD COLUMN "scope_json" JSONB;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- v1.37.0 — a record somebody else runs.
|
||||
--
|
||||
-- A managed profile is an account for a person who cannot run their own
|
||||
-- record: a child, a parent under care. It has a display name, no e-mail and
|
||||
-- no password, never signs in, and is administered by whoever holds an active
|
||||
-- MANAGE grant on it.
|
||||
--
|
||||
-- One column, and the whole of the feature's identity fence hangs off it. An
|
||||
-- invited adult who granted management of their OWN record must never reach
|
||||
-- the settings, integrations, notification routing, grant management, AI
|
||||
-- configuration, export or deletion of that record — those are the surfaces
|
||||
-- that would let a manager outlive their own revocation. On a managed profile
|
||||
-- there is no self to reserve them for, so the guardian holds them. The gate
|
||||
-- is this column and not the grant level, which is what makes the two cases
|
||||
-- structurally different rather than differently argued.
|
||||
--
|
||||
-- A timestamp rather than a boolean, deliberately. It says since when, which
|
||||
-- the activity trail needs to be readable across the transition, and clearing
|
||||
-- it leaves a future-compatible NULL state. This migration deliberately adds
|
||||
-- no transition route or credential attachment flow. If a later handover is
|
||||
-- designed, `email` and `password_hash` are already nullable and the grants
|
||||
-- can survive untouched while the product defines who holds the revoke button.
|
||||
--
|
||||
-- No index. The column is read by primary key, on a row the request already
|
||||
-- loaded, and the one enumeration that scans for marked rows (the reminder
|
||||
-- fan-out) resolves them from the grant side.
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "managed_profile_at" TIMESTAMPTZ(3);
|
||||
@@ -0,0 +1,121 @@
|
||||
-- v1.37.0 — a notification names the health record it concerns and the
|
||||
-- account whose channels receive it. The former remains the source of health
|
||||
-- calculations and content; the latter owns delivery preferences and reach.
|
||||
--
|
||||
-- `user_id` remains as a legacy recipient mirror so established self-delivery
|
||||
-- writers and bounded historical readers stay valid while every row gains the
|
||||
-- explicit pair. The trigger maps an omitted pair to self-delivery, rejects a
|
||||
-- partial or inconsistent pair, and refuses a managed profile as recipient.
|
||||
|
||||
ALTER TABLE "push_attempts"
|
||||
ADD COLUMN "record_user_id" TEXT,
|
||||
ADD COLUMN "recipient_user_id" TEXT;
|
||||
|
||||
UPDATE "push_attempts"
|
||||
SET
|
||||
"record_user_id" = "user_id",
|
||||
"recipient_user_id" = "user_id";
|
||||
|
||||
ALTER TABLE "push_attempts"
|
||||
ALTER COLUMN "record_user_id" SET NOT NULL,
|
||||
ALTER COLUMN "recipient_user_id" SET NOT NULL;
|
||||
|
||||
ALTER TABLE "push_attempts"
|
||||
ADD CONSTRAINT "push_attempts_record_user_id_fkey"
|
||||
FOREIGN KEY ("record_user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT "push_attempts_recipient_user_id_fkey"
|
||||
FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "push_attempts_recipient_user_id_created_at_desc_idx"
|
||||
ON "push_attempts" ("recipient_user_id", "created_at" DESC);
|
||||
|
||||
CREATE INDEX "push_attempts_record_user_id_created_at_desc_idx"
|
||||
ON "push_attempts" ("record_user_id", "created_at" DESC);
|
||||
|
||||
CREATE TABLE "notification_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"record_user_id" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"dedup_key" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "notification_events_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "notification_events_record_user_id_fkey"
|
||||
FOREIGN KEY ("record_user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "notification_events_record_user_id_event_type_dedup_key_created_at_desc_idx"
|
||||
ON "notification_events" ("record_user_id", "event_type", "dedup_key", "created_at" DESC);
|
||||
|
||||
-- Tracking belongs to the account that received the Telegram message. The
|
||||
-- prior subject-only key collapsed multiple delegated recipients into one row.
|
||||
ALTER TABLE "telegram_reminder_messages"
|
||||
ADD COLUMN "recipient_user_id" TEXT;
|
||||
|
||||
UPDATE "telegram_reminder_messages" AS "message"
|
||||
SET "recipient_user_id" = "medication"."user_id"
|
||||
FROM "medications" AS "medication"
|
||||
WHERE "medication"."id" = "message"."medication_id";
|
||||
|
||||
ALTER TABLE "telegram_reminder_messages"
|
||||
ALTER COLUMN "recipient_user_id" SET NOT NULL,
|
||||
ADD CONSTRAINT "telegram_reminder_messages_recipient_user_id_fkey"
|
||||
FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
DROP INDEX "telegram_reminder_messages_med_sched_date_phase_tod_key";
|
||||
|
||||
CREATE UNIQUE INDEX "telegram_reminder_messages_recipient_slot_key"
|
||||
ON "telegram_reminder_messages" (
|
||||
"recipient_user_id",
|
||||
"medication_id",
|
||||
"schedule_id",
|
||||
"date",
|
||||
"phase",
|
||||
"time_of_day"
|
||||
);
|
||||
|
||||
CREATE FUNCTION enforce_push_attempt_attribution()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.record_user_id IS NULL AND NEW.recipient_user_id IS NULL THEN
|
||||
NEW.record_user_id := NEW.user_id;
|
||||
NEW.recipient_user_id := NEW.user_id;
|
||||
ELSIF NEW.record_user_id IS NULL OR NEW.recipient_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'push attempt attribution requires both principals';
|
||||
ELSIF NEW.user_id <> NEW.recipient_user_id THEN
|
||||
RAISE EXCEPTION 'push attempt recipient must match user_id';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "users"
|
||||
WHERE "id" = NEW.recipient_user_id
|
||||
AND "managed_profile_at" IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'managed profile cannot receive notification delivery';
|
||||
END IF;
|
||||
|
||||
IF NEW.record_user_id <> NEW.recipient_user_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "users"
|
||||
WHERE "id" = NEW.record_user_id
|
||||
AND "managed_profile_at" IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'cross-principal delivery requires a managed record';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER push_attempt_attribution_guard
|
||||
BEFORE INSERT OR UPDATE ON "push_attempts"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION enforce_push_attempt_attribution();
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Keep the record subject and the actor distinct for durable intake-import
|
||||
-- work. Historical rows predate delegated imports, so their actor is the
|
||||
-- record subject: the only truthful attribution available for those jobs.
|
||||
ALTER TABLE "medication_intake_import_jobs"
|
||||
RENAME COLUMN "user_id" TO "record_user_id";
|
||||
|
||||
ALTER TABLE "medication_intake_import_jobs"
|
||||
RENAME CONSTRAINT "medication_intake_import_jobs_user_id_fkey"
|
||||
TO "medication_intake_import_jobs_record_user_id_fkey";
|
||||
|
||||
ALTER TABLE "medication_intake_import_jobs"
|
||||
ADD COLUMN "actor_user_id" TEXT;
|
||||
|
||||
UPDATE "medication_intake_import_jobs"
|
||||
SET "actor_user_id" = "record_user_id"
|
||||
WHERE "actor_user_id" IS NULL;
|
||||
|
||||
ALTER TABLE "medication_intake_import_jobs"
|
||||
ALTER COLUMN "actor_user_id" SET NOT NULL;
|
||||
|
||||
-- No actor foreign key: a deleted delegate must remain identifiable in the
|
||||
-- record's durable job and audit trail, just like audit_logs.actor_user_id.
|
||||
|
||||
DROP INDEX "medication_intake_import_jobs_user_id_created_at_idx";
|
||||
CREATE INDEX "medication_intake_import_jobs_record_user_id_created_at_idx"
|
||||
ON "medication_intake_import_jobs"("record_user_id", "created_at");
|
||||
CREATE INDEX "medication_intake_import_jobs_actor_user_id_created_at_idx"
|
||||
ON "medication_intake_import_jobs"("actor_user_id", "created_at");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- v1.37.0 — notification event retention deletes globally by created_at.
|
||||
-- The dedup lookup index starts with record_user_id, so it cannot serve this
|
||||
-- trailing-edge cleanup predicate efficiently.
|
||||
CREATE INDEX "notification_events_created_at_idx"
|
||||
ON "notification_events" ("created_at");
|
||||
@@ -0,0 +1,41 @@
|
||||
-- v1.37.0 — a managed Guardian notification receives a durable final
|
||||
-- authorization claim under the profile lifecycle lock before provider I/O.
|
||||
-- This records only delivery principals and outcome, never health content or
|
||||
-- channel credentials. Retention follows the notification attempt cleanup.
|
||||
CREATE TABLE "notification_egress_authorizations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"record_user_id" TEXT NOT NULL,
|
||||
"recipient_user_id" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"authorized_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"outcome" TEXT,
|
||||
|
||||
CONSTRAINT "notification_egress_authorizations_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "notification_egress_authorizations_record_user_id_fkey"
|
||||
FOREIGN KEY ("record_user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "notification_egress_authorizations_recipient_user_id_fkey"
|
||||
FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "notification_egress_authorizations_authorized_at_idx"
|
||||
ON "notification_egress_authorizations" ("authorized_at");
|
||||
|
||||
CREATE INDEX "notification_egress_authorizations_record_user_id_recipient_user_id_authorized_at_idx"
|
||||
ON "notification_egress_authorizations" (
|
||||
"record_user_id",
|
||||
"recipient_user_id",
|
||||
"authorized_at" DESC
|
||||
);
|
||||
|
||||
-- A recipient can erase their account without owning the underlying record.
|
||||
-- PostgreSQL needs a recipient-first access path for that FK cascade and for
|
||||
-- the explicit Delete All Data predicate over recipient_user_id.
|
||||
CREATE INDEX "notification_egress_auth_recipient_authorized_at_idx"
|
||||
ON "notification_egress_authorizations" (
|
||||
"recipient_user_id",
|
||||
"authorized_at" DESC
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
-- v1.37.0 — a monotonic counter for "how many times has this session's record
|
||||
-- selector moved".
|
||||
--
|
||||
-- The problem it solves is not authorization; `acting_as_user_id` plus the
|
||||
-- grant join already decides that. It is agreement. A browser holds several
|
||||
-- tabs on one session, and a request that left a suspended tab before a switch
|
||||
-- landed arrives after it — carrying an intent formed under the old record and
|
||||
-- addressed to a server that has since moved. The request is perfectly
|
||||
-- authorized and answers the wrong question. So every request that resolves a
|
||||
-- record now asserts which context it believes it is in, and the server refuses
|
||||
-- when that belief no longer matches the row.
|
||||
--
|
||||
-- The counter has to be the DATABASE's, not the application's, because the
|
||||
-- application is not the only writer of the column it tracks. Three of the
|
||||
-- five writers run no application code at all:
|
||||
--
|
||||
-- * `sessions_acting_as_user_id_fkey ON DELETE SET NULL` — deleting an owner
|
||||
-- account nulls every delegate session pointing at it, by referential
|
||||
-- action, with nothing to hook;
|
||||
-- * raw `UPDATE sessions SET acting_as_user_id = NULL` in the end-to-end
|
||||
-- fixtures;
|
||||
-- * an operator at `psql`, and whatever writes this column next year.
|
||||
--
|
||||
-- An application-level increment would be a discipline those three break
|
||||
-- silently, and a fence whose counter can silently fail to move is a fence that
|
||||
-- passes stale requests. A row trigger cannot be forgotten.
|
||||
--
|
||||
-- BEFORE, not AFTER, and that timing is load-bearing: on any statement that
|
||||
-- moves the selector, the trigger overwrites whatever `record_epoch` the
|
||||
-- incoming statement supplied, so a writer cannot steer the counter while
|
||||
-- switching.
|
||||
--
|
||||
-- What that does NOT cover, stated because the first draft of this comment
|
||||
-- overstated it: a statement that names `record_epoch` and does NOT change
|
||||
-- `acting_as_user_id` never fires this trigger at all, so it writes the column
|
||||
-- freely. The trigger owns the counter on the path that matters — every
|
||||
-- selector move — and what keeps application code away from the column
|
||||
-- otherwise is the source guard in
|
||||
-- `src/__tests__/record-session-fence-guard.test.ts`, which holds the set of
|
||||
-- files naming it to one.
|
||||
--
|
||||
-- `UPDATE OF "acting_as_user_id"` plus the `WHEN` clause keep the two writes
|
||||
-- that touch a session on nearly every request off the trigger entirely — the
|
||||
-- throttled `last_active_at` stamp and the sliding `expires_at` extension both
|
||||
-- name other columns, and neither would satisfy the `IS DISTINCT FROM` test
|
||||
-- even if it did.
|
||||
--
|
||||
-- Precedent for a trigger on this line: `0298_notification_delivery_identity`
|
||||
-- ships `push_attempt_attribution_guard` the same way. A trigger is invisible
|
||||
-- to Prisma's datamodel comparison, which 0298 already proved tolerable.
|
||||
|
||||
ALTER TABLE "sessions" ADD COLUMN "record_epoch" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- A session already sitting inside somebody else's record is not a session
|
||||
-- that has never left its own, and the fence's exemption for pre-existing
|
||||
-- browser tabs is defined as epoch zero. Normalise before the trigger exists,
|
||||
-- so that from the first request after deploy `record_epoch = 0` means exactly
|
||||
-- "never pointed at another record, and not pointed at one now".
|
||||
UPDATE "sessions" SET "record_epoch" = 1 WHERE "acting_as_user_id" IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "sessions_bump_record_epoch"() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW."record_epoch" := OLD."record_epoch" + 1;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER "sessions_record_epoch_bump"
|
||||
BEFORE UPDATE OF "acting_as_user_id" ON "sessions"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."acting_as_user_id" IS DISTINCT FROM NEW."acting_as_user_id")
|
||||
EXECUTE FUNCTION "sessions_bump_record_epoch"();
|
||||
+169
-26
@@ -52,6 +52,28 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
/// v1.37.0 — since when this account is a record somebody else runs.
|
||||
///
|
||||
/// A managed profile is a person who cannot run their own record: it has a
|
||||
/// display name and no credentials, never signs in, and is administered
|
||||
/// end to end by whoever holds an active MANAGE grant on it. The marker is
|
||||
/// what separates that case from an ordinary adult who granted management
|
||||
/// of their own record — the identity and reach surfaces open for the
|
||||
/// former and stay shut for the latter, and the gate is this column rather
|
||||
/// than the grant, so no argument about a grant level can reach them.
|
||||
///
|
||||
/// A timestamp rather than a boolean: it says since when, and preserves a
|
||||
/// future-compatible null state without adding a transition surface here.
|
||||
/// This release does not expose a way to clear the marker or attach
|
||||
/// credentials; the grants and activity trail remain intact if a later,
|
||||
/// separately designed handover ever needs that state.
|
||||
///
|
||||
/// Consumers land in this release: the guardian resolver in
|
||||
/// `src/lib/api-handler.ts` reads it on every guardian request, and the
|
||||
/// creation flow is what writes it. A later notification plan may add
|
||||
/// delivery fanout after it defines consent and preference semantics.
|
||||
managedProfileAt DateTime? @map("managed_profile_at")
|
||||
|
||||
// Profile
|
||||
heightCm Float? @map("height_cm")
|
||||
dateOfBirth DateTime? @map("date_of_birth")
|
||||
@@ -794,7 +816,7 @@ model User {
|
||||
// pg-boss job state so the polling endpoint can read live per-
|
||||
// phase progress without round-tripping through pg-boss internals.
|
||||
importJobs ImportJob[]
|
||||
medicationIntakeImportJobs MedicationIntakeImportJob[]
|
||||
medicationIntakeImportJobs MedicationIntakeImportJob[] @relation("MedicationIntakeImportRecord")
|
||||
// v1.5.0 — persistent measurement rollup table. Carries the
|
||||
// count/min/max/mean/sd/slope/r2 stats per (type, granularity,
|
||||
// bucketStart) so the analytics + comprehensive read paths bypass
|
||||
@@ -838,7 +860,12 @@ model User {
|
||||
// 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin
|
||||
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
|
||||
// `reminder-worker.ts`).
|
||||
pushAttempts PushAttempt[]
|
||||
pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser")
|
||||
pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser")
|
||||
notificationEvents NotificationEvent[]
|
||||
notificationEgressAuthorizationsForRecord NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecordUser")
|
||||
notificationEgressAuthorizationsReceived NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecipientUser")
|
||||
telegramReminderMessages TelegramReminderMessage[]
|
||||
// v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
|
||||
// TRIMP + running EWMA reference) so the nightly Strain engine anchors
|
||||
// the 0–100 map to the user's own training load without re-integrating
|
||||
@@ -1152,6 +1179,24 @@ model Session {
|
||||
/// into its own account.
|
||||
actingAsUserId String? @map("acting_as_user_id")
|
||||
|
||||
/// v1.37.0 — how many times this session's record selector has moved.
|
||||
///
|
||||
/// Monotonic, starts at 0, and NEVER written by application code: migration
|
||||
/// 0302 installs a `BEFORE UPDATE OF acting_as_user_id` trigger that sets it,
|
||||
/// so the increment also happens on the paths where no application code runs
|
||||
/// at all — the `ON DELETE SET NULL` referential action above, a raw fixture
|
||||
/// `UPDATE`, an operator at `psql`. A convention that every writer must
|
||||
/// remember is a convention those three break silently.
|
||||
///
|
||||
/// Consumer: the record-session fence
|
||||
/// (`src/lib/sharing/record-session-fence.ts`), which refuses a cookie
|
||||
/// request whose asserted epoch no longer matches the row. `0` additionally
|
||||
/// means "this session has never been pointed at another record", which is
|
||||
/// the fence's exemption for browser tabs that predate the feature; the
|
||||
/// migration normalises existing rows so that invariant holds from the first
|
||||
/// request after deploy.
|
||||
recordEpoch Int @default(0) @map("record_epoch")
|
||||
|
||||
user User @relation("SessionOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
actingAs User? @relation("SessionActingAs", fields: [actingAsUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -3340,24 +3385,26 @@ model ReminderPhaseConfig {
|
||||
}
|
||||
|
||||
model TelegramReminderMessage {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @map("medication_id")
|
||||
scheduleId String @map("schedule_id")
|
||||
chatId String @map("chat_id")
|
||||
messageId Int @map("message_id")
|
||||
phase ReminderPhase
|
||||
sentAt DateTime @default(now()) @map("sent_at")
|
||||
date String // YYYY-MM-DD for day grouping
|
||||
id String @id @default(cuid())
|
||||
recipientUserId String @map("recipient_user_id")
|
||||
medicationId String @map("medication_id")
|
||||
scheduleId String @map("schedule_id")
|
||||
chatId String @map("chat_id")
|
||||
messageId Int @map("message_id")
|
||||
phase ReminderPhase
|
||||
sentAt DateTime @default(now()) @map("sent_at")
|
||||
date String // YYYY-MM-DD for day grouping
|
||||
/// v1.7.0 — time-of-day this reminder belongs to (HH:mm, user-local).
|
||||
/// Empty string = the single legacy window (pre-v1.7 rows + single-time
|
||||
/// schedules). Part of the dedup key so a multi-time schedule dispatches
|
||||
/// once per time-of-day per phase per day instead of once per day.
|
||||
timeOfDay String @default("") @map("time_of_day")
|
||||
timeOfDay String @default("") @map("time_of_day")
|
||||
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||
recipientUser User @relation(fields: [recipientUserId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_med_sched_date_phase_tod_key")
|
||||
@@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key")
|
||||
@@index([medicationId, date])
|
||||
@@map("telegram_reminder_messages")
|
||||
}
|
||||
@@ -4693,7 +4740,8 @@ model ImportJob {
|
||||
// stays in Postgres so retries never depend on request-process memory.
|
||||
model MedicationIntakeImportJob {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
recordUserId String @map("record_user_id")
|
||||
actorUserId String @map("actor_user_id")
|
||||
/// v1.33.0 (migration 0278) — nullable. The per-medication import route
|
||||
/// scopes its job to one medication and names it here. A dose history
|
||||
/// exported from another tracker covers a whole regimen in one file — the
|
||||
@@ -4714,10 +4762,11 @@ model MedicationIntakeImportJob {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
recordUser User @relation("MedicationIntakeImportRecord", fields: [recordUserId], references: [id], onDelete: Cascade)
|
||||
medication Medication? @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([recordUserId, createdAt])
|
||||
@@index([actorUserId, createdAt])
|
||||
@@index([medicationId, createdAt])
|
||||
@@index([status, heartbeatAt])
|
||||
@@map("medication_intake_import_jobs")
|
||||
@@ -5779,6 +5828,50 @@ model ConsentReceipt {
|
||||
// `channel` is the dispatcher `ChannelType` string (`APNS`,
|
||||
// `WEB_PUSH`, `TELEGRAM`, `NTFY`).
|
||||
//
|
||||
// Record-scoped notification events are durable event anchors rather than
|
||||
// channel delivery records. They intentionally name no recipient, which keeps
|
||||
// scheduler deduplication independent from future Guardian fan-out.
|
||||
model NotificationEvent {
|
||||
id String @id @default(cuid())
|
||||
recordUserId String @map("record_user_id")
|
||||
eventType String @map("event_type")
|
||||
dedupKey String @map("dedup_key")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
recordUser User @relation(fields: [recordUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([recordUserId, eventType, dedupKey, createdAt(sort: Desc)])
|
||||
@@index([createdAt], map: "notification_events_created_at_idx")
|
||||
@@map("notification_events")
|
||||
}
|
||||
|
||||
// A managed Guardian channel send crosses a revocable authorization boundary.
|
||||
// The dispatcher creates this claim while it holds the managed-profile
|
||||
// lifecycle lock, then commits before starting provider I/O. That committed
|
||||
// claim is the linearization point: a revocation that commits first prevents
|
||||
// the send, while a revocation that commits later does not cancel the one
|
||||
// already-authorized provider operation. No notification content, channel
|
||||
// credentials, or event deduplication keys are retained here. Completion is
|
||||
// observational only; authorization and retry decisions never read it.
|
||||
model NotificationEgressAuthorization {
|
||||
id String @id @default(cuid())
|
||||
recordUserId String @map("record_user_id")
|
||||
recipientUserId String @map("recipient_user_id")
|
||||
channel String
|
||||
eventType String @map("event_type")
|
||||
authorizedAt DateTime @default(now()) @map("authorized_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
outcome String?
|
||||
|
||||
recordUser User @relation("NotificationEgressAuthorizationRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade)
|
||||
recipientUser User @relation("NotificationEgressAuthorizationRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([authorizedAt], map: "notification_egress_authorizations_authorized_at_idx")
|
||||
@@index([recordUserId, recipientUserId, authorizedAt(sort: Desc)])
|
||||
@@index([recipientUserId, authorizedAt(sort: Desc)], map: "notification_egress_auth_recipient_authorized_at_idx")
|
||||
@@map("notification_egress_authorizations")
|
||||
}
|
||||
|
||||
// `result` is one of `"ok"` | `"error"` | `"skipped"` — `skipped`
|
||||
// covers the "no recipient" / "config missing" soft cases.
|
||||
//
|
||||
@@ -5788,17 +5881,38 @@ model ConsentReceipt {
|
||||
// classified `web_push_*` reason; Telegram / NTFY likewise mirror
|
||||
// their sender-side `SendOutcome.reason`.
|
||||
model PushAttempt {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY"
|
||||
eventType String @map("event_type") // dispatcher event-type string
|
||||
result String // "ok" | "error" | "skipped"
|
||||
reason String? // sender-side reason on error/skip; null on ok
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id String @id @default(cuid())
|
||||
/// Legacy recipient mirror. Migration 0298 preserves established writers
|
||||
/// while the database trigger requires it to equal `recipientUserId`.
|
||||
userId String @map("user_id")
|
||||
/// The health-record subject. This supplies calculation context and content.
|
||||
///
|
||||
/// Optional HERE and `NOT NULL` in the database, deliberately, and the two do
|
||||
/// not disagree: `push_attempt_attribution_guard` is a BEFORE INSERT trigger,
|
||||
/// so a write that omits both principals has them filled from `userId` before
|
||||
/// the constraint is checked. Declaring them required would force every
|
||||
/// established self-delivery writer to name a pair it has no opinion about;
|
||||
/// making the columns nullable in SQL would let a partial pair reach the
|
||||
/// table, which the trigger exists to refuse. `prisma migrate diff` reports
|
||||
/// the difference because it cannot see the trigger — that report is expected,
|
||||
/// and `notification-attribution.test.ts` ("persistence maps an omitted pair
|
||||
/// from the legacy recipient") is what proves the accommodation still works.
|
||||
recordUserId String? @map("record_user_id")
|
||||
/// The account whose channel, preferences, and locale receive the delivery.
|
||||
/// Nullable for the same reason as `recordUserId` above.
|
||||
recipientUserId String? @map("recipient_user_id")
|
||||
channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY"
|
||||
eventType String @map("event_type") // dispatcher event-type string
|
||||
result String // "ok" | "error" | "skipped"
|
||||
reason String? // sender-side reason on error/skip; null on ok
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
recordUser User? @relation("PushAttemptRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade)
|
||||
recipientUser User? @relation("PushAttemptRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([recordUserId, createdAt(sort: Desc)])
|
||||
@@index([recipientUserId, createdAt(sort: Desc)])
|
||||
@@map("push_attempts")
|
||||
}
|
||||
|
||||
@@ -5904,6 +6018,14 @@ model ClinicianShareLinkDocument {
|
||||
enum AccountGrantAccess {
|
||||
READ
|
||||
WRITE
|
||||
/// v1.37.0 — full management of the record's data: everything WRITE reaches,
|
||||
/// plus the edit, delete and restore verbs beside it, plus the record-wide
|
||||
/// derived surfaces. Never the identity and reach surfaces — credentials,
|
||||
/// channels, tokens, integrations, grant management, AI configuration —
|
||||
/// which stay unreachable at every invited level, because a manager who
|
||||
/// could mint a credential would survive their own revocation. MANAGE is
|
||||
/// whole-record by construction: `scopeJson` must be NULL on a MANAGE row.
|
||||
MANAGE
|
||||
|
||||
@@map("account_grant_access")
|
||||
}
|
||||
@@ -5970,6 +6092,24 @@ model AccountGrant {
|
||||
|
||||
access AccountGrantAccess @default(READ)
|
||||
|
||||
/// v1.37.0 — which sections of the record this grant opens.
|
||||
///
|
||||
/// NULL means the entire record, which is exactly what every row written
|
||||
/// before this column existed was consented as — so there is no backfill and
|
||||
/// nobody is asked to consent again. A non-NULL value is a non-empty array
|
||||
/// of `ShareDomain` keys (`src/lib/sharing/scope.ts`).
|
||||
///
|
||||
/// Read fail-closed through `normaliseScope` in `src/lib/sharing/grants.ts`
|
||||
/// and nowhere else: a malformed non-NULL blob resolves to the empty set and
|
||||
/// refuses every section, the opposite direction from the module preference
|
||||
/// normaliser, because this column answers an authorization question and
|
||||
/// that one answers a presentation question.
|
||||
///
|
||||
/// Always NULL on a MANAGE row. Management is whole-record: "they can do
|
||||
/// anything, but only to part of you" promises a boundary that cannot be
|
||||
/// kept once edits are in play.
|
||||
scopeJson Json? @map("scope_json")
|
||||
|
||||
/// When the invitation was offered. A domain event, so re-offering an
|
||||
/// invitation on the same pending row moves it; `createdAt` below is row
|
||||
/// bookkeeping and never moves. They agree the day the row is written and
|
||||
@@ -5986,7 +6126,10 @@ model AccountGrant {
|
||||
revokedBy AccountGrantRevoker? @map("revoked_by")
|
||||
|
||||
/// Optional lapse date. Checked live on every resolution, so an expired
|
||||
/// grant stops conferring access without anything having to sweep it.
|
||||
/// grant stops conferring access without anything having to sweep it. A
|
||||
/// managed-profile invitation may carry an expiry while awaiting consent;
|
||||
/// accepting its MANAGE grant clears this field because an active Guardian
|
||||
/// ends only through an explicit lifecycle transition.
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
/// Refreshed fire-and-forget when the grant is exercised, the same posture
|
||||
/// as `ApiToken.lastUsedAt`. The owner's panel reads it to answer "is this
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.36.1";
|
||||
/* @sw-version-fallback */ "v1.37.0";
|
||||
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/*`
|
||||
|
||||
@@ -241,6 +241,23 @@
|
||||
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Operational windows and cookie flags",
|
||||
"description": "Values the product states back to a user or that decide whether a session cookie is accepted. Optional: each has a documented default. They are listed so `pnpm check-env` reports them, and so the compose whitelist and the example files can be held in step with this manifest.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "AUDIT_LOG_RETENTION_DAYS",
|
||||
"purpose": "How long audit rows survive, in days (365 by default). Published, not merely enforced: the shared-access activity feed and the dialog that ends somebody's access both state the number back to the user.",
|
||||
"example": "365"
|
||||
},
|
||||
{
|
||||
"name": "SESSION_COOKIE_SECURE",
|
||||
"purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.",
|
||||
"example": "false"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* v1.37.0 integration verifier — the inventories, and whether any of them is empty.
|
||||
*
|
||||
* ## What this is for
|
||||
*
|
||||
* The release is proved by inventories: a migration allocation, a set of
|
||||
* admitted mutating handlers, a notification delivery matrix, a set of security
|
||||
* principals, five accessibility states and eight journeys. Every one of them is
|
||||
* consumed by a test that iterates it, and every one of those tests passes
|
||||
* trivially when the list it iterates is empty. That is the failure this script
|
||||
* exists to catch, and it is not hypothetical — three separate checks in this
|
||||
* repository have been found green because their matcher matched nothing.
|
||||
*
|
||||
* So the rule here is that a count is asserted before it is used, and asserted
|
||||
* against a number written down rather than against "more than zero" alone.
|
||||
* A list that lost half its entries is not caught by a non-empty check.
|
||||
*
|
||||
* ## What this is NOT
|
||||
*
|
||||
* It does not run the suites and it cannot tell whether the tests that consume
|
||||
* these inventories assert anything worth asserting. It answers one question —
|
||||
* "is there something in each of these, and is it the amount there is supposed
|
||||
* to be" — and leaves the rest to the tests themselves.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* pnpm dlx tsx scripts/verify-v137-adversarial.ts # everything
|
||||
* pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-inventories # counts only
|
||||
* pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-migrations # allocation only
|
||||
*
|
||||
* Exits non-zero on the first failing assertion, and prints every check it ran
|
||||
* so a run that checked nothing is visible as a run that printed nothing.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
ACCESSIBILITY_STATES,
|
||||
SHARING_ACCESSIBILITY_STATES,
|
||||
V137_E2E_JOURNEYS,
|
||||
} from "../tests/fixtures/v137/e2e-journeys";
|
||||
import { NOTIFICATION_DELIVERY_MATRIX } from "../tests/fixtures/v137/notification-matrix";
|
||||
import { SECURITY_PRINCIPALS } from "../tests/fixtures/v137/security-principals";
|
||||
import { ADMITTED_MUTATING_HANDLERS } from "../tests/fixtures/v137/sharing-matrix";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function check(label: string, ok: boolean, detail: string): void {
|
||||
checks += 1;
|
||||
if (ok) {
|
||||
console.log(` ok ${label} — ${detail}`);
|
||||
return;
|
||||
}
|
||||
console.error(` FAIL ${label} — ${detail}`);
|
||||
failures += 1;
|
||||
}
|
||||
|
||||
function expectCount(label: string, actual: number, expected: number): void {
|
||||
check(label, actual === expected, `${actual} (expected ${expected})`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Migration allocation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The release's allocation, corrected. The synthesis said 0296-0299 and called
|
||||
* the range frozen; the release shipped seven. The list is here so a builder
|
||||
* inventing an eighth number, or a merge quietly renumbering one of these,
|
||||
* fails rather than being noticed at the release audit or not at all.
|
||||
*/
|
||||
const MIGRATION_ALLOCATION = [
|
||||
"0296_account_grant_scope_and_manage",
|
||||
"0297_managed_profile",
|
||||
"0298_notification_delivery_identity",
|
||||
"0299_medication_intake_import_actor_attribution",
|
||||
"0300_notification_event_created_at_index",
|
||||
"0301_notification_egress_authorization",
|
||||
"0302_session_record_epoch",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Triggers this release installs. Neither is modelled in the Prisma schema, so
|
||||
* `migrate diff` has nothing to compare them against and cannot report one
|
||||
* going missing — the drift check is structurally blind here. Their presence in
|
||||
* the migration text is the cheapest honest signal; the behaviour is proved by
|
||||
* the integration suites named beside each one.
|
||||
*/
|
||||
const MIGRATION_TRIGGERS: ReadonlyArray<{
|
||||
trigger: string;
|
||||
migration: string;
|
||||
provenBy: string;
|
||||
}> = [
|
||||
{
|
||||
trigger: "push_attempt_attribution_guard",
|
||||
migration: "0298_notification_delivery_identity",
|
||||
provenBy: "tests/integration/notification-attribution.test.ts",
|
||||
},
|
||||
{
|
||||
trigger: "sessions_record_epoch_bump",
|
||||
migration: "0302_session_record_epoch",
|
||||
provenBy: "tests/integration/record-session-epoch-trigger.test.ts",
|
||||
},
|
||||
];
|
||||
|
||||
function checkMigrations(): void {
|
||||
console.log("migration allocation");
|
||||
const dir = join(ROOT, "prisma/migrations");
|
||||
const present = readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
|
||||
check(
|
||||
"the migrations directory really loaded",
|
||||
present.length > 100,
|
||||
`${present.length} migrations on disk`,
|
||||
);
|
||||
|
||||
const release = present.filter((name) => /^(029[6-9]|030[0-2])_/.test(name));
|
||||
expectCount(
|
||||
"the release allocates exactly seven",
|
||||
release.length,
|
||||
MIGRATION_ALLOCATION.length,
|
||||
);
|
||||
|
||||
for (const expected of MIGRATION_ALLOCATION) {
|
||||
check(
|
||||
`allocated ${expected.slice(0, 4)}`,
|
||||
present.includes(expected),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
// Every number used once. A collision is the thing renumbering exists to
|
||||
// prevent, and two directories sharing a prefix apply in an order nobody
|
||||
// chose.
|
||||
const releaseNumbers = release.map((name) => name.slice(0, 4));
|
||||
const duplicated = releaseNumbers.filter(
|
||||
(n, i) => releaseNumbers.indexOf(n) !== i,
|
||||
);
|
||||
check(
|
||||
"no number is allocated twice in the release range",
|
||||
duplicated.length === 0,
|
||||
duplicated.length === 0 ? "none" : duplicated.join(", "),
|
||||
);
|
||||
|
||||
for (const { trigger, migration, provenBy } of MIGRATION_TRIGGERS) {
|
||||
const sql = readFileSync(join(dir, migration, "migration.sql"), "utf8");
|
||||
check(
|
||||
`${migration} installs ${trigger}`,
|
||||
sql.includes(trigger),
|
||||
`behaviour proved by ${provenBy}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Inventories */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function checkInventories(): void {
|
||||
console.log("inventories");
|
||||
|
||||
// Handlers. Every admitted mutation is meant to run level, owner, actor,
|
||||
// effect and encrypted-field cases; an empty list runs none of them and the
|
||||
// matrix test reports the same green either way.
|
||||
check(
|
||||
"the admitted mutating handlers are non-empty",
|
||||
ADMITTED_MUTATING_HANDLERS.length > 0,
|
||||
`${ADMITTED_MUTATING_HANDLERS.length} handlers`,
|
||||
);
|
||||
// Keyed on route AND action, because one route legitimately appears more
|
||||
// than once: an `[id]` route carries an update and a delete, and they are
|
||||
// different contracts with different admitted levels. Keying on the route
|
||||
// alone reported eleven false collisions on the first run of this script.
|
||||
const handlerKeys = new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map((h) => `${h.action} ${h.route}`),
|
||||
);
|
||||
expectCount(
|
||||
"no route and action pair is admitted twice",
|
||||
handlerKeys.size,
|
||||
ADMITTED_MUTATING_HANDLERS.length,
|
||||
);
|
||||
check(
|
||||
"every handler names a module that exists",
|
||||
ADMITTED_MUTATING_HANDLERS.every((h) => {
|
||||
try {
|
||||
return (
|
||||
readFileSync(join(ROOT, "src", h.handlerModule), "utf8").length > 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
"each handlerModule resolves under src/",
|
||||
);
|
||||
|
||||
// The notification matrix is two records by two Guardians by four channels.
|
||||
// Written as a product, so a dropped channel shows up here as sixteen minus
|
||||
// four rather than as a suite that simply ran fewer cases.
|
||||
expectCount(
|
||||
"the notification delivery matrix is 2 records x 2 Guardians x 4 channels",
|
||||
NOTIFICATION_DELIVERY_MATRIX.length,
|
||||
16,
|
||||
);
|
||||
check(
|
||||
"no delivery in the matrix is interactive",
|
||||
NOTIFICATION_DELIVERY_MATRIX.every((row) => row.interactive === false),
|
||||
"a Guardian's notification offers no action buttons",
|
||||
);
|
||||
|
||||
// Security principals. Each names an actor, a record and a recipient; a
|
||||
// principal missing one of the three would let a test assert against
|
||||
// undefined and pass.
|
||||
const principals = Object.entries(SECURITY_PRINCIPALS);
|
||||
check(
|
||||
"the security principals are non-empty",
|
||||
principals.length > 0,
|
||||
`${principals.length} principals`,
|
||||
);
|
||||
for (const [name, principal] of principals) {
|
||||
const { actorUserId, recordUserId, recipientUserId } = principal.identities;
|
||||
check(
|
||||
`${name} names all three identities`,
|
||||
Boolean(actorUserId && recordUserId && recipientUserId),
|
||||
`${actorUserId} / ${recordUserId} / ${recipientUserId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Accessibility states and journeys. Both are enumerations the browser suite
|
||||
// is read back against, and both are exact rather than non-empty: a ninth
|
||||
// journey nobody counted and a fifth state quietly absorbed into a fourth are
|
||||
// the two failures the enumeration exists to make visible.
|
||||
expectCount(
|
||||
"exactly five accessibility states",
|
||||
ACCESSIBILITY_STATES.length,
|
||||
5,
|
||||
);
|
||||
expectCount(
|
||||
"every state carries a case",
|
||||
SHARING_ACCESSIBILITY_STATES.length,
|
||||
5,
|
||||
);
|
||||
expectCount("exactly eight journeys", V137_E2E_JOURNEYS.length, 8);
|
||||
expectCount(
|
||||
"every journey id is distinct",
|
||||
new Set(V137_E2E_JOURNEYS.map((j) => j.id)).size,
|
||||
8,
|
||||
);
|
||||
|
||||
// Each journey id must appear in a test title somewhere under e2e/ or tests/
|
||||
// or src/__tests__/. `sharing-accessibility-states.test.tsx` pins WHICH file
|
||||
// carries each one; this leg is the cheaper cross-check that none of the
|
||||
// eight has become a claim with no test anywhere at all.
|
||||
const searched = [
|
||||
...readdirSync(join(ROOT, "e2e")).map((f) => join("e2e", f)),
|
||||
...readdirSync(join(ROOT, "tests/integration")).map((f) =>
|
||||
join("tests/integration", f),
|
||||
),
|
||||
...readdirSync(join(ROOT, "src/__tests__")).map((f) =>
|
||||
join("src/__tests__", f),
|
||||
),
|
||||
].filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
|
||||
check(
|
||||
"the test tree really loaded",
|
||||
searched.length > 50,
|
||||
`${searched.length} files scanned`,
|
||||
);
|
||||
const corpus = searched
|
||||
.map((f) => readFileSync(join(ROOT, f), "utf8"))
|
||||
.join("\n");
|
||||
for (const journey of V137_E2E_JOURNEYS) {
|
||||
check(
|
||||
`${journey.id} is marked in a test title`,
|
||||
corpus.includes(`[${journey.id}]`),
|
||||
journey.claim.slice(0, 60) + "…",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const only = argv.filter((a) => a.startsWith("--"));
|
||||
const runMigrations = only.length === 0 || only.includes("--check-migrations");
|
||||
const runInventories =
|
||||
only.length === 0 || only.includes("--check-inventories");
|
||||
|
||||
if (runMigrations) checkMigrations();
|
||||
if (runInventories) checkInventories();
|
||||
|
||||
// A run that asserted nothing is a run that proves nothing, and it would exit 0.
|
||||
if (checks === 0) {
|
||||
console.error("no checks ran — refusing to report success");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${checks} check(s), ${failures} failure(s)` +
|
||||
(failures === 0 ? " — inventories are non-empty and exact" : ""),
|
||||
);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -80,6 +80,19 @@ const NOT_A_CONSUMER = new Set([
|
||||
join(SRC, "app", "api", "auth", "me", "route.ts"),
|
||||
]);
|
||||
|
||||
const ACCOUNT_ENTRY_CONSUMER = join(
|
||||
SRC,
|
||||
"components",
|
||||
"dashboard",
|
||||
"looking-after-card.tsx",
|
||||
);
|
||||
const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts");
|
||||
const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [
|
||||
"level",
|
||||
"sections",
|
||||
"recordKind",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fields with no client reader, each with the reason it is nonetheless
|
||||
* correct for the payload to carry it. An entry here is a claim; write one
|
||||
@@ -192,4 +205,48 @@ describe("account payload consumer guard", () => {
|
||||
expect(reason.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it("has a consumer that ADOPTS the record-session context, not only a type for it", () => {
|
||||
// `recordSession` is the field the fence hands the browser, and the whole
|
||||
// point of it is that a client adopts the server's value rather than
|
||||
// deriving one. The generic sweep above is satisfied by any read anywhere;
|
||||
// this leg names the two places that have to do the adopting, because a
|
||||
// payload field read only by the transport type is the shape the file's
|
||||
// opening paragraph is about.
|
||||
expect(fields).toContain("recordSession");
|
||||
|
||||
const adopters = [
|
||||
join(SRC, "hooks", "use-account-switch.ts"),
|
||||
join(SRC, "hooks", "use-record-capabilities.ts"),
|
||||
];
|
||||
let reads = 0;
|
||||
for (const file of adopters) {
|
||||
expect(stripComments(readFileSync(file, "utf8"))).toMatch(
|
||||
/recordSession/,
|
||||
);
|
||||
reads += 1;
|
||||
}
|
||||
expect(reads).toBe(adopters.length);
|
||||
expect(reads).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has non-zero entry and active-record readers for additive access fields", () => {
|
||||
const entrySource = stripComments(
|
||||
readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"),
|
||||
);
|
||||
const activeSource = stripComments(
|
||||
readFileSync(ACTIVE_RECORD_CONSUMER, "utf8"),
|
||||
);
|
||||
|
||||
const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce(
|
||||
(count, field) => {
|
||||
expect(entrySource).toMatch(new RegExp(`entry\\.${field}(?![\\w$])`));
|
||||
expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
|
||||
return count + 2;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
expect(readerCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,20 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
// whole auth context for exactly this reason: had it taken the id, the
|
||||
// route would name the column too, and the list would grow per surface.
|
||||
"lib/sharing/account-access.ts",
|
||||
// v1.37.0 — the record-session fence COMPARES it and never writes it. It
|
||||
// reads the value off the `AuthContext` the resolver already holds rather
|
||||
// than from the row, which is the property the fence rests on: the scope
|
||||
// the handler serves under and the epoch the fence validated are one row
|
||||
// read, so they cannot straddle a switch. A version of this file that
|
||||
// resolved its own session would be a second answer to "whose record is
|
||||
// this" and would also be wrong more often.
|
||||
"lib/sharing/record-session-fence.ts",
|
||||
// v1.37.0 — names the column in prose only. The matcher does not exempt
|
||||
// comments and is left that way on purpose (see the note above), so the
|
||||
// module that explains the fence has to be listed. It imports nothing at
|
||||
// all, which the fence's own unit test asserts, so it can read no column
|
||||
// even in principle.
|
||||
"lib/sharing/record-session-fence-contract.ts",
|
||||
].sort();
|
||||
|
||||
it("no other file reads or writes the carrier column", () => {
|
||||
@@ -125,7 +139,7 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
* Who may ask "which record does this request claim" without an auth
|
||||
* context in hand.
|
||||
*
|
||||
* `readClaimedActingAccount()` answers before any grant check has run, so a
|
||||
* `readClaimedRecordContext()` answers before any grant check has run, so a
|
||||
* caller that treats its answer as permission has skipped the only thing
|
||||
* that grants any. Exactly one caller exists, beside the file that declares
|
||||
* it: the idempotency wrapper, which uses the claim to pick a cache cell and
|
||||
@@ -133,9 +147,17 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
* acting account has an auth context by then and goes through
|
||||
* `requireRecordAuth`, which checks a live grant before returning. A second
|
||||
* caller here is a design question, not a patch.
|
||||
*
|
||||
* The matcher names the v1.36.0 spelling (`readClaimedActingAccount`) as well
|
||||
* as today's. That function was removed in v1.37.0 when the wrapper started
|
||||
* needing the session's record context on the same read; matching both means
|
||||
* a reintroduced narrow copy trips this leg instead of quietly becoming a
|
||||
* second answer.
|
||||
*/
|
||||
it("the unchecked claim has exactly two consumers", () => {
|
||||
const consumers = filesMatching(/readClaimedActingAccount/);
|
||||
const consumers = filesMatching(
|
||||
/readClaimedRecordContext|readClaimedActingAccount/,
|
||||
);
|
||||
expect(consumers.length).toBeGreaterThan(0);
|
||||
expect(consumers).toEqual([
|
||||
"lib/auth/acting-carrier.ts",
|
||||
@@ -168,16 +190,25 @@ describe("the cookie-only helpers cannot see the acting account", () => {
|
||||
expect(body).not.toContain("readActingCarrier");
|
||||
});
|
||||
|
||||
it("substitutes a data-scope user in exactly one function", () => {
|
||||
it("substitutes a data-scope user in exactly two functions", () => {
|
||||
const src = read("lib/api-handler.ts");
|
||||
const record = functionBody(src, "requireRecordAuth");
|
||||
expect(record).toContain("readActingCarrier");
|
||||
|
||||
// v1.37.0 — the guardian resolver is the second function that acts on the
|
||||
// carrier, and it is a second declaration rather than a level on the
|
||||
// first: it admits the surfaces of a record NOBODY runs themselves, gated
|
||||
// on the managed-profile marker rather than on the grant.
|
||||
const guardian = functionBody(src, "requireGuardianAuth");
|
||||
expect(guardian.length).toBeGreaterThan(0);
|
||||
expect(guardian).toContain("readActingCarrier");
|
||||
expect(guardian).toContain("managedProfileAt");
|
||||
|
||||
// `readActingCarrier` is what turns a request into "acting as somebody
|
||||
// else". Two callers: the bare path, which refuses on it, and the record
|
||||
// path, which acts on it. A third would be a mode nobody declared.
|
||||
// else". Three callers: the bare path, which refuses on it, and the two
|
||||
// record paths, which act on it. A fourth would be a mode nobody declared.
|
||||
const callers = (src.match(/await readActingCarrier\(/g) ?? []).length;
|
||||
expect(callers).toBe(2);
|
||||
expect(callers).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -146,6 +146,15 @@ describe("every schema model has a backup verdict", () => {
|
||||
"a one-word reason is how a decision stops being reviewable",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes Guardian egress claims rather than exporting delivery metadata", () => {
|
||||
expect(backupVerdict("NotificationEgressAuthorization")).toBe(
|
||||
"NOT_IN_BACKUP",
|
||||
);
|
||||
expect(NOT_IN_BACKUP_MODELS.NotificationEgressAuthorization).toMatch(
|
||||
/no notification content, credentials, or deduplication key/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -341,4 +341,10 @@ describe("data-wipe completeness", () => {
|
||||
});
|
||||
expect(wipeWhere("Measurement", "u1")).toEqual({ userId: "u1" });
|
||||
});
|
||||
|
||||
it("selects both record and recipient ownership of a Guardian egress claim", () => {
|
||||
expect(wipeWhere("NotificationEgressAuthorization", "u1")).toEqual({
|
||||
OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
|
||||
|
||||
describe("managed Guardian notification payload safety", () => {
|
||||
it("recognises only cross-account record delivery as managed Guardian delivery", () => {
|
||||
expect(
|
||||
isManagedGuardianDelivery({
|
||||
recordUserId: "record-1",
|
||||
recipientUserId: "guardian-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isManagedGuardianDelivery({
|
||||
recordUserId: "user-1",
|
||||
recipientUserId: "user-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findUnique, findMany } = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique },
|
||||
accountGrant: { findMany },
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
isManagedGuardianFanoutAllowed,
|
||||
resolveManagedGuardianRecipientIds,
|
||||
} from "@/lib/notifications/delivery-identity";
|
||||
|
||||
const recordUserId = "managed-record";
|
||||
|
||||
function payload(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
eventType: "MEDICATION_REMINDER" as const,
|
||||
userId: recordUserId,
|
||||
title: "Record content",
|
||||
message: "Record schedule",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
findUnique.mockResolvedValue({ managedProfileAt: new Date() });
|
||||
findMany.mockResolvedValue([
|
||||
{ granteeId: "guardian-one", grantee: { managedProfileAt: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("guardian notification policy — allowlist and eligibility", () => {
|
||||
it("allows exactly medication, measurement, low-stock, and explicitly tagged safety-floor alerts", () => {
|
||||
expect(isManagedGuardianFanoutAllowed(payload())).toBe(true);
|
||||
expect(
|
||||
isManagedGuardianFanoutAllowed(
|
||||
payload({ eventType: "MEASUREMENT_REMINDER" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isManagedGuardianFanoutAllowed(
|
||||
payload({ eventType: "MEDICATION_LOW_STOCK" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isManagedGuardianFanoutAllowed(
|
||||
payload({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
managedFanoutEvent: "SAFETY_FLOOR_ALERT",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isManagedGuardianFanoutAllowed(payload({ eventType: "SYSTEM_ALERT" })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isManagedGuardianFanoutAllowed(
|
||||
payload({ eventType: "MEASUREMENT_ANOMALY" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isManagedGuardianFanoutAllowed(
|
||||
payload({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
managedFanoutEvent: "ILLNESS_RED_FLAG",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("enumerates only active Guardians of the marked record", async () => {
|
||||
await expect(
|
||||
resolveManagedGuardianRecipientIds(payload()),
|
||||
).resolves.toEqual(["guardian-one"]);
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({ grantorId: recordUserId }),
|
||||
select: {
|
||||
granteeId: true,
|
||||
grantee: { select: { managedProfileAt: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fan out unmarked records, disallowed events, self delivery, or invalid Guardians", async () => {
|
||||
findUnique.mockResolvedValueOnce({ managedProfileAt: null });
|
||||
await expect(
|
||||
resolveManagedGuardianRecipientIds(payload()),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
resolveManagedGuardianRecipientIds(
|
||||
payload({ eventType: "MEASUREMENT_ANOMALY" }),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
|
||||
findMany.mockResolvedValueOnce([
|
||||
{ granteeId: recordUserId, grantee: { managedProfileAt: null } },
|
||||
{
|
||||
granteeId: "managed-guardian",
|
||||
grantee: { managedProfileAt: new Date() },
|
||||
},
|
||||
{ granteeId: "guardian-two", grantee: { managedProfileAt: null } },
|
||||
]);
|
||||
await expect(
|
||||
resolveManagedGuardianRecipientIds(payload()),
|
||||
).resolves.toEqual(["guardian-two"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { revokeBody } from "@/components/settings/access/grants-given-card";
|
||||
import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb";
|
||||
|
||||
/**
|
||||
* Every MANAGE-admitted action names itself, in all six locales.
|
||||
*
|
||||
* ## What this asks that the sibling guard does not
|
||||
*
|
||||
* `src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts` asks the
|
||||
* same question of the WRITE surface: a delegable write whose audit action has
|
||||
* no sentence renders as "made a change". MANAGE is where that answer stops
|
||||
* being merely vague and starts being wrong. A WRITE delegate can only add,
|
||||
* and "somebody added something" is at least the right shape of fact. A MANAGE
|
||||
* delegate can change and remove entries the owner made themselves, so the
|
||||
* generic line would tell an owner that something happened in their record
|
||||
* while withholding the only part they need — whether a reading was corrected
|
||||
* or deleted, whether a medication was stopped, whether the dose history was
|
||||
* purged. The level ships under "reconstructable or refused", and a feed that
|
||||
* cannot say which verb ran does not reconstruct anything.
|
||||
*
|
||||
* So the inventory here is the MANAGE half of the same sweep, and it goes two
|
||||
* steps further than its sibling:
|
||||
*
|
||||
* 1. it resolves the sentence by CALLING the map rather than by matching
|
||||
* `case "…"` in its source, because a `case` inside a comment matches and
|
||||
* an action whose arm falls through to the fallback does not;
|
||||
* 2. it follows the resolved key into all six bundles, because a key that
|
||||
* exists only in English renders as its own dotted path to everyone else.
|
||||
*
|
||||
* ## PHI
|
||||
*
|
||||
* Two legs, and both are about the same failure. The activity feed reads out
|
||||
* of the audit table, the audit table is not encrypted, and the temptation
|
||||
* whenever a sentence feels thin is to put the value in it — "{name} changed
|
||||
* your weight from 81 to 78". That is a health figure in a second store with
|
||||
* its own retention and no rotation story. So: every verb line interpolates
|
||||
* `name` and nothing else, in every locale, and the feed's own DTO carries no
|
||||
* `details` field for a future caller to reach for.
|
||||
*
|
||||
* ## Why the counts are of MATCHES
|
||||
*
|
||||
* The verdict is an empty list of unmapped actions, and an empty match set
|
||||
* says exactly the same thing. Every leg therefore pins the size of what it
|
||||
* matched — modules walked, MANAGE modules found, one of them named, actions
|
||||
* read, sentences resolved — so a matcher that quietly stopped matching fails
|
||||
* instead of passing. `describe("the matcher sees what it looks for")` proves
|
||||
* both halves against synthetic modules, including the one shape that silenced
|
||||
* the sibling guard: a resolver call prettier broke across lines.
|
||||
*/
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const API = join(SRC, "app/api");
|
||||
const MESSAGES = join(process.cwd(), "messages");
|
||||
const ACTIVITY_ROUTE = join(
|
||||
process.cwd(),
|
||||
"src/app/api/account/activity/route.ts",
|
||||
);
|
||||
const GRANTS_GIVEN_CARD = join(
|
||||
process.cwd(),
|
||||
"src/components/settings/access/grants-given-card.tsx",
|
||||
);
|
||||
|
||||
/** The declaration a record-delegated route makes, at any level. */
|
||||
const RECORD_RESOLVER = "requireRecordAuth";
|
||||
|
||||
/**
|
||||
* Rows the feed cannot show, and the reason each one cannot.
|
||||
*
|
||||
* `src/lib/api-handler.ts` is a direct import of every route, so the walk
|
||||
* below reaches the three actions the auth kit files itself. None of them can
|
||||
* appear in a record owner's activity view, and the reason is the same for all
|
||||
* three: they are filed under `auth.user.id` — the CALLER — at a point where
|
||||
* no record has been resolved. The feed selects rows where `userId` is the
|
||||
* owner and `actorUserId` is somebody else, so a caller-filed row is excluded
|
||||
* twice over.
|
||||
*
|
||||
* Frozen at three. A fourth entry is a claim about a row nobody looked at, and
|
||||
* the leg below fails rather than letting one join quietly.
|
||||
*/
|
||||
const NOT_IN_THE_FEED = [
|
||||
// The bearer path never resolves a record at all.
|
||||
"auth.bearer.failure",
|
||||
// Step-up is cookie-only and filed under the account being elevated.
|
||||
"auth.stepup.elevation.rejected",
|
||||
// `auditRefusal` files under the refused caller, before a record exists.
|
||||
"sharing.access.denied",
|
||||
] as const;
|
||||
/** What an action with no sentence of its own renders as. */
|
||||
const FALLBACK_KEY = "recordSharing.activity.acted";
|
||||
/** The six bundles the product ships. */
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
|
||||
* `*` skips a leading dot and this tree has dot-directories. */
|
||||
function routeFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...routeFiles(full));
|
||||
else if (entry.name === "route.ts") out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ModuleFacts {
|
||||
/** Does the module reach the record resolver at ANY level. */
|
||||
delegatesRecord: boolean;
|
||||
/**
|
||||
* Every level the module passes to `requireRecordAuth`, in source order.
|
||||
*
|
||||
* Recorded so the inclusion rule's LEVEL axis has something that can
|
||||
* falsify it. `delegatesRecord` used to be set only for `"manage"`, and the
|
||||
* legs below could not tell the corrected rule from the old one: every
|
||||
* assertion about coverage passed either way, because a manage-only sweep
|
||||
* still finds every manage action. The leg that separates them asks whether
|
||||
* the walked set STRICTLY EXCEEDS the manage-only one, and it needs the
|
||||
* levels to ask.
|
||||
*/
|
||||
recordLevels: string[];
|
||||
/**
|
||||
* Every action the module hands to `auditLog("…")`, awaited or not.
|
||||
*
|
||||
* v1.37.0 — this used to keep only the AWAITED calls, on the reasoning that
|
||||
* a fire-and-forget breadcrumb is not worth narrating. The feed disagrees,
|
||||
* and the feed is what the owner reads: `/api/account/activity` selects on
|
||||
* `actorUserId IS NOT NULL` with no action filter and no await in sight, so
|
||||
* a `void auditLog(…)` breadcrumb lands in the view exactly like an awaited
|
||||
* row. Ten `*.validation-failed` actions were invisible to this guard for
|
||||
* that reason and rendered to owners as "made a change in your record" —
|
||||
* including one a READ delegate can trigger. A guard whose inclusion rule
|
||||
* differs from its subject's proves a property about the wrong set.
|
||||
*/
|
||||
auditActions: string[];
|
||||
/** First-party modules this one imports, resolved to files. */
|
||||
imports: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read both facts off one parse.
|
||||
*
|
||||
* AST rather than substrings, for the reason the sibling guard learned by
|
||||
* going green while matching nothing: prettier moves a call's argument onto
|
||||
* its own line the moment the call grows a second one, and
|
||||
* `requireRecordAuth("manage"` stops appearing in the source text without
|
||||
* anything about the route changing. The import table resolves a local name
|
||||
* back to its exported one, so an alias and a namespace-qualified call both
|
||||
* count, and a comment naming either symbol counts for nothing.
|
||||
*/
|
||||
export function moduleFacts(source: string, fileName: string): ModuleFacts {
|
||||
const parsed = ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
const aliases = new Map<string, string>();
|
||||
const namespaces = new Set<string>();
|
||||
const importPaths: string[] = [];
|
||||
parsed.forEachChild((node) => {
|
||||
if (!ts.isImportDeclaration(node)) return;
|
||||
if (ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName);
|
||||
if (resolved !== null && !resolved.includes("__tests__")) {
|
||||
importPaths.push(resolved);
|
||||
}
|
||||
}
|
||||
const bindings = node.importClause?.namedBindings;
|
||||
if (!bindings) return;
|
||||
if (ts.isNamespaceImport(bindings)) {
|
||||
namespaces.add(bindings.name.text);
|
||||
return;
|
||||
}
|
||||
for (const spec of bindings.elements) {
|
||||
aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text);
|
||||
}
|
||||
});
|
||||
|
||||
/** The original exported name behind this callee, or null. */
|
||||
const calleeName = (callee: ts.Expression): string | null => {
|
||||
if (ts.isIdentifier(callee)) return aliases.get(callee.text) ?? callee.text;
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
ts.isIdentifier(callee.expression) &&
|
||||
namespaces.has(callee.expression.text)
|
||||
) {
|
||||
return callee.name.text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The first argument when it is a plain string literal. */
|
||||
const firstStringArg = (node: ts.CallExpression): string | null => {
|
||||
const first = node.arguments[0];
|
||||
return first && ts.isStringLiteralLike(first) ? first.text : null;
|
||||
};
|
||||
|
||||
let delegatesRecord = false;
|
||||
const recordLevels: string[] = [];
|
||||
const auditActions: string[] = [];
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const name = calleeName(node.expression);
|
||||
if (name === RECORD_RESOLVER) {
|
||||
delegatesRecord = true;
|
||||
const level = firstStringArg(node);
|
||||
if (level !== null) recordLevels.push(level);
|
||||
}
|
||||
if (name === "auditLog") {
|
||||
const action = firstStringArg(node);
|
||||
if (action !== null) auditActions.push(action);
|
||||
}
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
parsed.forEachChild(visit);
|
||||
|
||||
return { delegatesRecord, recordLevels, auditActions, imports: importPaths };
|
||||
}
|
||||
|
||||
/**
|
||||
* A module specifier resolved to a first-party file, or null.
|
||||
*
|
||||
* `@/…` and relative paths only. A package import is somebody else's tree and
|
||||
* cannot be walked usefully.
|
||||
*/
|
||||
export function resolveFirstParty(
|
||||
specifier: string,
|
||||
fromFile: string,
|
||||
): string | null {
|
||||
let base: string;
|
||||
if (specifier.startsWith("@/")) base = join(SRC, specifier.slice(2));
|
||||
else if (specifier.startsWith(".")) base = join(dirname(fromFile), specifier);
|
||||
else return null;
|
||||
for (const candidate of [
|
||||
`${base}.ts`,
|
||||
`${base}.tsx`,
|
||||
join(base, "index.ts"),
|
||||
]) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every action one route module can file, including through what it imports.
|
||||
*
|
||||
* One level deep, deliberately. The transitive closure pulls in the whole auth
|
||||
* and grant machinery through `api-handler`, which reports actions no request
|
||||
* to this route can file; one level is where a route's own collaborators live
|
||||
* — the import-admission module and the medication lifecycle module are both
|
||||
* direct imports of the routes that use them, and both were invisible to a
|
||||
* walk that read only `route.ts`.
|
||||
*/
|
||||
function actionsReachableFrom(
|
||||
file: string,
|
||||
read: (path: string) => ModuleFacts,
|
||||
): string[] {
|
||||
const own = read(file);
|
||||
const out = [...own.auditActions];
|
||||
for (const imported of own.imports) {
|
||||
out.push(...read(imported).auditActions);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The message key one action resolves to, or the fallback. */
|
||||
function verbKeyFor(action: string): { key: string; params: string[] } {
|
||||
let key = "";
|
||||
let params: string[] = [];
|
||||
recordActivityVerbLine(
|
||||
(resolved, given) => {
|
||||
key = resolved;
|
||||
params = Object.keys(given ?? {});
|
||||
return resolved;
|
||||
},
|
||||
action,
|
||||
"Alex",
|
||||
);
|
||||
return { key, params };
|
||||
}
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The leaf at a dotted path, or undefined. */
|
||||
function leaf(root: Record<string, unknown>, key: string): unknown {
|
||||
let node: unknown = root;
|
||||
for (const segment of key.split(".")) {
|
||||
if (typeof node !== "object" || node === null) return undefined;
|
||||
node = (node as Record<string, unknown>)[segment];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The `{placeholders}` a translated sentence interpolates. */
|
||||
function placeholders(sentence: string): string[] {
|
||||
return [...sentence.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
|
||||
}
|
||||
|
||||
describe("the matcher sees what it looks for", () => {
|
||||
const REFORMATTED = `
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth(
|
||||
"manage",
|
||||
"labs",
|
||||
);
|
||||
await auditLog("thing.delete", { userId: user.id });
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
|
||||
it("matches a resolver call broken across lines", () => {
|
||||
// The shape that silenced the sibling guard: the argument moves onto its
|
||||
// own line and a substring test goes quiet with nothing else changed.
|
||||
expect(REFORMATTED.includes('requireRecordAuth("manage"')).toBe(false);
|
||||
const facts = moduleFacts(REFORMATTED, "r.ts");
|
||||
expect(facts.delegatesRecord).toBe(true);
|
||||
expect(facts.auditActions).toEqual(["thing.delete"]);
|
||||
});
|
||||
|
||||
it("matches an aliased and a namespace-qualified resolver call", () => {
|
||||
const ALIASED = `
|
||||
import { requireRecordAuth as resolveRecord } from "@/lib/api-handler";
|
||||
import { auditLog as record } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await resolveRecord("manage", "labs");
|
||||
await record("thing.aliased");
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
const facts = moduleFacts(ALIASED, "a.ts");
|
||||
expect(facts.delegatesRecord).toBe(true);
|
||||
expect(facts.auditActions).toEqual(["thing.aliased"]);
|
||||
|
||||
const NAMESPACED = `
|
||||
import * as auth from "@/lib/api-handler";
|
||||
|
||||
export const DELETE = auth.apiHandler(async () => {
|
||||
const { user } = await auth.requireRecordAuth("manage", "labs");
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(NAMESPACED, "n.ts").delegatesRecord).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enrol a route that only talks about delegating", () => {
|
||||
const ACTOR_ONLY = `
|
||||
/** An actor surface. It must never take requireRecordAuth("manage"). */
|
||||
import { requireActorAuth } from "@/lib/api-handler";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireActorAuth();
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
// The raw text carries the exact substring a naive membership test uses.
|
||||
expect(ACTOR_ONLY.includes('requireRecordAuth("manage")')).toBe(true);
|
||||
expect(moduleFacts(ACTOR_ONLY, "ao.ts").delegatesRecord).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the fire-and-forget audit calls too", () => {
|
||||
// The defect this file shipped with. `void auditLog(…)` was invisible
|
||||
// here and perfectly visible in the owner's feed, so ten refusal
|
||||
// breadcrumbs rendered as "made a change in your record".
|
||||
const MIXED = `
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
void auditLog("thing.breadcrumb", {});
|
||||
await auditLog("thing.delete", { userId: user.id });
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(MIXED, "m.ts").auditActions).toEqual([
|
||||
"thing.breadcrumb",
|
||||
"thing.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves a first-party import and skips a package one", () => {
|
||||
// The other half of the same defect: two of the twelve unmapped actions
|
||||
// were filed by library modules the routes import, which a walk over
|
||||
// `route.ts` alone could not see.
|
||||
const admission = resolveFirstParty(
|
||||
"@/lib/medications/intake-import-admission",
|
||||
join(API, "medications/route.ts"),
|
||||
);
|
||||
expect(admission).not.toBeNull();
|
||||
expect(readFileSync(admission!, "utf8")).toContain(
|
||||
"medication.intake.import.kickoff.denied",
|
||||
);
|
||||
expect(resolveFirstParty("next/server", "x.ts")).toBeNull();
|
||||
expect(resolveFirstParty("@/lib/nothing-here", "x.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("still reports the fallback for an action nobody mapped", () => {
|
||||
// The positive control for the recording translator below. Without it, a
|
||||
// `verbKeyFor` that always returned the same string would report every
|
||||
// action as specific and this file would prove nothing.
|
||||
expect(verbKeyFor("nothing.anybody.mapped").key).toBe(FALLBACK_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every delegated action has a sentence of its own", () => {
|
||||
const routes = routeFiles(API);
|
||||
const cache = new Map<string, ModuleFacts>();
|
||||
const read = (file: string): ModuleFacts => {
|
||||
let facts = cache.get(file);
|
||||
if (!facts) {
|
||||
facts = moduleFacts(readFileSync(file, "utf8"), file);
|
||||
cache.set(file, facts);
|
||||
}
|
||||
return facts;
|
||||
};
|
||||
|
||||
const delegatedModules = routes.filter((file) => read(file).delegatesRecord);
|
||||
const excluded = new Set<string>(NOT_IN_THE_FEED);
|
||||
const reachable = [
|
||||
...new Set(
|
||||
delegatedModules.flatMap((file) => actionsReachableFrom(file, read)),
|
||||
),
|
||||
].sort();
|
||||
const actions = reachable.filter((action) => !excluded.has(action));
|
||||
|
||||
it("matches the delegated modules, and not zero of them", () => {
|
||||
// A walk that finds nothing agrees with an inventory that covers nothing.
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
expect(delegatedModules.length).toBeGreaterThan(0);
|
||||
// Named anchors, so a matcher that collapsed to one stray file fails
|
||||
// rather than clearing the floor. Deleting a reading and purging a dose
|
||||
// history are the two acts MANAGE exists for; the mood list is the READ
|
||||
// route whose refusal breadcrumb reaches the feed, and it is here because
|
||||
// the level is no longer part of the inclusion rule.
|
||||
const found = delegatedModules.map((file) => relative(process.cwd(), file));
|
||||
expect(found).toContain("src/app/api/measurements/[id]/route.ts");
|
||||
expect(found).toContain(
|
||||
"src/app/api/medications/[id]/intake/purge/route.ts",
|
||||
);
|
||||
expect(found).toContain("src/app/api/mood-entries/route.ts");
|
||||
// And the actions themselves were read, not merely looked for.
|
||||
expect(actions.length).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
it("walks strictly more modules than a manage-only sweep would", () => {
|
||||
// The level axis of the inclusion rule, with something that can falsify
|
||||
// it. Every other leg in this file passes under BOTH rules — a manage-only
|
||||
// sweep still finds every manage action, so coverage cannot tell the two
|
||||
// apart. This one can: the corrected rule is "any module that reaches
|
||||
// `requireRecordAuth` at any level", so the walked set must strictly
|
||||
// exceed the subset that names `manage` anywhere.
|
||||
const manageOnly = delegatedModules.filter((file) =>
|
||||
read(file).recordLevels.includes("manage"),
|
||||
);
|
||||
expect(manageOnly.length).toBeGreaterThan(0);
|
||||
expect(delegatedModules.length).toBeGreaterThan(manageOnly.length);
|
||||
|
||||
// A named anchor, so the claim does not rest on a count that could drift
|
||||
// to equality without anybody noticing. The custom-metrics list resolves
|
||||
// `requireRecordAuth("read", …)` and nothing else, so a manage-only filter
|
||||
// drops it — along with the refusal breadcrumbs it and its neighbours file.
|
||||
const anchor = join(API, "custom-metrics/route.ts");
|
||||
expect(read(anchor).recordLevels).toEqual(["read"]);
|
||||
expect(read(anchor).recordLevels).not.toContain("manage");
|
||||
expect(delegatedModules).toContain(anchor);
|
||||
expect(manageOnly).not.toContain(anchor);
|
||||
});
|
||||
|
||||
it("reaches the actions a route files through what it imports", () => {
|
||||
// Both halves of the fix, pinned against the two that were invisible.
|
||||
expect(actions).toContain("medication.intake.import.kickoff.denied");
|
||||
expect(actions).toContain("medication.oneShot.reconciled");
|
||||
// And the fire-and-forget half, on the READ route that started this.
|
||||
expect(actions).toContain("mood-entries.list.validation-failed");
|
||||
});
|
||||
|
||||
it("excludes exactly the three rows the feed cannot show", () => {
|
||||
// The exclusion list is the one place a real action could be hidden, so
|
||||
// its size is pinned and every member is asserted to have been REACHED —
|
||||
// an entry naming an action nothing files would be a claim about nothing.
|
||||
expect(NOT_IN_THE_FEED).toHaveLength(3);
|
||||
for (const action of NOT_IN_THE_FEED) {
|
||||
expect(reachable, action).toContain(action);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every one of them a specific verb rather than the fallback", () => {
|
||||
const generic = actions.filter(
|
||||
(action) => verbKeyFor(action).key === FALLBACK_KEY,
|
||||
);
|
||||
|
||||
expect(
|
||||
generic,
|
||||
"a delegated action with no verb line renders to the owner as the " +
|
||||
"generic 'made a change'. Add a case to activity-verb.ts and a " +
|
||||
"sentence to all six bundles — or, if the row genuinely should not " +
|
||||
"reach the feed, stop stamping the actor on it.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("never reports a refused attempt as something that happened", () => {
|
||||
// The half of the HIGH finding that a coverage count cannot express. Every
|
||||
// one of these rows is an attempt that changed NOTHING, and the generic
|
||||
// line told the owner their record had been edited. A future arm that maps
|
||||
// `…validation-failed` onto an ordinary verb would be the same defect with
|
||||
// coverage, so the shape of the key is asserted rather than its presence.
|
||||
const attempts = actions.filter(
|
||||
(action) =>
|
||||
action.endsWith(".validation-failed") || action.endsWith(".denied"),
|
||||
);
|
||||
expect(attempts.length).toBeGreaterThan(10);
|
||||
|
||||
const dishonest = attempts.filter(
|
||||
(action) => !/\.(rejected|denied)[A-Z]/.test(verbKeyFor(action).key),
|
||||
);
|
||||
expect(
|
||||
dishonest,
|
||||
"an attempt that was refused must not render as an act that happened",
|
||||
).toEqual([]);
|
||||
|
||||
// And the sentences themselves say so, in the source language. A key named
|
||||
// `rejected…` whose EN text read "added a reading" would pass the check
|
||||
// above and be exactly the lie it exists to stop.
|
||||
const en = bundle("en");
|
||||
for (const action of attempts) {
|
||||
const sentence = leaf(en, verbKeyFor(action).key);
|
||||
expect(typeof sentence, action).toBe("string");
|
||||
expect(String(sentence), action).toMatch(
|
||||
/tried to|could not|was refused|search this record/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves every verb key in all six bundles", () => {
|
||||
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
|
||||
const missing: string[] = [];
|
||||
let resolved = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
const { key } = verbKeyFor(action);
|
||||
for (const locale of LOCALES) {
|
||||
const value = leaf(bundles.get(locale)!, key);
|
||||
if (typeof value === "string" && value.length > 0) resolved += 1;
|
||||
else missing.push(`${locale}: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing.sort()).toEqual([]);
|
||||
// Six locales for every action, and the actions were counted above.
|
||||
expect(resolved).toBe(actions.length * LOCALES.length);
|
||||
});
|
||||
|
||||
it("interpolates the actor's name and nothing else, in every locale", () => {
|
||||
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
|
||||
const leaky: string[] = [];
|
||||
let checked = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
const { key, params } = verbKeyFor(action);
|
||||
// The call site's own parameters first: a sentence cannot leak a value
|
||||
// the map never hands it.
|
||||
if (params.join(",") !== "name") leaky.push(`${key} passes ${params}`);
|
||||
for (const locale of LOCALES) {
|
||||
const value = leaf(bundles.get(locale)!, key);
|
||||
if (typeof value !== "string") continue;
|
||||
checked += 1;
|
||||
const found = placeholders(value);
|
||||
if (found.length !== 1 || found[0] !== "name") {
|
||||
leaky.push(`${locale}: ${key} interpolates ${found.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(leaky.sort()).toEqual([]);
|
||||
expect(checked).toBe(actions.length * LOCALES.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the retention disclosure does not depend on a race", () => {
|
||||
/**
|
||||
* The sentence that bounds attribution — "who entered what is answerable for
|
||||
* the next N days" — used to appear only if a SECOND query had already
|
||||
* answered. The revoke dialog reads the grant list; the number came off the
|
||||
* activity feed. Two independent requests, and whichever lost decided
|
||||
* whether the owner was told. An owner who clicked quickly, or whose
|
||||
* activity read failed, ended a manage grant having read one sentence fewer
|
||||
* than an owner who clicked slowly, with nothing on screen to indicate that
|
||||
* a sentence was missing.
|
||||
*
|
||||
* The number now rides the same payload as the row: if there is a grant to
|
||||
* revoke, `retentionDays` is already in hand. Deterministic means there is
|
||||
* no branch left, which is what both legs below check from opposite sides —
|
||||
* one that the sentence is always composed, one that the second query is
|
||||
* gone.
|
||||
*/
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}(${JSON.stringify(params)})` : key;
|
||||
|
||||
it("states the window on every grant that could have written something", () => {
|
||||
for (const access of ["WRITE", "MANAGE"] as const) {
|
||||
const body = revokeBody({ t, access, name: "Alex", retentionDays: 90 });
|
||||
expect(body).toContain("recordSharing.given.revokeEntriesStay");
|
||||
expect(body).toContain(
|
||||
'recordSharing.given.revokeAttribution({"days":90})',
|
||||
);
|
||||
}
|
||||
// READ keeps neither: a read grant never put anything in the record, so
|
||||
// saying what happens to what they entered would describe an act that
|
||||
// cannot have occurred.
|
||||
const read = revokeBody({
|
||||
t,
|
||||
access: "READ",
|
||||
name: "Alex",
|
||||
retentionDays: 90,
|
||||
});
|
||||
expect(read).not.toContain("recordSharing.given.revokeAttribution");
|
||||
});
|
||||
|
||||
it("takes the number from the read that produced the row", () => {
|
||||
const source = readFileSync(GRANTS_GIVEN_CARD, "utf8");
|
||||
// Positive control: the matcher can see a hook name in this file at all.
|
||||
expect(source.match(/useAccountGrants/g)?.length ?? 0).toBeGreaterThan(0);
|
||||
// And the second query, whose arrival used to decide the copy, is gone.
|
||||
expect(source).not.toContain("useRecordActivity");
|
||||
// The branch it fed is gone with it. A required number cannot be absent,
|
||||
// so there is nothing left for a caller to omit — which is the difference
|
||||
// between "the sentence is usually there" and "there is no path without
|
||||
// it". Both halves are named: the optional marker and the guard.
|
||||
expect(source).not.toContain("retentionDays?:");
|
||||
expect(source).not.toContain("retentionDays !== undefined");
|
||||
expect(source.match(/retentionDays: number/g)?.length ?? 0).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the activity feed carries no audit detail", () => {
|
||||
/**
|
||||
* The DTO the owner's feed publishes, read off the route.
|
||||
*
|
||||
* A field-level assertion rather than a sentence in a comment, because the
|
||||
* pressure to widen it is real and constant: every thin sentence in the feed
|
||||
* looks like it wants the value that would make it specific, and the audit
|
||||
* row usually has one. `details` is a free-form JSON column written by sixty
|
||||
* handlers; publishing it would put whatever any of them decided to file in
|
||||
* front of the record owner, and some of them file a label.
|
||||
*/
|
||||
it("publishes five fields, and `details` is not one of them", () => {
|
||||
const source = readFileSync(ACTIVITY_ROUTE, "utf8");
|
||||
const parsed = ts.createSourceFile(
|
||||
ACTIVITY_ROUTE,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
let members: string[] | null = null;
|
||||
parsed.forEachChild((node) => {
|
||||
if (!ts.isInterfaceDeclaration(node)) return;
|
||||
if (node.name.text !== "RecordActivityEntry") return;
|
||||
members = node.members
|
||||
.map((m) => (m.name && ts.isIdentifier(m.name) ? m.name.text : ""))
|
||||
.filter((n) => n.length > 0)
|
||||
.sort();
|
||||
});
|
||||
|
||||
// The interface was found at all. `toEqual` against null would report the
|
||||
// absence as a mismatch of the list, which reads like the wrong failure.
|
||||
expect(
|
||||
members,
|
||||
"RecordActivityEntry not found in the route",
|
||||
).not.toBeNull();
|
||||
expect(members).toEqual(["accesses", "action", "actor", "createdAt", "id"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { openApiPaths } from "@/lib/openapi/routes";
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
/** Every `route.ts` under the API tree. */
|
||||
async function apiRouteFiles(): Promise<string[]> {
|
||||
const entries = await readdir(path.join(root, "src/app/api"), {
|
||||
recursive: true,
|
||||
});
|
||||
return entries
|
||||
.filter((entry) => entry.endsWith("route.ts"))
|
||||
.map((entry) => path.join("src/app/api", entry));
|
||||
}
|
||||
|
||||
/** The published path a route file serves, or null when it is unpublished. */
|
||||
function publishedPathFor(file: string): string | null {
|
||||
const apiPath = file
|
||||
.replace(/^src\/app/, "")
|
||||
.replace(/\/route\.ts$/, "")
|
||||
.replace(/\[([^\]]+)\]/g, "{$1}");
|
||||
return apiPath in openApiPaths ? apiPath : null;
|
||||
}
|
||||
|
||||
describe("managed-profile contract", () => {
|
||||
it("publishes the last-Guardian refusal on every published route that emits it", async () => {
|
||||
// The failure this catches is the one the release audit found: the floor
|
||||
// is enforced on four surfaces and was DESCRIBED on the one route that
|
||||
// cannot reach it, while two routes that answer it every time said only
|
||||
// "that access had already ended". Emitters are discovered from the route
|
||||
// sources, so a fifth one cannot ship undescribed.
|
||||
const files = await apiRouteFiles();
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
|
||||
const emitters: string[] = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(path.join(root, file), "utf8");
|
||||
if (!source.includes("LastManagedGuardianError")) continue;
|
||||
if (!source.includes("managed_profile.guardian.required")) continue;
|
||||
emitters.push(file);
|
||||
}
|
||||
// Non-zero, and known: the two grant routes. The lifecycle surfaces that
|
||||
// also raise it (account deletion, the data wipe) answer through their own
|
||||
// envelopes and are not in this spec.
|
||||
expect(emitters).toContain("src/app/api/account/grants/[id]/route.ts");
|
||||
expect(emitters).toContain(
|
||||
"src/app/api/account/grants/[id]/renounce/route.ts",
|
||||
);
|
||||
|
||||
const silent = emitters.filter((file) => {
|
||||
const apiPath = publishedPathFor(file);
|
||||
if (apiPath === null) return false;
|
||||
return !JSON.stringify(openApiPaths[apiPath]).includes(
|
||||
"managed_profile.guardian.required",
|
||||
);
|
||||
});
|
||||
expect(
|
||||
silent,
|
||||
"published routes that emit the last-Guardian refusal without publishing it",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires a cookie-backed fresh MFA proof for creation", async () => {
|
||||
const source = await readFile(
|
||||
path.join(root, "src/app/api/managed-profiles/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("requireFreshMfa");
|
||||
expect(source).not.toContain("requireFreshMfaIfEnrolled");
|
||||
});
|
||||
|
||||
it("requires a cookie-backed fresh MFA proof for profile deletion", async () => {
|
||||
const source = await readFile(
|
||||
path.join(root, "src/app/api/managed-profiles/[id]/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("requireFreshMfa");
|
||||
expect(source).not.toContain("requireFreshMfaIfEnrolled");
|
||||
});
|
||||
|
||||
it("publishes no Guardian refusal a request cannot produce", async () => {
|
||||
// `managed_profile.guardian.self` was written, shipped and never
|
||||
// reachable: `self_grant` needs the grantor to equal the grantee, and the
|
||||
// grantor of a Guardian grant is the PROFILE. Inviting yourself lands on
|
||||
// the duplicate refusal; naming the profile is refused a step earlier as
|
||||
// `managed_grantee`. Both doors are asserted in
|
||||
// `tests/integration/managed-profile-surface.test.ts`, so the arm was
|
||||
// known to be dead rather than assumed to be, and it was removed rather
|
||||
// than frozen into the published contract.
|
||||
const route = await readFile(
|
||||
path.join(root, "src/app/api/managed-profiles/[id]/guardians/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const spec = await readFile(
|
||||
path.join(root, "docs/api/openapi.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Non-zero control: the codes that ARE reachable are still named, so this
|
||||
// cannot pass by matching an empty file or a renamed family.
|
||||
expect(route).toContain("managed_profile.guardian.duplicate");
|
||||
expect(route).toContain("managed_profile.guardian.managed_invitee");
|
||||
expect(route).not.toContain('errorCode: "managed_profile.guardian.self"');
|
||||
expect(spec).not.toContain("managed_profile.guardian.self");
|
||||
});
|
||||
|
||||
it("does not publish an emancipation API surface", async () => {
|
||||
const source = await readFile(
|
||||
path.join(root, "prisma/schema.prisma"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source.toLowerCase()).not.toContain("emancipation");
|
||||
|
||||
const routes = await readdir(
|
||||
path.join(root, "src/app/api/managed-profiles"),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(
|
||||
routes.some((entry) => entry.toLowerCase().includes("emancip")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not claim notification fanout before its consent plan exists", async () => {
|
||||
const source = await readFile(
|
||||
path.join(root, "prisma/schema.prisma"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toContain("notification dispatcher fans reminders");
|
||||
expect(source).toContain("later notification plan");
|
||||
});
|
||||
|
||||
it("uses the same record lock for every Guardian-reducing transition", async () => {
|
||||
const lifecycle = await readFile(
|
||||
path.join(root, "src/lib/managed-profiles/lifecycle.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const grants = await readFile(
|
||||
path.join(root, "src/lib/sharing/grants.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const accountDeletion = await readFile(
|
||||
path.join(root, "src/app/api/settings/account/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(lifecycle).toContain("pg_advisory_xact_lock");
|
||||
expect(lifecycle).toContain("clearManagedProfileMarker");
|
||||
expect(lifecycle).toContain("deleteManagedProfile");
|
||||
expect(grants).toContain("reduceManagedProfileGuardian");
|
||||
expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
|
||||
const pathnameRef = { value: "/settings/security" };
|
||||
const activeRecordRef = { value: true };
|
||||
/** The record on screen. `managed` unless a case says otherwise. */
|
||||
const recordRef: { value: { kind: "managed" | "shared"; level: string } } = {
|
||||
value: { kind: "managed", level: "manage" },
|
||||
};
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => pathnameRef.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: activeRecordRef.value
|
||||
? {
|
||||
id: "guardian",
|
||||
modules: undefined,
|
||||
accountAccess: {
|
||||
active: {
|
||||
accountId: "managed-record",
|
||||
username: "managed-record",
|
||||
displayName: "Managed record",
|
||||
access: "write",
|
||||
level: recordRef.value.level,
|
||||
recordKind: recordRef.value.kind,
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
},
|
||||
accounts: [],
|
||||
canSwitch: true,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
SETTINGS_SECTIONS,
|
||||
SettingsShell,
|
||||
} from "@/components/settings/settings-shell";
|
||||
import { SharedRecordBanner } from "@/components/layout/shared-record-banner";
|
||||
|
||||
function renderSettings(pathname = "/settings/security") {
|
||||
pathnameRef.value = pathname;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SettingsShell>
|
||||
<div>managed settings content</div>
|
||||
</SettingsShell>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The destinations a record offers, by what kind of record it is.
|
||||
*
|
||||
* v1.37.0 — a managed profile offers guardian configuration AND the record
|
||||
* content a MANAGE holder may write, because a Guardian holds MANAGE. The
|
||||
* `manage-writable` kind is what carries the second half; before it existed
|
||||
* the anamnesis form had no page a Guardian could open, while the routes
|
||||
* behind it had admitted the write since the level shipped.
|
||||
*/
|
||||
const MANAGED_KINDS = new Set(["managed-guardian", "manage-writable"]);
|
||||
|
||||
describe("managed record navigation", () => {
|
||||
it("shows only managed settings destinations while retaining an unavailable deep-link heading", () => {
|
||||
recordRef.value = { kind: "managed", level: "manage" };
|
||||
const html = renderSettings();
|
||||
const managedDestinations = SETTINGS_SECTIONS.filter((section) =>
|
||||
MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind),
|
||||
);
|
||||
const unavailableDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
!MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind),
|
||||
);
|
||||
|
||||
// The partition is non-empty on both sides. A classification change that
|
||||
// emptied either would make one of the loops below prove nothing.
|
||||
expect(managedDestinations.length).toBeGreaterThan(1);
|
||||
expect(unavailableDestinations.length).toBeGreaterThan(1);
|
||||
// And the destination this release opened is really on the offered side.
|
||||
expect(managedDestinations.map((s) => s.slug)).toContain("anamnesis");
|
||||
|
||||
expect(html).toContain('data-active-record-id="managed-record"');
|
||||
expect(html).toContain('id="settings-section-security-title"');
|
||||
expect(html).not.toMatch(/href="[^"]*managed-record/);
|
||||
|
||||
for (const section of managedDestinations) {
|
||||
expect(
|
||||
html.match(new RegExp(`href="/settings/${section.slug}"`, "g"))
|
||||
?.length ?? 0,
|
||||
).toBe(2);
|
||||
}
|
||||
|
||||
for (const section of unavailableDestinations) {
|
||||
expect(html).not.toContain(`href="/settings/${section.slug}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers an adult manager the record content and none of the configuration", () => {
|
||||
// The other half of the same classification, and the half that is easiest
|
||||
// to get wrong in the generous direction: a delegate manages somebody's
|
||||
// health record, not their account. Anamnese is offered; modules,
|
||||
// thresholds and notification routing are not, even though a Guardian
|
||||
// reaches all three.
|
||||
recordRef.value = { kind: "shared", level: "manage" };
|
||||
const html = renderSettings();
|
||||
|
||||
expect(html).toContain('href="/settings/anamnesis"');
|
||||
for (const guardianOnly of [
|
||||
"account",
|
||||
"modules",
|
||||
"notifications",
|
||||
"thresholds",
|
||||
"coach",
|
||||
]) {
|
||||
expect(html, guardianOnly).not.toContain(
|
||||
`href="/settings/${guardianOnly}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a WRITE delegate no settings destination at all", () => {
|
||||
// The refusal side. `manage-writable` is admitted by LEVEL, not by being
|
||||
// in a shared record: a WRITE grant reaches no Settings page, and a
|
||||
// destination it could see would be one the route below refuses.
|
||||
recordRef.value = { kind: "shared", level: "write" };
|
||||
const html = renderSettings();
|
||||
|
||||
expect(html).not.toContain('href="/settings/anamnesis"');
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(html, section.slug).not.toContain(
|
||||
`href="/settings/${section.slug}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a managed entry below MANAGE nothing at all", () => {
|
||||
// A guardian grant is always MANAGE today, which is exactly why the level
|
||||
// check was easy to leave out of the managed branch. Without it a managed
|
||||
// entry that ever arrived below MANAGE would list destinations the section
|
||||
// gate refuses, sending somebody to a page that explains it cannot open.
|
||||
recordRef.value = { kind: "managed", level: "read" };
|
||||
const html = renderSettings();
|
||||
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(html, section.slug).not.toContain(
|
||||
`href="/settings/${section.slug}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the exit control present while administering a managed record", () => {
|
||||
recordRef.value = { kind: "managed", level: "manage" };
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SharedRecordBanner />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-account-id="managed-record"');
|
||||
expect(html).toContain('data-slot="shared-record-banner-exit"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transactionMock = vi.fn();
|
||||
const authorizationClaimQueryMock = vi.fn();
|
||||
const authorizationUpdateMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
notificationChannel: {
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
user: { findUnique: vi.fn() },
|
||||
accountGrant: { findMany: vi.fn() },
|
||||
notificationEgressAuthorization: {
|
||||
update: (...args: unknown[]) => authorizationUpdateMock(...args),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: (...args: unknown[]) => transactionMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (value: string) => value,
|
||||
encrypt: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({
|
||||
addWarning: vi.fn(),
|
||||
addMeta: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const sendViaWebPushMock = vi.fn();
|
||||
const sendViaNtfyMock = vi.fn();
|
||||
const sendViaApnsMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/notifications/senders/apns", () => ({
|
||||
sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/email", () => ({
|
||||
sendViaEmail: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/ntfy", () => ({
|
||||
sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/telegram", () => ({
|
||||
sendViaTelegram: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/web-push", () => ({
|
||||
sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/webhook", () => ({
|
||||
sendViaWebhook: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
||||
recordPushAttempt: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
const recordUserId = "managed-record";
|
||||
const recipientUserId = "guardian-recipient";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
authorizationClaimQueryMock.mockResolvedValue([{ id: "authorization" }]);
|
||||
authorizationUpdateMock.mockResolvedValue({ id: "authorization" });
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
managedProfileAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "recipient-channel",
|
||||
userId: recipientUserId,
|
||||
type: "WEB_PUSH",
|
||||
config: "{}",
|
||||
nextRetryAt: null,
|
||||
preferences: [],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.notificationChannel.update).mockResolvedValue({
|
||||
consecutiveFailures: 0,
|
||||
} as never);
|
||||
transactionMock.mockImplementation(
|
||||
async (operation: (tx: unknown) => Promise<unknown>) =>
|
||||
operation({
|
||||
$queryRaw: (...args: unknown[]) => authorizationClaimQueryMock(...args),
|
||||
user: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
|
||||
where.id === recordUserId
|
||||
? { managedProfileAt: new Date() }
|
||||
: { managedProfileAt: null },
|
||||
),
|
||||
},
|
||||
accountGrant: { findFirst: vi.fn(async () => ({ id: "grant" })) },
|
||||
}),
|
||||
);
|
||||
sendViaWebPushMock.mockResolvedValue({ ok: true });
|
||||
sendViaNtfyMock.mockResolvedValue({ ok: true });
|
||||
sendViaApnsMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("notification delivery identity", () => {
|
||||
it("principal resolution uses the recipient channel for a managed record", async () => {
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: recordUserId,
|
||||
recordUserId,
|
||||
recipientUserId,
|
||||
title: "Record content",
|
||||
message: "Record schedule",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
});
|
||||
expect(prisma.notificationChannel.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: recipientUserId, enabled: true },
|
||||
}),
|
||||
);
|
||||
expect(sendViaWebPushMock).toHaveBeenCalledWith(
|
||||
recipientUserId,
|
||||
expect.objectContaining({
|
||||
userId: recordUserId,
|
||||
recordUserId,
|
||||
recipientUserId,
|
||||
title: "Record content",
|
||||
message: "Record schedule",
|
||||
}),
|
||||
);
|
||||
// Preflight chooses recipient channels, then a short locked transaction
|
||||
// records the final authorization claim before provider I/O starts.
|
||||
expect(transactionMock).toHaveBeenCalledTimes(2);
|
||||
expect(transactionMock).toHaveBeenLastCalledWith(expect.any(Function));
|
||||
expect(authorizationClaimQueryMock).toHaveBeenCalledTimes(3);
|
||||
expect(authorizationUpdateMock).toHaveBeenCalledWith({
|
||||
where: { id: "authorization" },
|
||||
data: { completedAt: expect.any(Date), outcome: "ok" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invoke a provider when final Guardian authorization is revoked", async () => {
|
||||
let transactionCount = 0;
|
||||
transactionMock.mockImplementation(
|
||||
async (operation: (tx: unknown) => Promise<unknown>) => {
|
||||
transactionCount += 1;
|
||||
return operation({
|
||||
$queryRaw: (...args: unknown[]) =>
|
||||
authorizationClaimQueryMock(...args),
|
||||
user: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
|
||||
where.id === recordUserId
|
||||
? { managedProfileAt: new Date() }
|
||||
: { managedProfileAt: null },
|
||||
),
|
||||
},
|
||||
accountGrant: {
|
||||
findFirst: vi.fn(async () =>
|
||||
transactionCount === 1 ? { id: "grant" } : null,
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: recordUserId,
|
||||
recordUserId,
|
||||
recipientUserId,
|
||||
title: "Record content",
|
||||
message: "Record schedule",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
expect(authorizationClaimQueryMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendViaWebPushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a provider timeout on the claim without retaining the lifecycle lock", async () => {
|
||||
sendViaWebPushMock.mockRejectedValue(new Error("provider timeout"));
|
||||
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: recordUserId,
|
||||
recordUserId,
|
||||
recipientUserId,
|
||||
title: "Record content",
|
||||
message: "Record schedule",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
dispatched: false,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
expect(authorizationUpdateMock).toHaveBeenCalledWith({
|
||||
where: { id: "authorization" },
|
||||
data: { completedAt: expect.any(Date), outcome: "sender_threw" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a managed record without an explicit recipient", async () => {
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: recordUserId,
|
||||
title: "Record content",
|
||||
message: "Record schedule",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
expect(prisma.notificationChannel.findMany).not.toHaveBeenCalled();
|
||||
expect(sendViaWebPushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the recipient preference without changing record content", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "recipient-channel",
|
||||
userId: recipientUserId,
|
||||
type: "WEB_PUSH",
|
||||
config: "{}",
|
||||
nextRetryAt: null,
|
||||
preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: recordUserId,
|
||||
recordUserId,
|
||||
recipientUserId,
|
||||
title: "Record content",
|
||||
message: "Record schedule",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
expect(prisma.notificationChannel.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: recipientUserId, enabled: true },
|
||||
}),
|
||||
);
|
||||
expect(sendViaWebPushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders for each Guardian while isolating channel preferences", async () => {
|
||||
const firstGuardian = "guardian-en";
|
||||
const secondGuardian = "guardian-de";
|
||||
|
||||
(
|
||||
prisma.user.findUnique as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(async ({ where }: { where: { id: string } }) => {
|
||||
if (where.id === recordUserId) {
|
||||
return { managedProfileAt: new Date() } as never;
|
||||
}
|
||||
if (where.id === firstGuardian) {
|
||||
return {
|
||||
managedProfileAt: null,
|
||||
locale: "en",
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
} as never;
|
||||
}
|
||||
return {
|
||||
managedProfileAt: null,
|
||||
locale: "de",
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
} as never;
|
||||
});
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
{ granteeId: firstGuardian, grantee: { managedProfileAt: null } },
|
||||
{ granteeId: secondGuardian, grantee: { managedProfileAt: null } },
|
||||
] as never);
|
||||
(
|
||||
prisma.notificationChannel.findMany as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(async ({ where }: { where: { userId: string } }) => {
|
||||
if (where.userId === firstGuardian) {
|
||||
return [
|
||||
{
|
||||
id: "first-web-disabled",
|
||||
userId: firstGuardian,
|
||||
type: "WEB_PUSH",
|
||||
config: "{}",
|
||||
nextRetryAt: null,
|
||||
preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }],
|
||||
},
|
||||
{
|
||||
id: "first-ntfy-enabled",
|
||||
userId: firstGuardian,
|
||||
type: "NTFY",
|
||||
config: "{}",
|
||||
nextRetryAt: null,
|
||||
preferences: [],
|
||||
},
|
||||
] as never;
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "second-web-enabled",
|
||||
userId: secondGuardian,
|
||||
type: "WEB_PUSH",
|
||||
config: "{}",
|
||||
nextRetryAt: null,
|
||||
preferences: [],
|
||||
},
|
||||
{
|
||||
id: "second-ntfy-disabled",
|
||||
userId: secondGuardian,
|
||||
type: "NTFY",
|
||||
config: "{}",
|
||||
nextRetryAt: null,
|
||||
preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }],
|
||||
},
|
||||
] as never;
|
||||
});
|
||||
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: recordUserId,
|
||||
title: "record-language-title",
|
||||
message: "record-language-message",
|
||||
renderForRecipient: (locale) => ({
|
||||
title: `title-${locale}`,
|
||||
message: `message-${locale}`,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
dispatched: true,
|
||||
channelsAttempted: 2,
|
||||
channelsSucceeded: 2,
|
||||
});
|
||||
expect(sendViaNtfyMock).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
recipientUserId: firstGuardian,
|
||||
title: "title-en",
|
||||
message: "message-en",
|
||||
}),
|
||||
);
|
||||
expect(sendViaWebPushMock).toHaveBeenCalledWith(
|
||||
secondGuardian,
|
||||
expect.objectContaining({
|
||||
recipientUserId: secondGuardian,
|
||||
title: "title-de",
|
||||
message: "message-de",
|
||||
}),
|
||||
);
|
||||
expect(sendViaWebPushMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendViaNtfyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bypasses client-managed APNs suppression only for managed Guardian delivery", async () => {
|
||||
const guardian = "guardian-apns";
|
||||
(
|
||||
prisma.user.findUnique as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(
|
||||
async ({ where }: { where: { id: string } }) =>
|
||||
(where.id === recordUserId
|
||||
? { managedProfileAt: new Date() }
|
||||
: {
|
||||
managedProfileAt: null,
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
{ granteeId: guardian, grantee: { managedProfileAt: null } },
|
||||
] as never);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "guardian-apns",
|
||||
userId: guardian,
|
||||
type: "APNS",
|
||||
config: "{}",
|
||||
nextRetryAt: null,
|
||||
preferences: [],
|
||||
},
|
||||
] as never);
|
||||
|
||||
await expect(
|
||||
dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: recordUserId,
|
||||
title: "Record content",
|
||||
message: "Record schedule",
|
||||
}),
|
||||
).resolves.toMatchObject({ dispatched: true, channelsSucceeded: 1 });
|
||||
|
||||
expect(sendViaApnsMock).toHaveBeenCalledWith(
|
||||
guardian,
|
||||
expect.objectContaining({
|
||||
recordUserId,
|
||||
recipientUserId: guardian,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
/**
|
||||
* Every schema id the sharing route table declares reaches the published
|
||||
* document.
|
||||
*
|
||||
* The failure this exists for looks like nothing at all. `.meta()` in Zod 4 is
|
||||
* immutable: it returns a NEW schema carrying the metadata and registers that
|
||||
* one, so `schema.meta({ id, description })` written as a statement registers
|
||||
* an instance no route references. The generator then emits the request body
|
||||
* inline and anonymous, the id never becomes a component, and the object-level
|
||||
* description never leaves the source file. Nothing throws, nothing drifts,
|
||||
* `openapi:check` stays green, and the sentence somebody wrote for the client
|
||||
* team is readable only by someone already reading the code.
|
||||
*
|
||||
* Four of the sharing bodies were written that way, and one of them carried the
|
||||
* clause scoping every rule on the invitation endpoint to an INVITED grant —
|
||||
* the exact sentence a reader needs in order not to carry the step-up and the
|
||||
* refusals across to a Guardian grant. It was found by reading the artifact
|
||||
* instead of the source, which is not a repeatable way to find things.
|
||||
*
|
||||
* ## What is checked
|
||||
*
|
||||
* The ids are read out of the route table's own text rather than listed here,
|
||||
* so a body added tomorrow is covered without anybody remembering this file.
|
||||
* The set is asserted non-empty first: a matcher that silently stopped matching
|
||||
* would otherwise report a green run over nothing, which is the same class of
|
||||
* defect as the one under test.
|
||||
*
|
||||
* The document is BUILT rather than read off disk. `scripts/check-openapi.ts`
|
||||
* already pins `docs/api/openapi.yaml` to exactly this document and CI fails on
|
||||
* drift, so building it here checks the same artifact without parsing a
|
||||
* megabyte of YAML.
|
||||
*
|
||||
* ## What it cannot see
|
||||
*
|
||||
* Only this route module. The idiom is long-standing across the registry and
|
||||
* fifty-seven other ids are declared the same way in modules this file does not
|
||||
* read; widening the sweep is a separate change with a separate diff, recorded
|
||||
* in the post-release inventory.
|
||||
*/
|
||||
const ROUTE_TABLE = join(
|
||||
process.cwd(),
|
||||
"src/lib/openapi/routes/account-sharing.ts",
|
||||
);
|
||||
|
||||
/** Every `id: "…"` the route table declares, in source order. */
|
||||
function declaredIds(): string[] {
|
||||
const source = readFileSync(ROUTE_TABLE, "utf8");
|
||||
return [...source.matchAll(/\bid:\s*"([A-Za-z0-9_]+)"/g)].map(
|
||||
(match) => match[1]!,
|
||||
);
|
||||
}
|
||||
|
||||
describe("the sharing route table's schema ids reach the document", () => {
|
||||
const ids = declaredIds();
|
||||
const schemas = buildOpenApiDocument().components?.schemas ?? {};
|
||||
|
||||
it("has ids to check", () => {
|
||||
// The non-zero control. Rename the file, change the quoting style, drop the
|
||||
// `id` key convention, and this leg fails instead of the file passing
|
||||
// vacuously.
|
||||
expect(ids.length).toBeGreaterThan(10);
|
||||
expect(ids).toContain("AccountGrantInvite");
|
||||
expect(ids).toContain("CreateManagedProfileRequest");
|
||||
expect(ids).toContain("InviteManagedProfileGuardianRequest");
|
||||
expect(ids).toContain("AccountSwitchRequest");
|
||||
});
|
||||
|
||||
it("publishes every declared id as a named component", () => {
|
||||
const missing = ids.filter((id) => !(id in schemas));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes the invited-grant scoping clause with the request body", () => {
|
||||
// The sentence that was lost, checked where a client would read it rather
|
||||
// than where it was written.
|
||||
const invite = schemas["AccountGrantInvite"] as
|
||||
{ description?: string } | undefined;
|
||||
expect(invite?.description ?? "").toMatch(
|
||||
/Every sentence here describes an INVITED grant on this endpoint/,
|
||||
);
|
||||
// And the boundary it states is the corrected one. The stale wording denied
|
||||
// every settings surface, which stopped being true when the anamnesis
|
||||
// surface opened to a manage delegate.
|
||||
expect(invite?.description ?? "").not.toMatch(
|
||||
/never reaches[^.]*\bsettings\b/i,
|
||||
);
|
||||
expect(invite?.description ?? "").toMatch(
|
||||
/record content versus account configuration/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* v1.37.0 — one refusal, on every path a delegate can reach.
|
||||
*
|
||||
* A route that resolves through `requireRecordAuth` / `requireGuardianAuth` can
|
||||
* be asked to act on somebody else's record, and can therefore answer 403
|
||||
* `sharing.access.denied`. Before this file that fact was published once, on the
|
||||
* per-request selector parameter, and on none of the hundred and twenty
|
||||
* operations that produce it: a client reading the spec for `/api/measurements`
|
||||
* learned that the header existed somewhere and nothing about what the read
|
||||
* does when the grant behind it ends. This guard closes the pair — the set of
|
||||
* fenced operations is recomputed from the route sources on every run, and
|
||||
* every member has to publish the same sentence, byte for byte.
|
||||
*
|
||||
* ## Byte-identical, and why it is the point
|
||||
*
|
||||
* The refusal carries no reason on the wire. A selector naming an account that
|
||||
* does not exist, one naming an account that granted nothing, a grant whose
|
||||
* sections do not reach the surface, and a read grant on a write — all four are
|
||||
* the same status, the same code and the same bytes, because a distinguishable
|
||||
* refusal is an account-enumeration oracle for anyone with a login. A contract
|
||||
* that described them differently per path would invite a client to tell them
|
||||
* apart, and the first client that tried would be reading tea leaves. So the
|
||||
* description is ONE string, spliced into every fenced operation, and a
|
||||
* paraphrase on one path fails here.
|
||||
*
|
||||
* ## What this guard deliberately does not do
|
||||
*
|
||||
* - It classifies per VERB, not per file, because the two disagree on
|
||||
* seventeen route modules: `GET /api/dashboard/widgets` is delegable and its
|
||||
* `PUT` is not. The matcher takes each handler's own top-level declaration
|
||||
* and follows every declaration in the same module it names, transitively —
|
||||
* which is how `POST /api/custom-metrics` (body in `postCustomMetric`) and
|
||||
* `POST /api/cycle/day-logs` (body in `postDayLog`, wrapped in
|
||||
* `withIdempotency` and declared BELOW the `GET`) are classified correctly. A
|
||||
* first draft that segmented on export boundaries and matched call sites got
|
||||
* both of those wrong, in both directions.
|
||||
* - It resolves within the module only. A handler whose fence lived in an
|
||||
* imported helper would read as unfenced; `sharing-surface-guard.test.ts` is
|
||||
* what holds the declaration to the route module itself.
|
||||
* - It says nothing about the verbs that are NOT fenced. Those refuse a
|
||||
* switched caller with `sharing.not_permitted` — a different fact, published
|
||||
* today only where the sharing family itself documents it. Naming it on every
|
||||
* authenticated path in the application is a larger contract change than a
|
||||
* freeze should make.
|
||||
* - Comments are stripped before matching. `.../guardians/route.ts` names both
|
||||
* fence helpers in a docblock explaining why it uses neither, and counting
|
||||
* prose would classify an actor surface as delegable.
|
||||
* - It covers the PUBLISHED surface, which is not the whole delegable surface.
|
||||
* 116 route modules resolve the fence; 90 of them are in the OpenAPI document
|
||||
* and 26 have never been (mood entries, the record-settings tree, the
|
||||
* medication intake tree, several insight reads). That gap predates this
|
||||
* release and is not closed here — "frozen" means the published contract is
|
||||
* complete and consistent, not that every delegable route is described. What
|
||||
* this guard does guarantee is that one of those 26 cannot be published
|
||||
* without its refusal, because publishing it puts it in scope on the next run.
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { openApiPaths } from "@/lib/openapi/routes";
|
||||
import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
|
||||
/** A published operation, resolved to the module that serves it. */
|
||||
interface PublishedOperation {
|
||||
path: string;
|
||||
method: HttpMethod;
|
||||
/** The operation object as the route table declares it. */
|
||||
operation: Record<string, unknown>;
|
||||
/** The handler's own declaration plus every module-local one it names. */
|
||||
handlerSource: string;
|
||||
}
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function routeModulePath(apiPath: string): string {
|
||||
return join(
|
||||
ROOT,
|
||||
"src/app",
|
||||
apiPath.replace(/\{([^}]+)\}/g, "[$1]"),
|
||||
"route.ts",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every top-level declaration in a module, by name.
|
||||
*
|
||||
* The unit is the declaration and not the line range between two exports,
|
||||
* because a route module puts helpers wherever it likes: `cycle/day-logs`
|
||||
* declares `postDayLog` BELOW its `GET`, so a naive "from this export to the
|
||||
* next" slice hands GET a body that belongs to POST.
|
||||
*/
|
||||
function topLevelChunks(source: string): Map<string, string> {
|
||||
const declaration =
|
||||
/^(?:export\s+)?(?:async\s+function|function|const|let|class)\s+([A-Za-z0-9_$]+)/gm;
|
||||
const found = [...source.matchAll(declaration)];
|
||||
const chunks = new Map<string, string>();
|
||||
found.forEach((match, index) => {
|
||||
const from = match.index ?? 0;
|
||||
const to = found[index + 1]?.index ?? source.length;
|
||||
chunks.set(match[1], source.slice(from, to));
|
||||
});
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text a handler can reach: its own declaration plus every top-level
|
||||
* declaration in the same module it names, transitively.
|
||||
*
|
||||
* Both indirections in the tree need this. `apiHandler(postCustomMetric)`
|
||||
* names its body as a bare reference, and
|
||||
* `apiHandler(withIdempotency<[NextRequest]>(postDayLog))` names it as an
|
||||
* argument to a wrapper — neither is a call site, so a matcher keyed on
|
||||
* `name(` finds nothing and the handler reads as unfenced. It resolves within
|
||||
* the module only: a fence resolved in an imported helper would be missed, and
|
||||
* `sharing-surface-guard.test.ts` is what holds the declaration to the route
|
||||
* module.
|
||||
*/
|
||||
function handlerText(source: string, method: HttpMethod): string {
|
||||
const chunks = topLevelChunks(source);
|
||||
const own = chunks.get(method.toUpperCase());
|
||||
if (!own) return "";
|
||||
|
||||
const seen = new Set([method.toUpperCase()]);
|
||||
let text = own;
|
||||
for (let depth = 0; depth < 5; depth += 1) {
|
||||
const named = [...text.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
const next = named.filter((name) => chunks.has(name) && !seen.has(name));
|
||||
if (next.length === 0) break;
|
||||
for (const name of next) {
|
||||
seen.add(name);
|
||||
text += chunks.get(name);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function publishedOperations(): PublishedOperation[] {
|
||||
const operations: PublishedOperation[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const [path, item] of Object.entries(openApiPaths)) {
|
||||
const modulePath = routeModulePath(path);
|
||||
if (!existsSync(modulePath)) {
|
||||
missing.push(path);
|
||||
continue;
|
||||
}
|
||||
const source = stripComments(readFileSync(modulePath, "utf8"));
|
||||
for (const method of HTTP_METHODS) {
|
||||
const operation = (item as Record<string, unknown>)[method];
|
||||
if (!operation || typeof operation !== "object") continue;
|
||||
operations.push({
|
||||
path,
|
||||
method,
|
||||
operation: operation as Record<string, unknown>,
|
||||
handlerSource: handlerText(source, method),
|
||||
});
|
||||
}
|
||||
}
|
||||
// A published path with no route module would silently drop out of every
|
||||
// assertion below, so it fails here instead.
|
||||
expect(missing, "published paths with no route module").toEqual([]);
|
||||
return operations;
|
||||
}
|
||||
|
||||
/** Does this handler resolve the record fence? */
|
||||
function isFenced(operation: PublishedOperation): boolean {
|
||||
return /require(?:Record|Guardian)Auth\s*\(/.test(operation.handlerSource);
|
||||
}
|
||||
|
||||
function refusal(
|
||||
operation: PublishedOperation,
|
||||
): { description?: string; content?: unknown } | null {
|
||||
const responses = operation.operation.responses as
|
||||
Record<string, { description?: string; content?: unknown }> | undefined;
|
||||
return responses?.["403"] ?? null;
|
||||
}
|
||||
|
||||
const operations = publishedOperations();
|
||||
const fenced = operations.filter(isFenced);
|
||||
const unfenced = operations.filter((operation) => !isFenced(operation));
|
||||
const name = (operation: PublishedOperation) =>
|
||||
`${operation.method.toUpperCase()} ${operation.path}`;
|
||||
|
||||
describe("the sharing refusal every delegable path publishes", () => {
|
||||
it("discovers a real set of fenced operations", () => {
|
||||
// Non-zero, and anchored at both ends: a matcher that stopped matching
|
||||
// would leave every assertion below vacuously true.
|
||||
expect(operations.length).toBeGreaterThan(200);
|
||||
expect(fenced.length).toBeGreaterThan(100);
|
||||
|
||||
const fencedNames = fenced.map(name);
|
||||
expect(fencedNames).toContain("GET /api/measurements");
|
||||
expect(fencedNames).toContain("POST /api/labs");
|
||||
expect(fencedNames).toContain("GET /api/dashboard/widgets");
|
||||
|
||||
const unfencedNames = unfenced.map(name);
|
||||
// The other half of the same module: `PUT` and `DELETE` on the widget
|
||||
// layout are the caller's own settings and refuse a switch outright.
|
||||
expect(unfencedNames).toContain("PUT /api/dashboard/widgets");
|
||||
expect(unfencedNames).toContain("DELETE /api/dashboard/widgets");
|
||||
// An actor surface, and a route whose docblock names both fence helpers
|
||||
// while calling neither.
|
||||
expect(unfencedNames).toContain("GET /api/auth/me");
|
||||
expect(unfencedNames).toContain("GET /api/managed-profiles/{id}/guardians");
|
||||
});
|
||||
|
||||
it("publishes the refusal on every fenced operation", () => {
|
||||
const silent = fenced.filter((operation) => {
|
||||
const response = refusal(operation);
|
||||
return (
|
||||
!response ||
|
||||
!response.description?.includes(SHARING_ACCESS_DENIED_DESCRIPTION)
|
||||
);
|
||||
});
|
||||
|
||||
if (silent.length > 0) {
|
||||
throw new Error(
|
||||
`${silent.length} delegable operation(s) publish no sharing refusal:\n` +
|
||||
silent.map((operation) => ` ❌ ${name(operation)}`).join("\n") +
|
||||
"\n\nSpread `...recordRefusal()` into the operation's responses — or " +
|
||||
"`...recordRefusal(<the other reason>)` when it already answers 403 " +
|
||||
"for a module gate or a consent gate.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("says it the same way every time", () => {
|
||||
// One string, not ninety paraphrases. Collected from the published objects
|
||||
// rather than asserted against the constant, so a hand-written copy that
|
||||
// differs by a comma is what this catches.
|
||||
const sentences = new Set(
|
||||
fenced.map((operation) => {
|
||||
const description = refusal(operation)?.description ?? "";
|
||||
const at = description.indexOf(SHARING_ACCESS_DENIED_DESCRIPTION);
|
||||
return description.slice(at);
|
||||
}),
|
||||
);
|
||||
expect(sentences.size).toBe(1);
|
||||
expect([...sentences][0]).toBe(SHARING_ACCESS_DENIED_DESCRIPTION);
|
||||
|
||||
// What the sentence has to carry: the code a client branches on, and the
|
||||
// indistinguishability that keeps it from being an enumeration oracle.
|
||||
expect(SHARING_ACCESS_DENIED_DESCRIPTION).toContain(
|
||||
"sharing.access.denied",
|
||||
);
|
||||
expect(SHARING_ACCESS_DENIED_DESCRIPTION.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("keeps it off the paths that cannot answer with it", () => {
|
||||
const overreaching = unfenced.filter((operation) =>
|
||||
refusal(operation)?.description?.includes(
|
||||
SHARING_ACCESS_DENIED_DESCRIPTION,
|
||||
),
|
||||
);
|
||||
expect(overreaching.map(name)).toEqual([]);
|
||||
});
|
||||
|
||||
it("draws only from the reviewed delegable allowlists", () => {
|
||||
// Containment, not equality, and per FILE rather than per verb — which is
|
||||
// how `sharing-surface-guard.test.ts` keys its lists, because the
|
||||
// declaration a human reviewed is "this module may act on another record",
|
||||
// with the verb-level detail in the entry's prose beside it.
|
||||
//
|
||||
// What this closes: the guard above recomputes the delegable set from the
|
||||
// route sources, so a route that started resolving the fence would be
|
||||
// discovered and dressed with a refusal automatically — published as
|
||||
// delegable without anybody deciding it should be. Held to the reviewed
|
||||
// lists, that becomes two failures instead of a silent contract change.
|
||||
const guard = readFileSync(
|
||||
join(ROOT, "src/__tests__/sharing-surface-guard.test.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const allowlist = (name: string): string[] => {
|
||||
const start = guard.indexOf(`const ${name}`);
|
||||
expect(
|
||||
start,
|
||||
`${name} is missing from the surface guard`,
|
||||
).toBeGreaterThan(-1);
|
||||
const block = guard.slice(start, guard.indexOf("\n};", start));
|
||||
const keys = [...block.matchAll(/^ {2}"(app\/[^"]+)":/gm)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
// A block that matched nothing would make the union vacuous and the
|
||||
// containment below unfalsifiable.
|
||||
expect(keys.length, name).toBeGreaterThan(0);
|
||||
return keys;
|
||||
};
|
||||
|
||||
const reviewed = new Set([
|
||||
...allowlist("DELEGABLE_ROUTES"),
|
||||
...allowlist("DELEGABLE_MANAGE_ROUTES"),
|
||||
...allowlist("GUARDIAN_ROUTES"),
|
||||
]);
|
||||
expect(reviewed.size).toBeGreaterThan(50);
|
||||
|
||||
const contributing = new Set(
|
||||
fenced.map((operation) =>
|
||||
routeModulePath(operation.path)
|
||||
.slice(join(ROOT, "src/").length)
|
||||
.replaceAll("\\", "/"),
|
||||
),
|
||||
);
|
||||
expect(contributing.size).toBeGreaterThan(50);
|
||||
|
||||
const unreviewed = [...contributing].filter((file) => !reviewed.has(file));
|
||||
if (unreviewed.length > 0) {
|
||||
throw new Error(
|
||||
`${unreviewed.length} published operation(s) resolve the record fence from a module the surface guard has not reviewed:\n` +
|
||||
unreviewed.map((file) => ` ❌ src/${file}`).join("\n") +
|
||||
"\n\nA module becomes delegable by decision, not by discovery: add it " +
|
||||
"to the matching list in sharing-surface-guard.test.ts with the " +
|
||||
"reason, or take the fence back out.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the refusal into the generated document", () => {
|
||||
const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8");
|
||||
expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60));
|
||||
// The emitter aliases identical response objects, so the sentence appears
|
||||
// once per DISTINCT composition rather than once per path. Both ends are
|
||||
// checked: the text is there, and the paths that reference it outnumber it.
|
||||
const aliases = spec.match(/"403": \*/g)?.length ?? 0;
|
||||
expect(aliases).toBeGreaterThan(fenced.length - 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* An environment variable the product SHOWS a user has to reach the container.
|
||||
*
|
||||
* ## The failure this exists for
|
||||
*
|
||||
* `docker-compose.yml`'s `environment:` block is a whitelist. Docker Compose
|
||||
* reads `.env` for `${VAR}` substitution, so a value an operator sets there
|
||||
* appears to be configured, and a variable not named in the block never
|
||||
* arrives in the process. v1.5.2 shipped that exact defect for
|
||||
* `SESSION_COOKIE_SECURE`: documented, settable, and silently absent, which on
|
||||
* a plain-HTTP self-host reads as "login is broken".
|
||||
*
|
||||
* `AUDIT_LOG_RETENTION_DAYS` is a sharper version of the same class, because
|
||||
* the number is not merely enforced — it is PUBLISHED. `GET
|
||||
* /api/account/grants` and `GET /api/account/activity` both send it, the
|
||||
* activity list prints it, and the dialog that ends somebody's access states
|
||||
* how long "who entered what" stays answerable. An operator who sets 90 and
|
||||
* does not get it told every user 365, in a sentence about their own data.
|
||||
*
|
||||
* ## What this checks, and what it cannot
|
||||
*
|
||||
* The three places an operator meets a variable: the compose whitelist, the
|
||||
* self-host example, and the production example. It cannot check that the
|
||||
* value is honoured — `getAuditLogRetentionDays()` has its own tests — only
|
||||
* that a value set the documented way is not dropped on the floor between
|
||||
* `.env` and the process.
|
||||
*
|
||||
* The list is deliberately short. It is not every variable in the manifest:
|
||||
* `scripts/check-env.ts` owns that question and CI runs it against
|
||||
* `.env.production.example`. This is the narrower one that check-env cannot
|
||||
* answer, because it reads a file rather than the compose whitelist.
|
||||
*
|
||||
* v1.37.0 — a fourth place joined the three. Both of these variables were
|
||||
* documented in the two example files and whitelisted in compose, and NEITHER
|
||||
* was in `scripts/env-manifest.json` — so `pnpm check-env` had never heard of
|
||||
* them, and an operator running the pre-deploy check against their own
|
||||
* environment was told nothing about the two settings this guard exists for.
|
||||
* Three places held them in step and the fourth, the one CI runs, did not.
|
||||
*/
|
||||
const ROOT = process.cwd();
|
||||
const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
|
||||
const ENV_EXAMPLE = readFileSync(join(ROOT, ".env.example"), "utf8");
|
||||
const PROD_EXAMPLE = readFileSync(
|
||||
join(ROOT, ".env.production.example"),
|
||||
"utf8",
|
||||
);
|
||||
const MANIFEST = readFileSync(join(ROOT, "scripts/env-manifest.json"), "utf8");
|
||||
|
||||
/** Every variable name the manifest declares, in any group. */
|
||||
const MANIFEST_NAMES: string[] = (
|
||||
JSON.parse(MANIFEST) as {
|
||||
groups: Array<{ variables: Array<{ name: string; anyOf?: string[] }> }>;
|
||||
}
|
||||
).groups.flatMap((group) =>
|
||||
group.variables.flatMap((variable) => [
|
||||
variable.name,
|
||||
...(variable.anyOf ?? []),
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Variables whose value the product states back to a user.
|
||||
*
|
||||
* A variable earns a place here by being readable somewhere in the interface,
|
||||
* not by being important. That is the property that makes a missing whitelist
|
||||
* entry a lie rather than an inconvenience.
|
||||
*/
|
||||
const PUBLISHED_TO_USERS = [
|
||||
{
|
||||
name: "AUDIT_LOG_RETENTION_DAYS",
|
||||
shownBy: "the shared-access activity window and the revoke dialog",
|
||||
},
|
||||
{
|
||||
name: "SESSION_COOKIE_SECURE",
|
||||
shownBy:
|
||||
"nothing directly — the original of this defect class, kept as a control",
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("a variable the product publishes reaches the container", () => {
|
||||
it("has a matcher that can fail", () => {
|
||||
// The pins. Every leg below asserts a substring is PRESENT, and a file
|
||||
// that failed to load would be an empty string in which nothing is
|
||||
// present — so the failure would be loud rather than silent. These make
|
||||
// the fixtures' own size the evidence.
|
||||
expect(PUBLISHED_TO_USERS.length).toBeGreaterThan(1);
|
||||
expect(COMPOSE).toContain("environment:");
|
||||
expect(ENV_EXAMPLE.length).toBeGreaterThan(1000);
|
||||
expect(PROD_EXAMPLE.length).toBeGreaterThan(1000);
|
||||
// The manifest really parsed, and really carries names. An empty list
|
||||
// would make the manifest leg below a quiet pass for every entry.
|
||||
expect(MANIFEST_NAMES.length).toBeGreaterThan(20);
|
||||
expect(MANIFEST_NAMES).toContain("DATABASE_URL");
|
||||
expect(MANIFEST_NAMES).not.toContain("HEALTHLOG_NOT_A_REAL_VARIABLE");
|
||||
// And a name nothing declares is genuinely absent from all three, so the
|
||||
// matchers below are not passing on a substring that is everywhere.
|
||||
for (const file of [COMPOSE, ENV_EXAMPLE, PROD_EXAMPLE]) {
|
||||
expect(file).not.toContain("HEALTHLOG_NOT_A_REAL_VARIABLE");
|
||||
}
|
||||
});
|
||||
|
||||
for (const { name, shownBy } of PUBLISHED_TO_USERS) {
|
||||
describe(name, () => {
|
||||
it(`is on the compose whitelist, so a value in .env arrives (${shownBy})`, () => {
|
||||
// The substitution form, not merely the name: a bare mention in a
|
||||
// comment is how this class of defect hides.
|
||||
expect(COMPOSE).toMatch(
|
||||
new RegExp(`^\\s*${name}:\\s*"\\$\\{${name}`, "m"),
|
||||
);
|
||||
});
|
||||
|
||||
it("is documented in the self-host example", () => {
|
||||
expect(ENV_EXAMPLE).toContain(name);
|
||||
});
|
||||
|
||||
it("is documented in the production example", () => {
|
||||
expect(PROD_EXAMPLE).toContain(name);
|
||||
});
|
||||
|
||||
it("is declared in the manifest, so `pnpm check-env` reports it", () => {
|
||||
// The fourth place, and the one CI actually runs. A variable an
|
||||
// operator is told to set, and which the pre-deploy check has never
|
||||
// heard of, is documented everywhere except where it would be checked.
|
||||
expect(MANIFEST_NAMES).toContain(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* A fenced route may not be reached by a transport that cannot carry a header.
|
||||
*
|
||||
* The record-session fence is asserted on request HEADERS, attached inside
|
||||
* `src/lib/api/api-fetch.ts`. Three browser transports never go through that
|
||||
* wrapper and cannot be made to:
|
||||
*
|
||||
* * `<img src="/api/…">` — the browser issues the subresource itself;
|
||||
* * `<iframe src="/api/…">` — likewise, and it is a document load;
|
||||
* * `<a href="/api/…" download>` — a navigation, not a fetch.
|
||||
*
|
||||
* On a session that has never entered a shared record this is invisible: the
|
||||
* fence exempts `record_epoch = 0`, so a headerless request is served exactly
|
||||
* as it always was. The moment the session enters or leaves a shared record the
|
||||
* epoch moves, the exemption ends, and every one of those three gets
|
||||
* `403 sharing.access.denied` — permanently, because nothing about them will
|
||||
* ever start sending a header. A thumbnail that silently stops rendering after
|
||||
* somebody visits a shared record is the shape of the bug.
|
||||
*
|
||||
* The fix shape already existed in the repository before this guard did:
|
||||
* `src/components/documents/document-ai-transport.ts` reaches
|
||||
* `/api/documents/inbound/{id}/original` through `apiFetchRaw` and turns the
|
||||
* blob into a `File`. Everything here follows it — fetch through the app
|
||||
* transport, hand the DOM an object URL, revoke it on unmount.
|
||||
*
|
||||
* ## What this matcher can and cannot see
|
||||
*
|
||||
* It resolves ONE level of local aliasing: `const src = \`/api/…\`` followed by
|
||||
* `src={src}` is caught, because that is the shape the real offenders had and a
|
||||
* matcher that only read inline literals would have found two of the six and
|
||||
* reported success. It does NOT trace a path built by a helper, assembled
|
||||
* across two hops, or returned from a hook.
|
||||
*
|
||||
* It also only knows the routes it can RESOLVE. A path whose route module it
|
||||
* cannot find is reported separately rather than silently passing, because an
|
||||
* unresolvable path is the one case where a green result would mean nothing.
|
||||
*
|
||||
* ### The blind spots, named
|
||||
*
|
||||
* Every one of these issues a browser request that carries no record-session
|
||||
* assertion, and none of them is matched here. They are written down rather
|
||||
* than matched because each is either absent from the tree today or costs more
|
||||
* to detect than it currently buys — but a future offender in any of these
|
||||
* shapes SLIPS this guard, and that is the thing to know before trusting a
|
||||
* green run:
|
||||
*
|
||||
* * `srcSet` on an `<img>` / `<source>`, and `poster` on a `<video>`;
|
||||
* * `<object data=…>` and `<embed src=…>`;
|
||||
* * `<form action="/api/…">`, which navigates on submit;
|
||||
* * `window.open("/api/…")`;
|
||||
* * `navigator.sendBeacon("/api/…")`, which is fire-and-forget and cannot
|
||||
* carry a custom header at all;
|
||||
* * a CSS `url("/api/…")` in a style attribute, a styled component, or a
|
||||
* stylesheet;
|
||||
* * props SPREAD onto an element (`<img {...props} />`) where the path
|
||||
* arrives from a caller;
|
||||
* * anything outside `src/components` and `src/app` — those are the only two
|
||||
* trees walked.
|
||||
*
|
||||
* The `element.src = "/api/…"` ASSIGNMENT form IS matched, because it is cheap
|
||||
* and the tree already contains one (`umami-script.tsx`, an unfenced route, so
|
||||
* no offender today — but it is proof the shape occurs here).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const API_ROOT = join(SRC, "app", "api");
|
||||
|
||||
/** Client-side trees. `src/app/api` is the server end and is excluded. */
|
||||
const CLIENT_ROOTS = [join(SRC, "components"), join(SRC, "app")];
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
"__tests__",
|
||||
"__mocks__",
|
||||
"node_modules",
|
||||
".next",
|
||||
"generated",
|
||||
]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) {
|
||||
if (p === API_ROOT) continue;
|
||||
walk(p, out);
|
||||
} else if (
|
||||
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
||||
!name.endsWith(".test.ts") &&
|
||||
!name.endsWith(".test.tsx")
|
||||
) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `/api/...` path template to its route module.
|
||||
*
|
||||
* `${…}` segments match any `[param]` directory, which is how
|
||||
* `/api/documents/inbound/${id}/thumbnail` finds
|
||||
* `src/app/api/documents/inbound/[id]/thumbnail/route.ts`. Query strings and
|
||||
* trailing fragments are dropped before matching.
|
||||
*/
|
||||
function resolveRouteModules(path: string): string[] {
|
||||
const clean = path.split(/[?#]/)[0];
|
||||
const segments = clean
|
||||
.replace(/^\/api\//, "")
|
||||
.split("/")
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
let dirs = [API_ROOT];
|
||||
for (const segment of segments) {
|
||||
const templated = segment.includes("${");
|
||||
const next: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
if (!templated && existsSync(join(dir, segment))) {
|
||||
next.push(join(dir, segment));
|
||||
continue;
|
||||
}
|
||||
// A templated segment is a WILDCARD over every child directory, literal
|
||||
// and dynamic alike. `${id}` finds `[id]`; `${provider}` finds
|
||||
// `withings`, `fitbit`, `whoop`, `google-health`. Taking only the
|
||||
// dynamic children would have silently dropped the second shape, and a
|
||||
// path that resolves to nothing reads as "no fenced route here".
|
||||
if (!templated) continue;
|
||||
for (const child of readdirSync(dir)) {
|
||||
const p = join(dir, child);
|
||||
if (statSync(p).isDirectory()) next.push(p);
|
||||
}
|
||||
}
|
||||
dirs = next;
|
||||
}
|
||||
return dirs
|
||||
.map((dir) => join(dir, "route.ts"))
|
||||
.filter((module) => existsSync(module));
|
||||
}
|
||||
|
||||
/** Does this route resolve a RECORD, and therefore consult the fence? */
|
||||
function isFenced(routeModule: string): boolean {
|
||||
const src = readFileSync(routeModule, "utf8");
|
||||
return (
|
||||
/\brequireRecordAuth\s*\(/.test(src) ||
|
||||
/\brequireGuardianAuth\s*\(/.test(src)
|
||||
);
|
||||
}
|
||||
|
||||
interface Reference {
|
||||
file: string;
|
||||
line: number;
|
||||
sink: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const API_LITERAL = /^[`"']((?:\/api\/)[^`"']*)[`"']$/;
|
||||
|
||||
/**
|
||||
* Every `/api/...` path handed to a headerless transport in one file.
|
||||
*
|
||||
* Sinks: `src=`, `href=`, and any `window.location` write. Each is resolved
|
||||
* against a one-level alias map built from the file's own
|
||||
* `const NAME = "/api/…"` declarations.
|
||||
*/
|
||||
/**
|
||||
* Comments are stripped before matching.
|
||||
*
|
||||
* A doc comment that NAMES `src="/api/…"` while explaining why the code no
|
||||
* longer does that is prose, not a transport — and this guard's own
|
||||
* explanations are written in exactly that shape. Commented-out code is
|
||||
* dropped with them, which is correct: it issues no request. Same treatment
|
||||
* and the same reason as `account-payload-consumer-guard.test.ts`.
|
||||
*/
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function headerlessReferences(file: string, source: string): Reference[] {
|
||||
const aliases = new Map<string, string>();
|
||||
// `const NAME = "/api/…"`, and the ternary form
|
||||
// `const NAME = cond ? \`/api/…\` : "#"` that the download links use. Both
|
||||
// are one hop, which is the documented limit of this resolution.
|
||||
for (const m of source.matchAll(
|
||||
/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*[^;\n]*?([`"'])(\/api\/[^`"']*)\2/g,
|
||||
)) {
|
||||
if (!aliases.has(m[1])) aliases.set(m[1], m[3]);
|
||||
}
|
||||
|
||||
const found: Reference[] = [];
|
||||
const record = (index: number, sink: string, raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
const literal = API_LITERAL.exec(trimmed);
|
||||
const path = literal ? literal[1] : (aliases.get(trimmed) ?? null);
|
||||
if (path === null) return;
|
||||
found.push({
|
||||
file,
|
||||
line: source.slice(0, index).split("\n").length,
|
||||
sink,
|
||||
path,
|
||||
});
|
||||
};
|
||||
|
||||
// `src={expr}` / `src="literal"` and the same for `href`.
|
||||
//
|
||||
// The expression container is read with a brace COUNTER, not a
|
||||
// `[^}]*` class: a template literal like `` `/api/x/${id}/y` `` contains a
|
||||
// `}` of its own, and a non-counting matcher truncates at it and then fails
|
||||
// to recognise the literal at all. That is how the first draft of this guard
|
||||
// found two of six offenders and reported success.
|
||||
for (const m of source.matchAll(/\b(src|href)=/g)) {
|
||||
const start = m.index + m[0].length;
|
||||
const ch = source[start];
|
||||
if (ch === "{") {
|
||||
let depth = 0;
|
||||
let end = start;
|
||||
for (; end < source.length; end++) {
|
||||
if (source[end] === "{") depth++;
|
||||
else if (source[end] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
record(m.index, m[1], source.slice(start + 1, end));
|
||||
} else if (ch === '"' || ch === "'" || ch === "`") {
|
||||
const end = source.indexOf(ch, start + 1);
|
||||
if (end !== -1) record(m.index, m[1], source.slice(start, end + 1));
|
||||
}
|
||||
}
|
||||
// Any `window.location` write or navigation.
|
||||
for (const m of source.matchAll(
|
||||
/window\.location(?:\.href)?\s*=\s*([^;\n]+)|window\.location\.(?:assign|replace)\(\s*([^)\n]+)\)/g,
|
||||
)) {
|
||||
record(m.index, "window.location", m[1] ?? m[2] ?? "");
|
||||
}
|
||||
// The imperative form: `el.src = "/api/…"`. A JSX attribute is the common
|
||||
// shape and the one the real offenders had, but a script or image built in
|
||||
// an effect issues exactly the same headerless request — and the tree
|
||||
// already contains that shape, so this is not a hypothetical.
|
||||
for (const m of source.matchAll(/\.\s*(src|href)\s*=\s*([^;\n]+)/g)) {
|
||||
record(m.index, `.${m[1]} =`, m[2]);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("no headerless browser transport reaches a fenced route", () => {
|
||||
const files = CLIENT_ROOTS.flatMap((root) => walk(root));
|
||||
const references = files.flatMap((file) =>
|
||||
headerlessReferences(
|
||||
file.slice(SRC.length + 1),
|
||||
stripComments(readFileSync(file, "utf8")),
|
||||
),
|
||||
);
|
||||
|
||||
it("finds a plausible client tree and a plausible set of references", () => {
|
||||
// Non-zero proof, twice: a walk that found nothing, or a matcher that
|
||||
// matched nothing, would make every assertion below vacuous.
|
||||
expect(files.length).toBeGreaterThan(200);
|
||||
expect(references.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it("resolves every referenced /api path to a route module", () => {
|
||||
// An unresolvable path is the one case where a green result would mean
|
||||
// nothing at all, so it fails loudly rather than being skipped.
|
||||
const unresolved = references.filter(
|
||||
(r) => resolveRouteModules(r.path).length === 0,
|
||||
);
|
||||
expect(unresolved.map((r) => `${r.file}:${r.line} → ${r.path}`)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the imperative `.src =` form, not only JSX attributes", () => {
|
||||
// A new matcher that matches nothing is a check that cannot fail, so it is
|
||||
// anchored to a real occurrence: the monitoring script builds its element
|
||||
// in an effect and assigns `.src`. That route is UNFENCED, so it is not an
|
||||
// offender — which is exactly why it makes a safe anchor, and why this leg
|
||||
// asserts the reference is SEEN rather than that it is refused.
|
||||
const assignments = references.filter((r) => r.sink === ".src =");
|
||||
expect(assignments.length).toBeGreaterThan(0);
|
||||
expect(assignments.map((r) => r.path)).toContain(
|
||||
"/api/monitoring/umami-script",
|
||||
);
|
||||
expect(
|
||||
resolveRouteModules("/api/monitoring/umami-script").some(isFenced),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves a known fenced route, so the fenced check is live", () => {
|
||||
// Without this the leg below would pass for an `isFenced` that always
|
||||
// returned false.
|
||||
const thumbnail = resolveRouteModules(
|
||||
"/api/documents/inbound/${id}/thumbnail",
|
||||
);
|
||||
expect(thumbnail).toHaveLength(1);
|
||||
expect(isFenced(thumbnail[0])).toBe(true);
|
||||
const connect = resolveRouteModules("/api/withings/connect");
|
||||
expect(connect).toHaveLength(1);
|
||||
expect(isFenced(connect[0])).toBe(false);
|
||||
// The wildcard shape resolves to several real routes, none of them fenced.
|
||||
const providers = resolveRouteModules("/api/${provider}/connect");
|
||||
expect(providers.length).toBeGreaterThan(2);
|
||||
expect(providers.some(isFenced)).toBe(false);
|
||||
});
|
||||
|
||||
it("names no fenced route in a src=, href= or window.location sink", () => {
|
||||
const offenders = references.filter((r) =>
|
||||
resolveRouteModules(r.path).some(isFenced),
|
||||
);
|
||||
|
||||
if (offenders.length > 0) {
|
||||
const report = offenders
|
||||
.map((o) => ` ❌ ${o.file}:${o.line} — ${o.sink}= ${o.path}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`${offenders.length} headerless reference(s) to a record-fenced route:\n${report}\n\n` +
|
||||
"These cannot carry the record-session fence headers, so they are refused with " +
|
||||
"403 sharing.access.denied on any session that has entered or left a shared " +
|
||||
"record. Fetch through `apiFetchRaw` and hand the DOM an object URL — see " +
|
||||
"`src/components/documents/document-ai-transport.ts` for the shape, and revoke " +
|
||||
"the URL when the component unmounts.",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* The ten acceptance cases of the record-session fence, mapped to the files
|
||||
* that prove them — as a runnable artifact rather than a table in a document.
|
||||
*
|
||||
* A dropped case is the failure this exists to catch. A test deleted during a
|
||||
* refactor takes its coverage with it silently; a document claiming ten cases
|
||||
* keeps claiming ten. So every case carries a stable id, the id appears in the
|
||||
* TITLE of at least one `it(` / `test(` in the file named below, and this file
|
||||
* asserts the mapping.
|
||||
*
|
||||
* ## What this guard cannot do, said plainly
|
||||
*
|
||||
* It proves an id appears in a test title. It does NOT prove that test asserts
|
||||
* anything worth asserting — that is what the named instruments are for, and
|
||||
* each case below records which instrument carries its weight. A guard that
|
||||
* only counted titles would be satisfiable by ten empty tests, so the
|
||||
* per-case `instrument` field is a review anchor, not a claim this file
|
||||
* verifies.
|
||||
*
|
||||
* ## Executed here versus deferred
|
||||
*
|
||||
* Five cases are proved by Vitest files that run in the ordinary gate. Five
|
||||
* ride `e2e/v137-record-session-fence.spec.ts` and therefore run only in the
|
||||
* Playwright acceptance stage. That split is recorded in `runner` below and
|
||||
* asserted, so nobody can read a green run of THIS file as "all ten proved" —
|
||||
* it means "all ten are claimed, and here is which ones have actually
|
||||
* executed".
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
interface AcceptanceCase {
|
||||
id: string;
|
||||
/** What the handoff asked for. */
|
||||
case: string;
|
||||
/** Where it lives, relative to the repository root. */
|
||||
home: string;
|
||||
/** Vitest runs in the ordinary gate; Playwright runs in the acceptance stage. */
|
||||
runner: "vitest" | "playwright";
|
||||
/** The thing that makes the assertion falsifiable. A review anchor. */
|
||||
instrument: string;
|
||||
}
|
||||
|
||||
const CASES: AcceptanceCase[] = [
|
||||
{
|
||||
id: "FENCE-AC-01",
|
||||
case: "Switch in and out; peers reconcile to the exact new epoch and scope",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Every record read the peer issues afterwards is inspected for the asserted epoch and scope; the epoch must have moved and the scope must name the record, not merely differ. Paired with a shell-ready assertion so a wedged page cannot pass.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-02",
|
||||
case: "Server switch driven externally, initiator response aborted",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Peers never assert a guessed owner scope under the bootstrap sentinel, AND do reach ready on the target scope once /me resolves. The second half is mandatory: an absence-only assertion passes on a permanently wedged page.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-03",
|
||||
case: "Initiator closed after the server commit, before any commit broadcast",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"The surviving tab reaches ready rather than sitting behind a permanent hydration gate — /me is the bootstrap and answers regardless of who began the transition.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-04",
|
||||
case: "Raw or external switch; a stale peer read and mutation refused before handler or database work",
|
||||
home: "tests/integration/record-session-fence.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"AccountGrant.lastUsedAt unchanged and no audit_logs row with action = 'sharing.record.accessed' for the pair — both are written only past admission, so their absence is the no-database-work proof. Asserted BEFORE the status code so a removed fence fails on the instrument rather than being masked. Paired with a positive control that moves both. Also the findFirst zero-call spy in src/lib/__tests__/acting-account-resolver.test.ts, plus an e2e leg driving the switch from a request context that runs no client journal.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-05",
|
||||
case: "Owner idempotent mutation and replay, switch before handler authorization",
|
||||
home: "tests/integration/record-session-fence-idempotency.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"The seeded owner cell is byte-unchanged — same id, responseStatus, responseBody, expiresAt, createdAt. expiresAt carries the weight: completion rewrites it to now+24h and a fresh claim to now+2min, so any wrapper that reached the cache moves it. The 'no row exists' assertion was rejected as unfalsifiable, because releaseClaim deletes the pending row whenever the handler throws.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-06",
|
||||
case: "Record response delayed across the switch; the client discards and holds",
|
||||
home: "src/lib/api/__tests__/record-fence-client.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"A response whose echoed epoch is one behind the adopted one makes the caller throw and fires the hold exactly once, while a response carrying NO echo is returned untouched and fires nothing. The second half is the version-poller case and is what stops the rule from being discard-on-absence. An e2e leg holds a real record read open across a real switch.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-07",
|
||||
case: "CacheStorage seeded with one record's responses; the disk layer cannot serve them under another",
|
||||
home: "e2e/v137-record-session-fence-offline.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Two tests, contents and serving, because the first alone is not the claim. CONTENTS: a non-zero seeded entry count as the positive control, the cache-version eviction, then every stored response carrying the target's scope and none the previous record's. SERVING: after an EXTERNAL switch — the path on which the app never runs its cache wipe, so the previous record's entries survive — cache.match() under the stored context hits (positive control) and under the new context misses. Removing the Vary block makes the serving test fail and leaves the contents test green, which is why both exist. NOT asserted: a zero entry count after a switch (the wipe is real but the reload refills it within milliseconds), and no offline leg — a bare page-context fetch cannot match a Vary-keyed entry, which is itself the property the serving test proves. Its own spec and its own Playwright project because every other project sets serviceWorkers: 'block', under which the data cache is never populated and the positive control could not pass whatever the application did.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-08",
|
||||
case: "Concurrent switchers; monotonic compare-and-set ordering, a stale switch cannot override",
|
||||
home: "tests/integration/record-session-fence.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"Two same-epoch switches to genuinely distinct targets resolve to exactly one 'applied' and one 'stale', and the epoch handed back to the winner equals the row's committed value. A separate leg pins the no-op case, where the trigger declines to move and 'applied' is the honest answer.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-09",
|
||||
case: "Revoke, account deletion, managed-profile lifecycle, FK-driven clearing",
|
||||
home: "tests/integration/record-session-epoch-trigger.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"The ON DELETE SET NULL referential action and a raw SQL clear both bump the epoch with no application code running, while the per-request last_active_at and expires_at writes do not. A lifecycle leg in tests/integration/record-session-fence.test.ts then asserts the fence refuses the pre-transition context after a revoke and after an owner deletion.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-10",
|
||||
case: "Pre-fence cookie callers fail closed into their reload; token and native keep the frozen contract",
|
||||
home: "src/__tests__/record-session-fence-compat.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"Every link in the pre-fence tab's only recovery chain is frozen, and the 409 is asserted ABSENT from the grant-loss predicate. The fence's own positive controls live in tests/integration/record-session-fence.test.ts: a Bearer request with no fence header is served, and an epoch-zero cookie request with no fence header is served. Without those two a fence that refused everything would pass every negative test in this plan.",
|
||||
},
|
||||
];
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(ROOT, rel), "utf8");
|
||||
}
|
||||
|
||||
/** Every `it(` / `test(` title in a file. */
|
||||
function testTitles(source: string): string[] {
|
||||
const titles: string[] = [];
|
||||
for (const m of source.matchAll(
|
||||
/\b(?:it|test)(?:\.\w+)*\s*\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g,
|
||||
)) {
|
||||
titles.push(m[2]);
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
describe("the ten acceptance cases are all claimed by a runnable test", () => {
|
||||
it("declares exactly ten distinct ids", () => {
|
||||
expect(CASES).toHaveLength(10);
|
||||
expect(new Set(CASES.map((c) => c.id)).size).toBe(10);
|
||||
for (const c of CASES) {
|
||||
expect(c.id).toMatch(/^FENCE-AC-\d{2}$/);
|
||||
// A review anchor with nothing in it is not one.
|
||||
expect(c.instrument.length).toBeGreaterThan(80);
|
||||
expect(c.case.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(CASES)("$id appears in a test title in $home", (c) => {
|
||||
const titles = testTitles(read(c.home));
|
||||
// Non-zero proof: a matcher that found no titles at all would make the
|
||||
// membership check below vacuous.
|
||||
expect(titles.length).toBeGreaterThan(0);
|
||||
const carrying = titles.filter((t) => t.includes(c.id));
|
||||
expect(carrying.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("records which cases have actually executed in this gate", () => {
|
||||
// Five cases ride Playwright and therefore prove nothing until the
|
||||
// acceptance stage runs. Stating the split here is what stops a green run
|
||||
// of this file from being read as "all ten proved".
|
||||
const vitest = CASES.filter((c) => c.runner === "vitest").map((c) => c.id);
|
||||
const playwright = CASES.filter((c) => c.runner === "playwright").map(
|
||||
(c) => c.id,
|
||||
);
|
||||
|
||||
expect(vitest).toEqual([
|
||||
"FENCE-AC-04",
|
||||
"FENCE-AC-05",
|
||||
"FENCE-AC-06",
|
||||
"FENCE-AC-08",
|
||||
"FENCE-AC-09",
|
||||
"FENCE-AC-10",
|
||||
]);
|
||||
expect(playwright).toEqual([
|
||||
"FENCE-AC-01",
|
||||
"FENCE-AC-02",
|
||||
"FENCE-AC-03",
|
||||
"FENCE-AC-07",
|
||||
]);
|
||||
expect(vitest.length + playwright.length).toBe(10);
|
||||
});
|
||||
|
||||
it("names only files that exist and carry real tests", () => {
|
||||
for (const home of new Set(CASES.map((c) => c.home))) {
|
||||
const source = read(home);
|
||||
expect(source.length).toBeGreaterThan(500);
|
||||
expect(testTitles(source).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* FENCE-AC-10 — the only recovery a pre-fence browser tab has, frozen.
|
||||
*
|
||||
* A tab open at deploy time that is inside or after a shared-record context
|
||||
* sends no fence header, so the server refuses it with 403
|
||||
* `sharing.access.denied`. That is not an arbitrary choice of code: it is the
|
||||
* one code the CURRENTLY DEPLOYED bundle already acts on. `SharedRecordGrantLossBridge`
|
||||
* is mounted globally, subscribes to the whole query cache, and on the first
|
||||
* errored query carrying that code leaves the record and performs a full
|
||||
* document navigation — which serves the fence-aware bundle. One reload,
|
||||
* self-healing, no new code in a bundle nobody can edit any more.
|
||||
*
|
||||
* Every link in that chain is load-bearing and every one of them looks
|
||||
* removable to somebody tidying up. So each is asserted here, with a non-zero
|
||||
* match first, and the reason written down.
|
||||
*
|
||||
* The fourth leg is the opposite assertion and matters as much: the 409
|
||||
* `sharing.session.changed` must NOT be in the set the grant-loss bridge fires
|
||||
* on. That code means "reconcile, do not leave"; routing it into a bridge that
|
||||
* hard-navigates out of the record would turn a one-frame hold into an
|
||||
* eviction, and would let one confused tab yank every other tab of the same
|
||||
* browser out of a record nobody revoked.
|
||||
*
|
||||
* Break it by adding `sharing.session.changed` to the bridge's predicate.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
const SWITCH_HOOK = read("hooks/use-account-switch.ts");
|
||||
const PROVIDERS = read("components/providers.tsx");
|
||||
const GRANT_LOSS_BRIDGE = read(
|
||||
"components/layout/shared-record-grant-loss-bridge.tsx",
|
||||
);
|
||||
const FENCE_BRIDGE = read("components/layout/record-session-fence-bridge.tsx");
|
||||
|
||||
describe("FENCE-AC-10 a pre-fence tab can still recover", () => {
|
||||
it("FENCE-AC-10 still keys the grant-loss predicate on sharing.access.denied", () => {
|
||||
// Non-zero proof first: an emptied file would satisfy a bare `toContain`
|
||||
// check on nothing.
|
||||
expect(SWITCH_HOOK.length).toBeGreaterThan(1000);
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/SHARING_ACCESS_DENIED\s*=\s*"sharing\.access\.denied"/,
|
||||
);
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/export function isSharingAccessDenied[\s\S]{0,200}SHARING_ACCESS_DENIED/,
|
||||
);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 still mounts the grant-loss bridge globally", () => {
|
||||
expect(PROVIDERS).toContain("SharedRecordGrantLossBridge");
|
||||
expect(PROVIDERS).toMatch(/<SharedRecordGrantLossBridge\s*\/>/);
|
||||
// And the bridge still subscribes rather than asking call sites.
|
||||
expect(GRANT_LOSS_BRIDGE).toContain("subscribeToGrantLoss");
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/subscribeToGrantLoss[\s\S]{0,600}isSharingAccessDenied\(event\.query\.state\.error\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 still leaves the record with a full document navigation", () => {
|
||||
// `router.push` would not do: a soft navigation keeps the in-memory cache,
|
||||
// every React tree holding a rendered value, and — critically here — the
|
||||
// PRE-FENCE JavaScript bundle. The reload is what serves the new one.
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/async function landOnRecord[\s\S]{0,400}window\.location\.assign\(destination\)/,
|
||||
);
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/useLeaveSharedRecordOnGrantLoss[\s\S]{0,700}await landOnRecord\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 does NOT route the 409 into the grant-loss bridge", () => {
|
||||
// The predicate the bridge fires on.
|
||||
const predicate = /subscribeToGrantLoss\(([\s\S]*?)\n\}/.exec(SWITCH_HOOK);
|
||||
expect(predicate).not.toBeNull();
|
||||
const body = predicate![1];
|
||||
expect(body.length).toBeGreaterThan(50);
|
||||
expect(body).toContain("isSharingAccessDenied");
|
||||
expect(body).not.toContain("isRecordSessionChanged");
|
||||
expect(body).not.toContain("sharing.session.changed");
|
||||
// The grant-loss bridge component itself must not learn about it either.
|
||||
expect(GRANT_LOSS_BRIDGE).not.toContain("sharing.session.changed");
|
||||
expect(GRANT_LOSS_BRIDGE).not.toContain("isRecordSessionChanged");
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 routes the 409 into a reconciling bridge instead", () => {
|
||||
// The positive control for the leg above: without it, "the 409 is not in
|
||||
// the grant-loss bridge" would pass for a release that dropped the 409
|
||||
// handling altogether.
|
||||
expect(PROVIDERS).toMatch(/<RecordSessionFenceBridge\s*\/>/);
|
||||
expect(FENCE_BRIDGE).toContain("isRecordSessionChanged");
|
||||
expect(FENCE_BRIDGE).toContain("holdForRecordSessionReconcile");
|
||||
expect(FENCE_BRIDGE).toContain("onRecordFenceMismatch");
|
||||
// It reconciles; it does not leave.
|
||||
expect(FENCE_BRIDGE).not.toContain("landOnRecord");
|
||||
expect(FENCE_BRIDGE).not.toContain("window.location.assign");
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 keeps the two codes distinct at the source", () => {
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/SHARING_SESSION_CHANGED\s*=\s*"sharing\.session\.changed"/,
|
||||
);
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/export function isRecordSessionChanged[\s\S]{0,200}SHARING_SESSION_CHANGED/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FENCE-AC-10 the browser adopts a context from exactly two responses", () => {
|
||||
it("FENCE-AC-10 has exactly two callers of adoptRecordFenceState", () => {
|
||||
// A third adoption path would be a third source of truth about which
|
||||
// record this browser is on — and it would arrive on the very responses
|
||||
// whose context is in doubt. The 409 body therefore carries an error code
|
||||
// and nothing else, by construction.
|
||||
const CONSUMERS = [
|
||||
// `GET /api/auth/me`, the bootstrap.
|
||||
"hooks/use-auth.ts",
|
||||
// `POST /api/account/switch`, the only other canonical resolver.
|
||||
"hooks/use-account-switch.ts",
|
||||
];
|
||||
|
||||
const callers = CONSUMERS.filter((rel) =>
|
||||
/adoptRecordFenceState\(/.test(read(rel)),
|
||||
);
|
||||
expect(callers).toEqual(CONSUMERS);
|
||||
|
||||
// And no third file calls it. The declaring module is allowed to name it.
|
||||
for (const rel of [
|
||||
"components/layout/record-session-fence-bridge.tsx",
|
||||
"components/layout/shared-record-grant-loss-bridge.tsx",
|
||||
"lib/api/api-fetch.ts",
|
||||
]) {
|
||||
expect(read(rel)).not.toContain("adoptRecordFenceState");
|
||||
}
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 adopts before it releases a hold", () => {
|
||||
// A hold released onto an unadopted context puts the shell back on screen
|
||||
// while every subsequent request still asserts `bootstrap`.
|
||||
const src = read("hooks/use-auth.ts");
|
||||
const adopt = src.indexOf("adoptRecordFenceState(data.recordSession)");
|
||||
const settle = src.indexOf("settleRecordSessionTransition(");
|
||||
const scope = src.indexOf("setRecordScope(");
|
||||
expect(adopt).toBeGreaterThan(-1);
|
||||
expect(settle).toBeGreaterThan(-1);
|
||||
expect(scope).toBeGreaterThan(-1);
|
||||
expect(adopt).toBeLessThan(scope);
|
||||
expect(adopt).toBeLessThan(settle);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 names the epoch on every switch", () => {
|
||||
// A fence-aware client never omits `expectedEpoch`; the server's
|
||||
// unconditional arm is reachable only by a bundle that predates the fence.
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/apiPost<SwitchResponse>\([\s\S]{0,200}expectedEpoch:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FENCE-AC-10 the transport attaches the assertion, not the call sites", () => {
|
||||
it("FENCE-AC-10 wraps all three fetch entries", () => {
|
||||
const src = read("lib/api/api-fetch.ts");
|
||||
const wraps = [...src.matchAll(/withRecordFenceHeaders\(/g)];
|
||||
const validates = [...src.matchAll(/validateResponseContext\(/g)];
|
||||
// Three entries: `apiFetch`, `apiFetchEnvelope`, `apiFetchRaw`. The raw one
|
||||
// matters most — ~25 call sites including the Coach SSE stream, several of
|
||||
// which are record traffic.
|
||||
expect(wraps.length).toBe(3);
|
||||
expect(validates.length).toBe(3);
|
||||
expect(src).toMatch(
|
||||
/export async function apiFetchRaw[\s\S]{0,900}withRecordFenceHeaders\(init\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 imports the header names from the client-safe contract only", () => {
|
||||
// Importing the server fence or the acting carrier would pull `next/headers`
|
||||
// or Prisma into the browser bundle: clean typecheck, failed `pnpm build`.
|
||||
const src = read("lib/api/record-fence.ts");
|
||||
expect(src).toContain('from "@/lib/sharing/record-session-fence-contract"');
|
||||
expect(src).not.toContain('from "@/lib/sharing/record-session-fence"');
|
||||
expect(src).not.toContain('from "@/lib/auth/acting-carrier"');
|
||||
expect(src).not.toContain('from "@/lib/api-handler"');
|
||||
expect(src).not.toContain('from "@/lib/db"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Structural guards on the record-session fence.
|
||||
*
|
||||
* The behavioural proofs live in `src/lib/sharing/__tests__/record-session-fence.test.ts`
|
||||
* (the verdict and the refusals), `tests/integration/record-session-fence.test.ts`
|
||||
* (a refusal before any database work, with a positive control) and
|
||||
* `tests/integration/record-session-fence-idempotency.test.ts` (a refusal above
|
||||
* the replay cache). Those prove what today's code does. This file freezes what
|
||||
* tomorrow's may not do, because the fence has one shape that makes it worth
|
||||
* anything:
|
||||
*
|
||||
* * exactly two auth helpers consult it, and both consult it BEFORE they read
|
||||
* a carrier or look up a grant;
|
||||
* * the helpers that deliberately do not consult it still do not;
|
||||
* * one file writes the epoch and a closed list reads it;
|
||||
* * the header names live in four files and no fifth: declared in the contract
|
||||
* module, read by the server fence, attached by the browser transport, and
|
||||
* published by the OpenAPI route module.
|
||||
*
|
||||
* ## What these matchers cannot see
|
||||
*
|
||||
* Everything here is a text match over source, so the guard's own limit is
|
||||
* worth writing down the way `session-surface-guard.test.ts` writes down its
|
||||
* own: **a fence bypass under a different header name would slip every matcher
|
||||
* below.** A future module that read `x-healthlog-record-context` and made its
|
||||
* own admission decision would be invisible here, exactly as the share-link
|
||||
* tree was invisible to a sweep that grepped only for `getSession`. The header
|
||||
* leg pins the names that exist; it cannot pin the ones that do not yet.
|
||||
*
|
||||
* Nor can it prove ordering at runtime. The placement legs compare source
|
||||
* offsets inside a function body, which is a proxy for execution order and
|
||||
* holds only because neither call sits inside a branch. The runtime property —
|
||||
* "no grant was looked up" — is proved by the integration file's `lastUsedAt`
|
||||
* and `sharing.record.accessed` instruments and by the `findActiveGrant`
|
||||
* zero-call spy, not here.
|
||||
*
|
||||
* Every leg asserts a non-zero match count before it asserts a set, because a
|
||||
* guard whose matcher finds nothing reports success, and this repository has
|
||||
* shipped several of those.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function filesMatching(re: RegExp): string[] {
|
||||
return sourceFiles().filter((rel) => re.test(read(rel)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The source text of a top-level exported function, braces included. Same
|
||||
* brace-matching walk `acting-account-boundary-guard.test.ts` uses.
|
||||
*/
|
||||
function functionBody(src: string, name: string): string {
|
||||
const start = src.indexOf(`export async function ${name}(`);
|
||||
if (start === -1) return "";
|
||||
const open = src.indexOf("{", src.indexOf(")", start));
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === "{") depth++;
|
||||
else if (src[i] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const API_HANDLER = read("lib/api-handler.ts");
|
||||
|
||||
/** The two helpers that resolve a record and therefore must consult the fence. */
|
||||
const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const;
|
||||
|
||||
/**
|
||||
* The helpers that deliberately do NOT consult the fence.
|
||||
*
|
||||
* This is a statement about the HELPERS. It must not be read as "no admin route
|
||||
* is ever fenced", and it must not be read as the reverse either.
|
||||
* `POST /api/admin/backups/[id]/restore` composes `withIdempotency` OUTSIDE
|
||||
* `apiHandler`, so the wrapper's fence check runs on it — but that check
|
||||
* refuses only the STALE half. An `unfenced-client` verdict falls through to
|
||||
* the handler, whose `requireAdmin` is on this list, so a headerless admin
|
||||
* request is served exactly as it was before the fence existed. The accurate
|
||||
* claim is therefore narrow: an idempotent admin write under a context the
|
||||
* client asserted WRONGLY is refused; one that asserts nothing is not.
|
||||
*
|
||||
* Each entry's reason, once:
|
||||
*
|
||||
* * `requireActorAuth` is the way OUT of a switch. Fencing it would make an
|
||||
* unprovable context unrecoverable, and it serves the caller's own rows by
|
||||
* definition, so the selector cannot change what it returns.
|
||||
* * `requireAuth` bare already refuses outright under an active switch, and
|
||||
* its routes are classified actor-only elsewhere.
|
||||
* * `requireAdmin`, `requireCookieAuth` and `requireFreshMfa` resolve through
|
||||
* `getSession()` and are structurally unreachable by a switch.
|
||||
*/
|
||||
const UNFENCED_HELPERS = [
|
||||
"requireAuth",
|
||||
"requireActorAuth",
|
||||
"requireAdmin",
|
||||
"requireCookieAuth",
|
||||
"requireFreshMfa",
|
||||
] as const;
|
||||
|
||||
describe("the fence runs in exactly two auth helpers", () => {
|
||||
it("is called by both record resolvers and by nothing else in api-handler", () => {
|
||||
const calls = [...API_HANDLER.matchAll(/assertRecordSessionFence\s*\(/g)];
|
||||
// Non-zero proof: an emptied file would agree with an emptied expectation.
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
const callers = FENCED_RESOLVERS.filter((name) =>
|
||||
functionBody(API_HANDLER, name).includes("assertRecordSessionFence("),
|
||||
);
|
||||
expect(callers).toEqual([...FENCED_RESOLVERS]);
|
||||
// Two call sites, one import. Anything else means a third caller appeared.
|
||||
expect(calls.length).toBe(FENCED_RESOLVERS.length);
|
||||
});
|
||||
|
||||
it.each(FENCED_RESOLVERS)(
|
||||
"%s fences after authentication and before the carrier and the grant",
|
||||
(name) => {
|
||||
const body = functionBody(API_HANDLER, name);
|
||||
expect(body.length).toBeGreaterThan(100);
|
||||
|
||||
const fence = body.indexOf("assertRecordSessionFence(");
|
||||
const authenticate = body.indexOf("authenticateCaller(");
|
||||
const carrier = body.indexOf("readActingCarrier(");
|
||||
expect(fence).toBeGreaterThan(-1);
|
||||
expect(authenticate).toBeGreaterThan(-1);
|
||||
expect(carrier).toBeGreaterThan(-1);
|
||||
|
||||
// `authenticateCaller` NECESSARILY precedes the fence: it supplies the
|
||||
// session row the fence reads, and the whole atomicity argument rests on
|
||||
// both facts coming off that one read. So the invariant is "after
|
||||
// authentication, before the carrier" — NOT "before the first database
|
||||
// await", which would be false and must not be asserted here.
|
||||
expect(fence).toBeGreaterThan(authenticate);
|
||||
expect(fence).toBeLessThan(carrier);
|
||||
},
|
||||
);
|
||||
|
||||
it("fences before any grant lookup in the shared resolver path", () => {
|
||||
// `findActiveGrant` lives in `resolveSwitchedRecord`, which both resolvers
|
||||
// tail-call. Assert it appears nowhere ABOVE the fence in either body.
|
||||
const grantCalls = [...API_HANDLER.matchAll(/findActiveGrant\s*\(/g)];
|
||||
expect(grantCalls.length).toBeGreaterThan(0);
|
||||
|
||||
for (const name of FENCED_RESOLVERS) {
|
||||
const body = functionBody(API_HANDLER, name);
|
||||
const fence = body.indexOf("assertRecordSessionFence(");
|
||||
const grant = body.indexOf("findActiveGrant(");
|
||||
// Either the body never names it (it is in the shared tail) or it names
|
||||
// it after the fence. Both are the property; neither may invert.
|
||||
if (grant !== -1) expect(fence).toBeLessThan(grant);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(UNFENCED_HELPERS)("%s deliberately does not fence", (name) => {
|
||||
const body = functionBody(API_HANDLER, name);
|
||||
expect(body.length).toBeGreaterThan(50);
|
||||
expect(body).not.toContain("assertRecordSessionFence");
|
||||
});
|
||||
|
||||
it("the two unfenced routes stay unfenced", () => {
|
||||
// `/api/auth/me` is the reconciliation bootstrap and must answer while the
|
||||
// client's context is unknown; the switch endpoint is the way out of a
|
||||
// context that cannot be proved. Fencing either would make an unprovable
|
||||
// context unrecoverable.
|
||||
const me = read("app/api/auth/me/route.ts");
|
||||
const sw = read("app/api/account/switch/route.ts");
|
||||
expect(me).toContain("requireActorAuth");
|
||||
expect(sw).toContain("requireActorAuth");
|
||||
expect(me).not.toContain("assertRecordSessionFence");
|
||||
expect(sw).not.toContain("assertRecordSessionFence");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Both lists below grow as the feature lands, and that is the point rather than
|
||||
* a defect: the idempotency arm and the browser's adopted-context module each
|
||||
* arrive as a visible diff here rather than as a quiet new reader. Same posture
|
||||
* as `CARRIER_ALLOWLIST` in `acting-account-boundary-guard.test.ts`.
|
||||
*/
|
||||
describe("the epoch has one writer and a closed reader set", () => {
|
||||
it("is written in exactly one file", () => {
|
||||
// The SQL spelling only. `acting-session.ts` owns the conditional write
|
||||
// because it needs `RETURNING`, which no model delegate expresses — and
|
||||
// keeping the statement there is what keeps this list at one.
|
||||
const writers = filesMatching(/record_epoch/);
|
||||
expect(writers.length).toBeGreaterThan(0);
|
||||
expect(writers).toEqual(["lib/sharing/acting-session.ts"]);
|
||||
});
|
||||
|
||||
it("is read by a closed list and nothing else", () => {
|
||||
// The identifier, bounded so `recordEpochParameter` in the OpenAPI module
|
||||
// does not count — that file publishes a header name, it does not read a
|
||||
// column.
|
||||
const readers = filesMatching(/\brecordEpoch(?![\w$])/);
|
||||
expect(readers.length).toBeGreaterThan(0);
|
||||
expect(readers).toEqual(
|
||||
[
|
||||
// Projects it off the session row it already loaded.
|
||||
"lib/auth/session.ts",
|
||||
// Carries it on the auth context the fence is handed.
|
||||
"lib/api-handler.ts",
|
||||
// Compares it. The one place that decides anything from it.
|
||||
"lib/sharing/record-session-fence.ts",
|
||||
// Reads it off the same session lookup the idempotency wrapper already
|
||||
// makes, so the fence can be evaluated above the replay cache without a
|
||||
// second round trip.
|
||||
"lib/auth/acting-carrier.ts",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is consulted by the idempotency wrapper, above the cache", () => {
|
||||
const src = read("lib/idempotency.ts");
|
||||
const fence = src.indexOf("refuseStaleRecordSessionClaim(claim)");
|
||||
const cache = src.indexOf("findCached(ctx)");
|
||||
const claimRow = src.indexOf("claimKey(ctx)");
|
||||
expect(fence).toBeGreaterThan(-1);
|
||||
expect(cache).toBeGreaterThan(-1);
|
||||
expect(claimRow).toBeGreaterThan(-1);
|
||||
// A stale request must not learn whether a cell exists, replay a body out
|
||||
// of one, or insert a claim row. The runtime proof is the zero-call
|
||||
// instrument in `src/lib/__tests__/idempotency.test.ts`; this is the
|
||||
// tripwire that says the line moved.
|
||||
expect(fence).toBeLessThan(cache);
|
||||
expect(fence).toBeLessThan(claimRow);
|
||||
// And it comes from the fence module, not from a second copy of the rule.
|
||||
expect(src).toContain('from "@/lib/sharing/record-session-fence"');
|
||||
});
|
||||
|
||||
it("does not leak the raw counter into the browser's vocabulary", () => {
|
||||
// The client never names the epoch as a column. It receives
|
||||
// `recordSession: { epoch, scope }` and asserts it back as a header, which
|
||||
// is why no file under `hooks/` or `components/` appears in the list above.
|
||||
// A client that started naming `recordEpoch` would be reaching past the
|
||||
// published shape into the row.
|
||||
const clientReaders = filesMatching(/\brecordEpoch(?![\w$])/).filter(
|
||||
(rel) =>
|
||||
rel.startsWith("hooks/") ||
|
||||
rel.startsWith("components/") ||
|
||||
rel.startsWith("app/"),
|
||||
);
|
||||
expect(clientReaders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the header names live in a closed set of files", () => {
|
||||
it("is declared, read and published — and nowhere else", () => {
|
||||
// Mirrors the `x-healthlog-account` leg in
|
||||
// `acting-account-boundary-guard.test.ts`. A route that started reading
|
||||
// these headers itself would be making an admission decision the fence
|
||||
// exists to make once.
|
||||
const namers = filesMatching(
|
||||
/RECORD_EPOCH_HEADER|RECORD_SCOPE_HEADER|x-healthlog-record-(epoch|scope)/i,
|
||||
);
|
||||
expect(namers.length).toBeGreaterThan(0);
|
||||
expect(namers).toEqual([
|
||||
// Attaches them to every same-origin request, and validates the echo.
|
||||
"lib/api/record-fence.ts",
|
||||
// Publishes them to the clients that have to send them.
|
||||
"lib/openapi/routes/account-sharing.ts",
|
||||
// Declares them.
|
||||
"lib/sharing/record-session-fence-contract.ts",
|
||||
// Reads them off the request scope, and writes the response echo.
|
||||
// `apiHandler` attaches the echo through this module's helper rather than
|
||||
// naming the headers itself, which is what keeps this list at four.
|
||||
"lib/sharing/record-session-fence.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the 409's code in the contract module and the client that branches on it", () => {
|
||||
const namers = filesMatching(/sharing\.session\.changed/);
|
||||
expect(namers.length).toBeGreaterThan(0);
|
||||
// The error class reads the constant rather than restating the string, so
|
||||
// `api-handler.ts` is deliberately NOT on this list.
|
||||
expect(namers).toContain("lib/sharing/record-session-fence-contract.ts");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
managedModulePreferencesFrom,
|
||||
parseManagedRecordSettingsPatch,
|
||||
} from "@/lib/record-settings/configuration";
|
||||
import { WRITABLE_MODULE_KEYS } from "@/lib/validations/modules";
|
||||
|
||||
describe("managed record settings configuration contract", () => {
|
||||
it("enumerates the only target-record fields each settings DTO may write", () => {
|
||||
expect(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST).toEqual({
|
||||
profile: [
|
||||
"displayName",
|
||||
"heightCm",
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
"locale",
|
||||
"timezone",
|
||||
"unitPreference",
|
||||
"timeFormat",
|
||||
"dateFormat",
|
||||
],
|
||||
modules: ["modulePreferences"],
|
||||
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
||||
thresholds: ["overrides"],
|
||||
coach: ["disableCoach", "preferences"],
|
||||
insights: ["layout"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["profile", { email: "not-allowed@example.test" }],
|
||||
["modules", { role: "ADMIN" }],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
notificationPreferences: { medication: { deliveryDefault: "client" } },
|
||||
},
|
||||
],
|
||||
["thresholds", { glucoseUnit: "imperial" }],
|
||||
["coach", { memories: [] }],
|
||||
["insights", { provider: "external" }],
|
||||
] as const)("rejects a disallowed %s field", (family, patch) => {
|
||||
expect(() => parseManagedRecordSettingsPatch(family, patch)).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"profile",
|
||||
{
|
||||
displayName: "Managed profile",
|
||||
heightCm: 120,
|
||||
dateOfBirth: "2016-04-03",
|
||||
gender: "OTHER",
|
||||
locale: "en",
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: "metric",
|
||||
timeFormat: "H24",
|
||||
dateFormat: "DMY",
|
||||
},
|
||||
],
|
||||
["modules", { modulePreferences: { mood: false } }],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
moodReminderEnabled: true,
|
||||
notificationPreferences: {
|
||||
medication: { lowStockRunwayDays: 7, reorderLeadDays: 10 },
|
||||
mood: { reminderHour: 22 },
|
||||
},
|
||||
},
|
||||
],
|
||||
["thresholds", { overrides: { WEIGHT: { min: 55, max: 80 } } }],
|
||||
[
|
||||
"coach",
|
||||
{
|
||||
disableCoach: false,
|
||||
preferences: {
|
||||
tone: "warm",
|
||||
verbosity: "default",
|
||||
excludeMetrics: [],
|
||||
showEvidenceByDefault: false,
|
||||
defaultWindow: "allTime",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"insights",
|
||||
{
|
||||
layout: {
|
||||
version: 2,
|
||||
tiles: [{ id: "weight", visible: true, order: 0 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)("accepts a typed %s patch", (family, patch) => {
|
||||
expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses the complete canonical module inventory for a fresh record", () => {
|
||||
expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual(
|
||||
WRITABLE_MODULE_KEYS,
|
||||
);
|
||||
expect(managedModulePreferencesFrom(null)).toEqual(
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts only the canonical profile gender enum", () => {
|
||||
expect(() =>
|
||||
parseManagedRecordSettingsPatch("profile", { gender: "MALE" }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
parseManagedRecordSettingsPatch("profile", { gender: "UNKNOWN" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
|
||||
"rejects inherited family name %s",
|
||||
(family) => {
|
||||
expect(isManagedRecordSettingsFamily(family)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertRecordSettingsAccess,
|
||||
type RecordSettingsAccess,
|
||||
} from "@/lib/record-settings/access";
|
||||
import {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
isManageDelegateSettingsDestination,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { RECORD_CONTENT_SECTIONS } from "@/components/settings/record-settings-section-gate";
|
||||
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
|
||||
import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations";
|
||||
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
|
||||
import { recordSettingsKeys } from "@/lib/query-keys";
|
||||
|
||||
describe("record settings contract", () => {
|
||||
it("names the target record in both the DTO and cache key", () => {
|
||||
const dto = toRecordSettingsDto({
|
||||
id: "record-1",
|
||||
name: "Managed profile",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin",
|
||||
recordKind: "managed",
|
||||
});
|
||||
|
||||
expect(dto).toEqual({
|
||||
record: {
|
||||
id: "record-1",
|
||||
displayName: "Managed profile",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin",
|
||||
kind: "managed",
|
||||
},
|
||||
});
|
||||
expect(recordSettingsKeys.detail("record-1")).toEqual([
|
||||
"record-settings",
|
||||
"record-1",
|
||||
"detail",
|
||||
]);
|
||||
expect(recordSettingsKeys.recordSettingsIntegrations("record-1")).toEqual([
|
||||
"record-settings",
|
||||
"record-1",
|
||||
"integrations",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let an adult MANAGE grant use Guardian-only configuration", () => {
|
||||
const access: RecordSettingsAccess = {
|
||||
actorId: "adult-manager",
|
||||
recordId: "managed-record",
|
||||
recordKind: "managed",
|
||||
relationship: "adult-manager",
|
||||
};
|
||||
|
||||
expect(() => assertRecordSettingsAccess(access, "guardian")).toThrow(
|
||||
"Guardian configuration is unavailable for this record",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies every Settings destination and limits Guardian writes", () => {
|
||||
expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0);
|
||||
expect(classifySettingsDestination("integrations")).toMatchObject({
|
||||
kind: "managed-guardian",
|
||||
guardianWritable: false,
|
||||
});
|
||||
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
|
||||
expect(classifySettingsDestination("dashboard").kind).toBe(
|
||||
"adult-shared-unavailable",
|
||||
);
|
||||
expect(isGuardianSettingsWriteAllowed("account")).toBe(true);
|
||||
expect(isGuardianSettingsWriteAllowed("notifications")).toBe(true);
|
||||
expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false);
|
||||
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the anamnesis destination, which is the one piece of record
|
||||
* CONTENT under `/settings`.
|
||||
*
|
||||
* `POST /api/allergies` and `POST /api/family-history` have resolved
|
||||
* `requireRecordAuth("manage", "profile")` since MANAGE shipped, while the
|
||||
* only form that posts to either was classified unavailable — an admitted
|
||||
* write with no reachable caller. These three legs are the classification
|
||||
* half of closing that; the integration file drives the routes themselves.
|
||||
*/
|
||||
it("opens the anamnesis destination to everybody the routes already admit", () => {
|
||||
expect(classifySettingsDestination("anamnesis")).toEqual({
|
||||
kind: "manage-writable",
|
||||
guardianWritable: true,
|
||||
});
|
||||
// The Guardian half. Before this the destination existed and was
|
||||
// read-only for the one person the profile exists for.
|
||||
expect(isGuardianSettingsWriteAllowed("anamnesis")).toBe(true);
|
||||
// The adult-delegate half, and its boundary in the same breath: a MANAGE
|
||||
// grant opens the record's health background and nothing else under
|
||||
// Settings. Record CONFIGURATION — modules, thresholds, notification
|
||||
// routing — stays with the owner even though a Guardian may change it.
|
||||
expect(isManageDelegateSettingsDestination("anamnesis")).toBe(true);
|
||||
for (const guardianOnly of [
|
||||
"account",
|
||||
"modules",
|
||||
"notifications",
|
||||
"thresholds",
|
||||
"insights",
|
||||
"coach",
|
||||
"integrations",
|
||||
]) {
|
||||
expect(
|
||||
isManageDelegateSettingsDestination(guardianOnly),
|
||||
guardianOnly,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every record-content destination a page of its own", () => {
|
||||
// Both directions, and the first is the one that matters. `manage-writable`
|
||||
// is a classification; the gate has to know which PAGE each slug is, or a
|
||||
// destination added to the kind would silently inherit the allergy and
|
||||
// family-history managers under its own heading. The map is what says
|
||||
// which, and this is what stops the two drifting apart — a new
|
||||
// manage-writable slug fails here, at the moment it is added, rather than
|
||||
// rendering somebody else's forms.
|
||||
const classified = Object.keys(SETTINGS_DESTINATION_INVENTORY)
|
||||
.filter(isManageDelegateSettingsDestination)
|
||||
.sort();
|
||||
const rendered = Object.keys(RECORD_CONTENT_SECTIONS).sort();
|
||||
|
||||
expect(classified).not.toHaveLength(0);
|
||||
expect(rendered).toEqual(classified);
|
||||
});
|
||||
|
||||
it("leaves exactly one destination on the manage-writable list", () => {
|
||||
// The list is short on purpose and a second entry is a consent decision,
|
||||
// not a classification tidy-up. Pinning the count means a destination
|
||||
// cannot join it as a side effect of somebody reclassifying a neighbour.
|
||||
const manageWritable = Object.entries(SETTINGS_DESTINATION_INVENTORY)
|
||||
.filter(([, value]) => value.kind === "manage-writable")
|
||||
.map(([slug]) => slug);
|
||||
expect(manageWritable).toEqual(["anamnesis"]);
|
||||
});
|
||||
|
||||
it("does not report a synthetic ledger row as a managed connection", () => {
|
||||
const connected = {
|
||||
withings: false,
|
||||
whoop: false,
|
||||
fitbit: false,
|
||||
nightscout: false,
|
||||
polar: false,
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
resolveManagedIntegrationState("connected", connected, "withings"),
|
||||
).toBe("disconnected");
|
||||
expect(
|
||||
resolveManagedIntegrationState(
|
||||
"error_reauth",
|
||||
{ ...connected, oura: true },
|
||||
"oura",
|
||||
),
|
||||
).toBe("error_reauth");
|
||||
});
|
||||
|
||||
it("rejects a late response for another record in a second tab", () => {
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
{ recordId: "managed-record-b" },
|
||||
"managed-record-a",
|
||||
),
|
||||
).toThrow("did not match its key");
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
{ recordId: "managed-record-a" },
|
||||
"managed-record-a",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST } from "@/lib/record-settings";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
const RECORD_SETTINGS_ROUTE_ROOT = fromRoot("src/app/api/record-settings");
|
||||
|
||||
describe("record settings route guard", () => {
|
||||
it("freezes the non-empty guardian-only route inventory", async () => {
|
||||
const discovered = (
|
||||
await readdir(RECORD_SETTINGS_ROUTE_ROOT, {
|
||||
recursive: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.endsWith("route.ts"))
|
||||
.map((entry) => `app/api/record-settings/${entry}`)
|
||||
.sort();
|
||||
|
||||
expect(discovered).toHaveLength(3);
|
||||
expect(discovered).toEqual([
|
||||
"app/api/record-settings/[family]/route.ts",
|
||||
"app/api/record-settings/integrations/route.ts",
|
||||
"app/api/record-settings/route.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves every record-settings route through the Guardian fence", async () => {
|
||||
const routes = [
|
||||
"route.ts",
|
||||
"integrations/route.ts",
|
||||
"[family]/route.ts",
|
||||
] as const;
|
||||
const sources = await Promise.all(
|
||||
routes.map((route) =>
|
||||
readFile(resolve(RECORD_SETTINGS_ROUTE_ROOT, route), "utf8"),
|
||||
),
|
||||
);
|
||||
|
||||
for (const source of sources) {
|
||||
expect(source).toContain("requireGuardianAuth");
|
||||
expect(source).toContain("resolveGuardianRecordSettingsAccess");
|
||||
}
|
||||
});
|
||||
|
||||
it("makes the field-specific route exhaustive over the approved DTO families", async () => {
|
||||
const source = await readFile(
|
||||
resolve(RECORD_SETTINGS_ROUTE_ROOT, "[family]/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const families = Object.keys(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST);
|
||||
expect(families.length).toBeGreaterThan(0);
|
||||
for (const family of families) {
|
||||
expect(source).toContain(`case \"${family}\"`);
|
||||
}
|
||||
expect(source).toContain("if (!isManagedRecordSettingsFamily(rawFamily))");
|
||||
expect(source).toContain('apiError("Settings family not found", 404)');
|
||||
expect(
|
||||
source.indexOf("isManagedRecordSettingsFamily(rawFamily)"),
|
||||
).toBeLessThan(source.indexOf("const context = await requireGuardianAuth"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
|
||||
if (
|
||||
expected === null ||
|
||||
typeof expected !== "object" ||
|
||||
actual === null ||
|
||||
typeof actual !== "object"
|
||||
) {
|
||||
expect(typeof actual).toBe(typeof expected);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedRecord = expected as Record<string, unknown>;
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
expect(Object.keys(actualRecord).sort()).toEqual(
|
||||
Object.keys(expectedRecord).sort(),
|
||||
);
|
||||
for (const key of Object.keys(expectedRecord)) {
|
||||
expectMatchingMessageKeys(expectedRecord[key], actualRecord[key]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("managed record settings UI", () => {
|
||||
it("gates shared deep links and renders integration status without controls", async () => {
|
||||
const [
|
||||
statusView,
|
||||
managedSettingsView,
|
||||
settingsPage,
|
||||
layoutModulePage,
|
||||
sectionGate,
|
||||
...localeBundles
|
||||
] = await Promise.all([
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-integration-status.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-record-settings-section.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
|
||||
readFile(fromRoot("src/app/settings/layout/[module]/page.tsx"), "utf8"),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
|
||||
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(statusView).toContain("recordSettingsIntegrations");
|
||||
expect(statusView).toContain("/api/record-settings/integrations");
|
||||
expect(statusView).toContain("assertRecordSettingsResponseForRecord");
|
||||
expect(statusView).not.toContain("<Button");
|
||||
expect(statusView).not.toContain("<Link");
|
||||
expect(settingsPage).toContain("RecordSettingsSectionGate");
|
||||
expect(sectionGate).toContain("if (!inSharedRecord) return children");
|
||||
expect(sectionGate).toContain("ManagedIntegrationStatus");
|
||||
expect(sectionGate).toContain("ManagedRecordSettingsSection");
|
||||
expect(sectionGate).toContain("if (isLoading)");
|
||||
expect(layoutModulePage).toContain(
|
||||
'RecordSettingsSectionGate section="layout"',
|
||||
);
|
||||
expect(managedSettingsView).toContain(
|
||||
"safeParseManagedRecordSettingsPatch",
|
||||
);
|
||||
expect(managedSettingsView).toContain("/api/record-settings/${family}");
|
||||
expect(managedSettingsView).not.toContain("/api/auth/");
|
||||
expect(managedSettingsView).not.toContain("/api/user/");
|
||||
expect(managedSettingsView).not.toContain("<textarea");
|
||||
expect(managedSettingsView).not.toContain("Display name");
|
||||
expect(managedSettingsView).not.toContain("Enable mood reminder");
|
||||
const referenceMessages = JSON.parse(localeBundles[0]).settings.sharedRecord
|
||||
.managedSettings;
|
||||
for (const bundle of localeBundles) {
|
||||
const sharedRecord = JSON.parse(bundle).settings.sharedRecord;
|
||||
expect(sharedRecord).toMatchObject({
|
||||
unavailableTitle: expect.any(String),
|
||||
integrationStatusDescription: expect.any(String),
|
||||
managedSettings: {
|
||||
profile: {
|
||||
title: expect.any(String),
|
||||
displayName: expect.any(String),
|
||||
},
|
||||
modules: { title: expect.any(String) },
|
||||
notifications: { title: expect.any(String) },
|
||||
thresholds: { title: expect.any(String) },
|
||||
coach: { title: expect.any(String) },
|
||||
insights: { title: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expectMatchingMessageKeys(
|
||||
referenceMessages,
|
||||
sharedRecord.managedSettings,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES,
|
||||
resolveSharedRecordNavigation,
|
||||
} from "@/lib/navigation/shared-record";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import {
|
||||
isDestinationInSharedRecord,
|
||||
visibleNavDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
|
||||
|
||||
describe("shared-record navigation", () => {
|
||||
it("keeps every granted route family in one closed eight-domain inventory", () => {
|
||||
expect(Object.keys(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES)).toEqual(
|
||||
SHARE_DOMAINS,
|
||||
);
|
||||
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(
|
||||
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].length,
|
||||
`${domain} needs at least one route family`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a scoped grant only its matching doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["labs"]);
|
||||
|
||||
expect(navigation.destinationHrefs).toEqual(["/labs"]);
|
||||
expect(navigation.allowsPath("/labs/panel/7")).toBe(true);
|
||||
expect(navigation.allowsPath("/medications")).toBe(false);
|
||||
|
||||
expect(
|
||||
visibleNavDestinations(undefined, true, true, ["labs"]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/labs"]);
|
||||
expect(isDestinationInSharedRecord("/labs/panel/7", ["labs"])).toBe(true);
|
||||
expect(isDestinationInSharedRecord("/medications", ["labs"])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["profile"]);
|
||||
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile"]);
|
||||
expect(navigation.allowsPath("/profile")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
expect(
|
||||
visibleNavDestinations(actorModules, true, true, ["medications"]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/medications"]);
|
||||
});
|
||||
|
||||
it("keeps whole-record navigation distinct from an all-sections scope", () => {
|
||||
const wholeRecord = resolveSharedRecordNavigation(null);
|
||||
const allSections = resolveSharedRecordNavigation([...SHARE_DOMAINS]);
|
||||
|
||||
expect(wholeRecord.allowsPath("/")).toBe(true);
|
||||
expect(allSections.allowsPath("/")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses unclassified direct URLs in presentation before pages start reads", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["documents"]);
|
||||
|
||||
expect(navigation.allowsPath("/documents/record-1")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/account")).toBe(false);
|
||||
expect(navigation.allowsPath("/new-unclassified-route")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps server-resolved access and record kind distinct in chrome", () => {
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "read",
|
||||
canWrite: false,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "view", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "view-and-add", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "manage", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
recordKind: "managed",
|
||||
}),
|
||||
).toEqual({ access: "manage", recordKind: "managed" });
|
||||
});
|
||||
|
||||
it("fails closed when a malformed entry reaches presentation", () => {
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: false,
|
||||
recordKind: "managed",
|
||||
} as never),
|
||||
).toEqual({ access: "view", recordKind: "self" });
|
||||
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "unknown",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
} as never),
|
||||
).toEqual({ access: "view", recordKind: "self" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ACCESSIBILITY_STATES,
|
||||
SHARING_ACCESSIBILITY_STATES,
|
||||
V137_E2E_JOURNEYS,
|
||||
} from "../../tests/fixtures/v137/e2e-journeys";
|
||||
|
||||
/**
|
||||
* All five states are claimed by a browser test that really examines them.
|
||||
*
|
||||
* ## Why a fast test guards a browser suite
|
||||
*
|
||||
* A Playwright run reports what it ran, never what it did not. "The
|
||||
* accessibility suite is green" and "the accessibility suite covers the error
|
||||
* state" are different sentences, and only the first one is ever checked —
|
||||
* a state nobody wrote a test for produces exactly the same output as a state
|
||||
* that passes. Before this file the suite had four tests for five states, with
|
||||
* success and empty sharing one and neither of them actually entering an empty
|
||||
* one.
|
||||
*
|
||||
* So the states are enumerated in `tests/fixtures/v137/e2e-journeys.ts` and
|
||||
* this file reads the spec back against the list. It cannot tell whether the
|
||||
* assertions inside a test are any good. It can tell that each state has a
|
||||
* test, that the test enters the state (there is an anchor, and the anchor is
|
||||
* a slot the component tree really renders), and that the test performs each
|
||||
* proof the state's own record says it needs — scan, reader order, and either
|
||||
* a keyboard reach or an announcement.
|
||||
*
|
||||
* ## The trap this file is written around
|
||||
*
|
||||
* Every assertion here is over a MATCHED set, and an empty match set satisfies
|
||||
* most of them silently: a spec path that no longer exists reads as a spec
|
||||
* with no offending tests. Each leg therefore pins the size of what it
|
||||
* matched — five states, five titles, eight journeys, a non-zero count of
|
||||
* helper calls — and the parse is proved against a synthetic spec at the
|
||||
* bottom, so the day the extractor stops seeing `test("…")` is the day this
|
||||
* file fails rather than the day it goes quiet.
|
||||
*/
|
||||
const ROOT = process.cwd();
|
||||
const A11Y_SPEC = join(ROOT, "e2e/v137-sharing-managed-profiles-a11y.spec.ts");
|
||||
|
||||
/** The helper each state's test has to call, by what the state offers. */
|
||||
const SCAN = "scanForViolations";
|
||||
const READER_ORDER = "expectReaderOrder";
|
||||
const KEYBOARD = "expectKeyboardReach";
|
||||
const ANNOUNCED = "expectAnnounced";
|
||||
const CONTRAST = "expectContentContrast";
|
||||
|
||||
/**
|
||||
* Split a spec into `title -> body`.
|
||||
*
|
||||
* Bodies are delimited by the next opener rather than by brace matching: a
|
||||
* body containing a string with a brace in it is common, a body containing the
|
||||
* literal `test(` is not. Exported and exercised against a synthetic spec
|
||||
* below, because a splitter that returned an empty map would make every leg
|
||||
* here pass by matching nothing.
|
||||
*
|
||||
* `it(` counts as an opener alongside `test(`. Both are the same call, and the
|
||||
* repository splits by suite rather than by preference: the browser specs
|
||||
* write `test(`, the Vitest files write `it(`. Five of the eight release
|
||||
* journeys are proved by Vitest files, so a parser that saw only `test(` would
|
||||
* read those files as having no tests at all — and every per-file leg below is
|
||||
* over a matched set, so it would have passed on the empty one.
|
||||
*
|
||||
* ## What it does not see, said plainly
|
||||
*
|
||||
* A title written as a template literal, because the extractor takes only
|
||||
* double-quoted strings — several parameterised cases in the sharing suites are
|
||||
* written that way, and a marker placed in one would be invisible here.
|
||||
* And a parameterised opener whose argument list contains its own `)`
|
||||
* (`it.each([{ a: f() }])`), because the table is skipped by a non-nesting
|
||||
* match. Both are limits of the matcher rather than statements about the
|
||||
* suites, so a journey is wired to a plainly-quoted title on purpose.
|
||||
*/
|
||||
export function testBodies(source: string): Map<string, string> {
|
||||
const bodies = new Map<string, string>();
|
||||
// `test.only(` and `test.skip(` are tests; `test.describe(` is not, and the
|
||||
// synthetic spec below is what caught the first version treating it as one.
|
||||
// `\b` keeps `it` from matching inside a word — `submit(`, `exit(`, `await(`
|
||||
// — because neither side of the pair is a boundary there.
|
||||
//
|
||||
// Two shapes, because `it.each(table)("title")` puts the table between the
|
||||
// opener and the title and the single-call form does not. The synthetic
|
||||
// Vitest spec below is what caught the first version seeing only the second
|
||||
// shape and reading a parameterised case as no case at all.
|
||||
const opener =
|
||||
/\b(?:test|it)(?:\.(?!describe)\w+)*\s*(?:\(\s*"([^"]+)"|\([^)]*\)\s*\(\s*"([^"]+)")/g;
|
||||
const starts: Array<{ title: string; from: number }> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = opener.exec(source)) !== null) {
|
||||
starts.push({ title: match[1] ?? match[2], from: match.index });
|
||||
}
|
||||
for (let i = 0; i < starts.length; i += 1) {
|
||||
const to = i + 1 < starts.length ? starts[i + 1].from : source.length;
|
||||
bodies.set(starts[i].title, source.slice(starts[i].from, to));
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
describe("the state parser sees what it looks for", () => {
|
||||
const SYNTHETIC = `
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
test.describe("a group", () => {
|
||||
test("first — success", async ({ page }) => {
|
||||
await scanForViolations(page);
|
||||
});
|
||||
|
||||
test("second — refusal", async ({ page }) => {
|
||||
await expectKeyboardReach(page, '[data-slot="x"]');
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
it("splits a spec into its test bodies", () => {
|
||||
const bodies = testBodies(SYNTHETIC);
|
||||
expect([...bodies.keys()]).toEqual(["first — success", "second — refusal"]);
|
||||
expect(bodies.get("first — success")).toContain(SCAN);
|
||||
// And no body bleeds into the next one, which is what would make a single
|
||||
// well-instrumented test satisfy the checks for every state.
|
||||
expect(bodies.get("first — success")).not.toContain(KEYBOARD);
|
||||
});
|
||||
|
||||
it("does not treat a describe block as a test", () => {
|
||||
expect(testBodies(SYNTHETIC).has("a group")).toBe(false);
|
||||
});
|
||||
|
||||
const SYNTHETIC_VITEST = `
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("a suite", () => {
|
||||
it("answers the first question", async () => {
|
||||
const submitted = await submit({ a: 1 });
|
||||
expect(submitted).toBe(true);
|
||||
});
|
||||
|
||||
it.each([1, 2])("answers the parameterised one", async () => {
|
||||
expect(await exit()).toBe(0);
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
it("reads a Vitest file's it( openers too", () => {
|
||||
// Five of the eight journeys are proved by Vitest files. Without this the
|
||||
// wiring leg below would read every one of them as a file with no tests,
|
||||
// and pass on the empty set.
|
||||
const bodies = testBodies(SYNTHETIC_VITEST);
|
||||
expect([...bodies.keys()]).toEqual([
|
||||
"answers the first question",
|
||||
"answers the parameterised one",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not find a test inside submit( or exit(", () => {
|
||||
// The bodies above call both. If the word boundary were wrong, the map
|
||||
// would carry entries the file never declared, and the wiring leg could be
|
||||
// satisfied by a marker sitting in a helper call rather than in a title.
|
||||
expect(testBodies(SYNTHETIC_VITEST).size).toBe(2);
|
||||
expect(testBodies(SYNTHETIC_VITEST).has("a suite")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the five states are enumerated once each", () => {
|
||||
it("lists exactly the five, with no state named twice", () => {
|
||||
expect(ACCESSIBILITY_STATES).toHaveLength(5);
|
||||
expect(SHARING_ACCESSIBILITY_STATES).toHaveLength(5);
|
||||
expect(SHARING_ACCESSIBILITY_STATES.map((c) => c.state).sort()).toEqual(
|
||||
[...ACCESSIBILITY_STATES].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors every state on a slot the tree really renders", () => {
|
||||
// A state can otherwise be added to the list with an anchor nothing emits,
|
||||
// and the browser test would then wait for an element that cannot appear —
|
||||
// which fails, but fails as a timeout that reads like flake rather than
|
||||
// like a missing surface.
|
||||
for (const c of SHARING_ACCESSIBILITY_STATES) {
|
||||
const source = readFileSync(join(ROOT, c.rendersIn), "utf8");
|
||||
// The last `[data-slot="…"]` in a compound selector is the one the
|
||||
// module has to emit; the prefix is context from an ancestor.
|
||||
const slots = [...c.anchor.matchAll(/data-slot="([^"]+)"/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
expect(slots.length, c.anchor).toBeGreaterThan(0);
|
||||
expect(source, `${c.rendersIn} does not emit ${c.anchor}`).toContain(
|
||||
`data-slot="${slots[slots.length - 1]}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every state a way forward or says it has none", () => {
|
||||
// Exactly one state may be without a control, and it is the one nobody can
|
||||
// act in. A second `null` here would mean a dead end shipped as a state.
|
||||
const dead = SHARING_ACCESSIBILITY_STATES.filter(
|
||||
(c) => c.keyboardTarget === null,
|
||||
);
|
||||
expect(dead.map((c) => c.state)).toEqual(["loading"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every state is claimed by a browser test that examines it", () => {
|
||||
const spec = readFileSync(A11Y_SPEC, "utf8");
|
||||
const bodies = testBodies(spec);
|
||||
|
||||
it("parses a non-zero number of tests out of the spec", () => {
|
||||
// The pin the legs below rest on: an empty map satisfies "no state is
|
||||
// missing a helper call" perfectly.
|
||||
expect(bodies.size).toBeGreaterThanOrEqual(
|
||||
SHARING_ACCESSIBILITY_STATES.length,
|
||||
);
|
||||
});
|
||||
|
||||
for (const c of SHARING_ACCESSIBILITY_STATES) {
|
||||
describe(c.state, () => {
|
||||
const body = [...bodies.entries()].find(([title]) =>
|
||||
title.includes(`[${c.state}]`),
|
||||
)?.[1];
|
||||
|
||||
it("has a test that names the state", () => {
|
||||
expect(
|
||||
body,
|
||||
`no test title carries [${c.state}] in ${A11Y_SPEC}`,
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("enters the state before it asserts anything about it", () => {
|
||||
expect(body ?? "", c.anchor).toContain(c.anchor);
|
||||
});
|
||||
|
||||
it("scans it and proves the reading order", () => {
|
||||
expect(body ?? "").toContain(SCAN);
|
||||
expect(body ?? "").toContain(READER_ORDER);
|
||||
});
|
||||
|
||||
it(
|
||||
c.keyboardTarget === null
|
||||
? "announces itself, having nothing to press"
|
||||
: "puts its way forward within reach of a keyboard",
|
||||
() => {
|
||||
if (c.keyboardTarget === null) {
|
||||
expect(body ?? "").toContain(ANNOUNCED);
|
||||
expect(body ?? "").not.toContain(KEYBOARD);
|
||||
} else {
|
||||
expect(body ?? "").toContain(KEYBOARD);
|
||||
expect(body ?? "").toContain(c.keyboardTarget);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
c.contrastTarget === null
|
||||
? "has no visible copy to check"
|
||||
: "renders its material copy as content, not as muted meta",
|
||||
() => {
|
||||
if (c.contrastTarget === null) {
|
||||
expect(body ?? "").not.toContain(CONTRAST);
|
||||
} else {
|
||||
expect(body ?? "").toContain(CONTRAST);
|
||||
expect(body ?? "").toContain(c.contrastTarget);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("defines every helper it demands", () => {
|
||||
// Otherwise the checks above are satisfied by a call to a function that
|
||||
// does not exist, which typechecks nowhere this guard can see.
|
||||
for (const helper of [SCAN, READER_ORDER, KEYBOARD, ANNOUNCED, CONTRAST]) {
|
||||
expect(spec, helper).toMatch(
|
||||
new RegExp(`(async\\s+function|const)\\s+${helper}\\b`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the eight release journeys are enumerated", () => {
|
||||
it("names exactly eight, with distinct ids", () => {
|
||||
expect(V137_E2E_JOURNEYS).toHaveLength(8);
|
||||
expect(new Set(V137_E2E_JOURNEYS.map((j) => j.id)).size).toBe(8);
|
||||
for (const journey of V137_E2E_JOURNEYS) {
|
||||
expect(journey.claim.length, journey.id).toBeGreaterThan(40);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — every journey, and the files that carry it.
|
||||
*
|
||||
* The enumeration above says eight journeys exist. It says nothing about
|
||||
* whether any of them RAN, which is the same gap between "the suite is
|
||||
* green" and "the suite covers this" that the rest of this file exists to
|
||||
* close. All eight are wired now, so the list is no longer a growing subset:
|
||||
* a journey missing from it fails below rather than reading as one nobody
|
||||
* has got to yet.
|
||||
*
|
||||
* ## Why a journey names more than one file
|
||||
*
|
||||
* A journey is a claim, not a test. J1 says an adult grant runs "through
|
||||
* invite, accept, switch, expiry, revoke and direct URL" — the browser
|
||||
* proves the switch, what each level opens and the direct-URL refusal; the
|
||||
* lifecycle transitions either side of it are database facts and are proved
|
||||
* against real Postgres. Forcing that onto one file would mean either a
|
||||
* browser spec driving grant expiry through the clock, or an integration
|
||||
* file asserting what a page renders. Both would be worse tests than the two
|
||||
* that exist, so the wiring records both homes.
|
||||
*
|
||||
* ## Why the runner is written down
|
||||
*
|
||||
* Three journeys ride Playwright and run only in the acceptance stage; five
|
||||
* are Vitest and run in the ordinary gate. That split is the same one
|
||||
* `record-session-fence-acceptance-map.test.ts` records for the fence, and
|
||||
* it is here for the same reason: a green run of THIS file means "all eight
|
||||
* are claimed, and here is which ones have actually executed", never "all
|
||||
* eight passed". The runner is checked against the path rather than trusted,
|
||||
* so it cannot drift into a label that flatters the split.
|
||||
*
|
||||
* ## What this cannot do
|
||||
*
|
||||
* It proves a marker appears in a test title in a file that really has
|
||||
* tests. It cannot prove the test behind the marker asserts anything worth
|
||||
* asserting. Ten empty tests would satisfy it, which is why the journey
|
||||
* claims stay written out above in the release's own words: the marker says
|
||||
* where to look, and the claim says what to look for.
|
||||
*/
|
||||
const WIRED_JOURNEYS: ReadonlyArray<{
|
||||
id: string;
|
||||
proofs: ReadonlyArray<{ file: string; runner: "playwright" | "vitest" }>;
|
||||
}> = [
|
||||
{
|
||||
id: "J1-adult-levels-lifecycle",
|
||||
proofs: [
|
||||
{
|
||||
file: "e2e/v137-sharing-managed-profiles.spec.ts",
|
||||
runner: "playwright",
|
||||
},
|
||||
{
|
||||
file: "tests/integration/sharing-lifecycle.test.ts",
|
||||
runner: "vitest",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "J2-manage-edits-without-settings",
|
||||
proofs: [
|
||||
{
|
||||
file: "e2e/v137-sharing-managed-profiles.spec.ts",
|
||||
runner: "playwright",
|
||||
},
|
||||
{
|
||||
file: "tests/integration/sharing-manage-handler-matrix.test.ts",
|
||||
runner: "vitest",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "J3-managed-profile-lifecycle",
|
||||
proofs: [
|
||||
{
|
||||
file: "e2e/v137-sharing-managed-profiles.spec.ts",
|
||||
runner: "playwright",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "J4-guardian-notification-matrix",
|
||||
proofs: [
|
||||
{
|
||||
file: "tests/integration/guardian-notification-fanout.test.ts",
|
||||
runner: "vitest",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "J5-revoked-idempotent-replay",
|
||||
proofs: [
|
||||
{
|
||||
file: "tests/integration/sharing-idempotency-revocation.test.ts",
|
||||
runner: "vitest",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "J6-no-delegated-provider-egress",
|
||||
proofs: [
|
||||
{
|
||||
file: "tests/integration/sharing-provider-origin.test.ts",
|
||||
runner: "vitest",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "J7-durable-import-actor",
|
||||
proofs: [
|
||||
{
|
||||
file: "tests/integration/sharing-import-attribution.test.ts",
|
||||
runner: "vitest",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "J8-old-client-compatibility",
|
||||
proofs: [
|
||||
{
|
||||
file: "src/__tests__/sharing-legacy-client-contract.test.ts",
|
||||
runner: "vitest",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("wires every enumerated journey, and claims no other", () => {
|
||||
// Both directions. A wiring entry naming a journey nobody enumerated would
|
||||
// be a claim about nothing; an enumerated journey missing from the wiring
|
||||
// is the dropped one this whole file exists to surface, and before this
|
||||
// release the list was allowed to be a subset, so a drop and a not-yet
|
||||
// looked identical.
|
||||
const declared = V137_E2E_JOURNEYS.map((j) => j.id).sort();
|
||||
const wired = WIRED_JOURNEYS.map((j) => j.id).sort();
|
||||
expect(wired).toEqual(declared);
|
||||
expect(new Set(wired).size).toBe(wired.length);
|
||||
});
|
||||
|
||||
it("splits the runners honestly, and by the path rather than the label", () => {
|
||||
const all = WIRED_JOURNEYS.flatMap((j) => j.proofs);
|
||||
// Non-zero on both sides: a split with nothing on one of them would let
|
||||
// "which of these have actually executed in the gate" be answered by a
|
||||
// list that never distinguished anything.
|
||||
const browser = all.filter((p) => p.runner === "playwright");
|
||||
const gate = all.filter((p) => p.runner === "vitest");
|
||||
expect(browser.length).toBeGreaterThan(0);
|
||||
expect(gate.length).toBeGreaterThan(0);
|
||||
for (const proof of all) {
|
||||
// A Playwright spec lives under e2e/ and nothing else does. Deriving the
|
||||
// expected runner from the path is what stops the field from becoming a
|
||||
// label somebody set to whichever value made the count read better.
|
||||
const expected = proof.file.startsWith("e2e/") ? "playwright" : "vitest";
|
||||
expect(proof.runner, proof.file).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(
|
||||
WIRED_JOURNEYS.flatMap(({ id, proofs }) =>
|
||||
proofs.map((p) => ({ id, ...p })),
|
||||
),
|
||||
)("$id is carried by a test title in $file", ({ id, file }) => {
|
||||
const source = readFileSync(join(ROOT, file), "utf8");
|
||||
// Non-zero proof: the file really loaded and really has tests, so a path
|
||||
// that stopped existing fails here rather than matching nothing quietly.
|
||||
expect(source.length).toBeGreaterThan(1000);
|
||||
const titles = [...testBodies(source).keys()];
|
||||
expect(titles.length, `${file} parsed to no tests`).toBeGreaterThan(1);
|
||||
expect(
|
||||
titles.filter((title) => title.includes(`[${id}]`)).length,
|
||||
`no test title in ${file} carries [${id}]`,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
|
||||
import {
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
isManageDelegateSettingsDestination,
|
||||
} from "@/lib/record-settings/classification";
|
||||
|
||||
/**
|
||||
* What the product SAYS a manage grant cannot reach agrees with what it cannot
|
||||
* reach.
|
||||
*
|
||||
* Two sentences make that promise, and both were written when it was true:
|
||||
*
|
||||
* * the `access` field's description in the sharing request schema, which is
|
||||
* generated into `docs/api/openapi.yaml` and read by the native client —
|
||||
* it said MANAGE "never reaches … settings";
|
||||
* * `recordSharing.unavailable.description`, the paragraph a delegate meets
|
||||
* on a page sharing does not cover — it said settings "stay with the
|
||||
* person who owns the account".
|
||||
*
|
||||
* v1.37.0 opened one Settings destination to a manage grant, and both
|
||||
* sentences became false in the most expensive place a sentence can be false:
|
||||
* one is a consent screen, the other is a published contract. A promise that
|
||||
* has quietly stopped holding is worse than no promise, because somebody has
|
||||
* already decided based on it.
|
||||
*
|
||||
* So the copy is checked against the classification rather than against
|
||||
* itself. If a second destination is ever opened, or the anamnesis one is
|
||||
* closed again, the sentence that describes the boundary has to move with it.
|
||||
*
|
||||
* What this cannot check is whether the wording is GOOD. It checks that the
|
||||
* absolute claim is gone, that the boundary is named the way the code draws it
|
||||
* (record content versus account configuration), and that all six locales say
|
||||
* it — which is the part that rots silently.
|
||||
*/
|
||||
const ROOT = process.cwd();
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
/** The published description of the level, off the Zod schema itself. */
|
||||
function accessDescription(): string {
|
||||
const shape = (
|
||||
inviteGrantSchema as unknown as {
|
||||
_def: { shape: Record<string, { description?: string }> };
|
||||
}
|
||||
)._def.shape;
|
||||
return shape.access?.description ?? "";
|
||||
}
|
||||
|
||||
function description(locale: string): string {
|
||||
const bundle = JSON.parse(
|
||||
readFileSync(join(ROOT, `messages/${locale}.json`), "utf8"),
|
||||
) as {
|
||||
recordSharing: { unavailable: { description: string } };
|
||||
};
|
||||
return bundle.recordSharing.unavailable.description;
|
||||
}
|
||||
|
||||
describe("the published boundary matches the one the code draws", () => {
|
||||
const openDestinations = Object.keys(SETTINGS_DESTINATION_INVENTORY).filter(
|
||||
isManageDelegateSettingsDestination,
|
||||
);
|
||||
|
||||
it("has something to be wrong about", () => {
|
||||
// The pin every leg below rests on. With no manage-reachable destination
|
||||
// the old absolute sentence would have been true and this whole file would
|
||||
// be asserting a style preference.
|
||||
expect(openDestinations).not.toHaveLength(0);
|
||||
expect(openDestinations).toContain("anamnesis");
|
||||
expect(accessDescription().length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("does not claim a manage grant reaches no settings at all", () => {
|
||||
const published = accessDescription();
|
||||
// The exact clause that went stale, and the shape of any replacement for
|
||||
// it. `settings` may appear — it has to, to say which parts stay with the
|
||||
// owner — but not as part of a blanket denial.
|
||||
expect(published).not.toMatch(/never reaches[^.]*\bsettings\b/i);
|
||||
expect(published).not.toMatch(/no access to settings/i);
|
||||
});
|
||||
|
||||
it("names the boundary the classification actually draws", () => {
|
||||
// Not a synonym check for its own sake: "record content versus account
|
||||
// configuration" IS the rule `isManageDelegateSettingsDestination`
|
||||
// implements, and a description that described some other boundary would
|
||||
// be a second, disagreeing specification.
|
||||
expect(accessDescription()).toMatch(
|
||||
/record content versus account configuration/i,
|
||||
);
|
||||
// And the destination that was opened is named, so a reader of the
|
||||
// contract can tell which part of the record it means.
|
||||
expect(accessDescription()).toMatch(/allergies, family history/i);
|
||||
});
|
||||
|
||||
it("keeps the refusal paragraph honest in all six locales", () => {
|
||||
// The delegate-facing half. It is shown on a page sharing does NOT cover,
|
||||
// so a sentence that over-claims there is read at the exact moment
|
||||
// somebody is working out where the edge is.
|
||||
const missing: string[] = [];
|
||||
for (const locale of LOCALES) {
|
||||
const text = description(locale);
|
||||
expect(text.length, locale).toBeGreaterThan(40);
|
||||
// Every locale has to acknowledge the health background, because that
|
||||
// is the part the old sentence implicitly denied. Matched on the stem
|
||||
// rather than the English spelling, and short enough to survive the
|
||||
// accent: the section is "Anamnèse" in French and "Anamneza" in Polish,
|
||||
// so anything past "anamn" is already language-specific and a matcher
|
||||
// tuned to one of them reports the others as broken.
|
||||
if (!/anamn/i.test(text)) missing.push(locale);
|
||||
// And it must still say the account itself stays with its owner —
|
||||
// dropping that would fix one over-claim by removing the boundary.
|
||||
expect(text, locale).toMatch(/konto|account|compte|cuenta|konta|koncie/i);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* v1.37.0 — what a client makes of the account payload, old and new.
|
||||
*
|
||||
* The sharing block gained three fields this release (`level`, `sections`,
|
||||
* `recordKind`) and kept the two the v1.36.0 contract published (`access`,
|
||||
* `canWrite`). Two questions follow from that, and this file answers both
|
||||
* against the payload the SERVER builds rather than against a copy of it:
|
||||
*
|
||||
* 1. a client that predates the release still decodes a whole-record grant,
|
||||
* and fails closed on a scoped one rather than reading it as the whole
|
||||
* record;
|
||||
* 2. the canonical readers consume the new fields off both the entry and the
|
||||
* active record, without a shim in between.
|
||||
*
|
||||
* ## Why it drives `resolveAccountAccess` and not a literal
|
||||
*
|
||||
* `src/lib/sharing/__tests__/account-access.test.ts` already runs the legacy
|
||||
* decoder over the FIXTURE objects, which carry an `access` and a `sections`
|
||||
* key of their own. That proves the decoder and proves the fixture; it cannot
|
||||
* prove that a payload the server actually emits decodes, because no server
|
||||
* code runs between them. This file resolves each fixture case through the real
|
||||
* resolver and hands the decoder the entry that comes out — both ends and the
|
||||
* pipe. The fixture's canonical fields are asserted against that entry too, so
|
||||
* a fixture that drifted from the server would fail here rather than quietly
|
||||
* describe something the product no longer does.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { accountGrant: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
import {
|
||||
decodeLegacyWholeRecordAccess,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
LEGACY_ACCOUNT_PAYLOADS,
|
||||
type LegacyAccountPayloadFixture,
|
||||
} from "../../tests/fixtures/v137/legacy-account-payloads";
|
||||
|
||||
const OWNER = "record-owner";
|
||||
|
||||
/** The grant row behind one fixture case, as Prisma would return it. */
|
||||
function grantFor(fixture: LegacyAccountPayloadFixture) {
|
||||
return {
|
||||
id: `grant-${fixture.name}`,
|
||||
access:
|
||||
fixture.level === "manage"
|
||||
? "MANAGE"
|
||||
: fixture.level === "write"
|
||||
? "WRITE"
|
||||
: "READ",
|
||||
scopeJson: fixture.sections,
|
||||
acceptedAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
grantor: {
|
||||
id: OWNER,
|
||||
username: "record-owner",
|
||||
displayName: "Record owner",
|
||||
managedProfileAt:
|
||||
fixture.recordKind === "managed"
|
||||
? new Date("2026-08-01T00:00:00.000Z")
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The entry and the active record the server publishes for one case. */
|
||||
async function publishedFor(fixture: LegacyAccountPayloadFixture): Promise<{
|
||||
entry: AccountAccessEntry;
|
||||
active: AccountAccessEntry | null;
|
||||
}> {
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
grantFor(fixture),
|
||||
] as never);
|
||||
const access = await resolveAccountAccess({
|
||||
user: { id: "delegate" },
|
||||
session: { actingAsUserId: OWNER },
|
||||
});
|
||||
return { entry: access.accounts[0], active: access.active };
|
||||
}
|
||||
|
||||
/** read < write < manage, so "never more than" is a comparison and not prose. */
|
||||
const RANK = { read: 0, write: 1, manage: 2 } as const;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("what an older client makes of the v1.37.0 account payload", () => {
|
||||
it("[J8-old-client-compatibility] covers every fixture case, and the fixtures describe the server", async () => {
|
||||
// Non-zero discovery, and the assertion that keeps the fixture honest: it
|
||||
// is a description of what the resolver emits, not a parallel invention.
|
||||
expect(LEGACY_ACCOUNT_PAYLOADS.length).toBeGreaterThan(0);
|
||||
|
||||
for (const fixture of LEGACY_ACCOUNT_PAYLOADS) {
|
||||
const { entry } = await publishedFor(fixture);
|
||||
expect(entry, fixture.name).toEqual(
|
||||
expect.objectContaining({
|
||||
access: fixture.access,
|
||||
level: fixture.level,
|
||||
sections: fixture.sections,
|
||||
canWrite: fixture.canWrite,
|
||||
recordKind: fixture.recordKind,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(LEGACY_ACCOUNT_PAYLOADS)(
|
||||
"decodes $name the way the fixture says an old client would",
|
||||
async (fixture) => {
|
||||
const { entry } = await publishedFor(fixture);
|
||||
const decoded = decodeLegacyWholeRecordAccess(entry);
|
||||
|
||||
expect(decoded === null ? "deny" : "allow").toBe(fixture.legacyDecoder);
|
||||
if (fixture.legacyDecoder === "allow") {
|
||||
expect(decoded).toBe(fixture.access);
|
||||
// Never MORE than the grant confers. A MANAGE grant decodes as `write`
|
||||
// on an old client — under-reporting what it may do, which costs a
|
||||
// control and leaks nothing. The reverse would hand a client a level
|
||||
// the server will refuse.
|
||||
expect(RANK[decoded!]).toBeLessThanOrEqual(RANK[fixture.level]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("[J8-old-client-compatibility] refuses a scoped grant rather than reading it as the whole record", async () => {
|
||||
const scoped = LEGACY_ACCOUNT_PAYLOADS.find(
|
||||
(fixture) => fixture.sections !== null,
|
||||
);
|
||||
expect(scoped, "the fixture set must carry a scoped case").toBeDefined();
|
||||
|
||||
const { entry } = await publishedFor(scoped!);
|
||||
expect(entry.sections).not.toBeNull();
|
||||
expect(decodeLegacyWholeRecordAccess(entry)).toBeNull();
|
||||
|
||||
// And the reason is the field, not the level: the same grant with the
|
||||
// sections removed decodes, so a client on the old contract is refused for
|
||||
// carrying a narrowing it cannot express rather than for being at READ.
|
||||
expect(decodeLegacyWholeRecordAccess({ ...entry, sections: null })).toBe(
|
||||
entry.access,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the canonical readers make of the same payload", () => {
|
||||
it.each(LEGACY_ACCOUNT_PAYLOADS)(
|
||||
"carries level, sections and recordKind through the active record for $name",
|
||||
async (fixture) => {
|
||||
const { active } = await publishedFor(fixture);
|
||||
expect(active, "the session is switched into the record").not.toBeNull();
|
||||
|
||||
const capabilities = resolveRecordCapabilities(active);
|
||||
expect(capabilities.level).toBe(fixture.level);
|
||||
expect(capabilities.sections).toEqual(fixture.sections);
|
||||
expect(capabilities.recordKind).toBe(fixture.recordKind);
|
||||
expect(capabilities.inSharedRecord).toBe(true);
|
||||
// Resolved server-side and rendered, never re-derived: `canAdd` follows
|
||||
// `canWrite`, and MANAGE does not become `canManage` on the client — a
|
||||
// delegate's management reach is the server's answer, one request at a
|
||||
// time.
|
||||
expect(capabilities.canWrite).toBe(fixture.canWrite);
|
||||
expect(capabilities.canAdd).toBe(fixture.canWrite);
|
||||
expect(capabilities.canManage).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("[J8-old-client-compatibility] reads a scope the server could not parse as nothing, on both ends", async () => {
|
||||
// The fail-closed reading: a stored scope this build cannot make sense of
|
||||
// resolves to an EMPTY section list rather than to the whole record. The
|
||||
// server refuses every section for it, and the client has to agree — an
|
||||
// empty array rendered as "everything" would paint the entire product for
|
||||
// a grant that opens none of it.
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
{ ...grantFor(LEGACY_ACCOUNT_PAYLOADS[0]), scopeJson: ["not-a-section"] },
|
||||
] as never);
|
||||
|
||||
const access = await resolveAccountAccess({
|
||||
user: { id: "delegate" },
|
||||
session: { actingAsUserId: OWNER },
|
||||
});
|
||||
|
||||
expect(access.accounts[0].sections).toEqual([]);
|
||||
expect(resolveRecordCapabilities(access.active).sections).toEqual([]);
|
||||
// An old client is refused outright rather than being handed the whole
|
||||
// record, which is the same fail-closed direction.
|
||||
expect(decodeLegacyWholeRecordAccess(access.accounts[0])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* v1.37.0 — what the native client may do with the third level, frozen.
|
||||
*
|
||||
* The release adds MANAGE and managed profiles, and the authority split it
|
||||
* ships with is deliberately asymmetric:
|
||||
*
|
||||
* * a token EXERCISES a MANAGE grant exactly as a browser does, per request,
|
||||
* through the account selector;
|
||||
* * a token cannot MINT one, and cannot create a managed profile at all,
|
||||
* because both acts are gated on a fresh second factor that resolves
|
||||
* through the session cookie and has no Bearer fall-through.
|
||||
*
|
||||
* The asymmetry is a consequence of the step-up rather than a policy invented
|
||||
* for the native client, and it is the sentence Plan 16's handoff note is
|
||||
* derived from — so it is pinned here as a set rather than left implicit in
|
||||
* five files that each prove a quarter of it.
|
||||
*
|
||||
* ## Why this file is a map and not a fifth copy of the assertions
|
||||
*
|
||||
* Every claim below is proved BEHAVIOURALLY somewhere — against the real
|
||||
* resolver, or the real route, or a real Postgres. Re-asserting them here would
|
||||
* make five tests that agree with each other and drift together. What has no
|
||||
* home is the CLAIM SET: that these four facts are one contract, and that
|
||||
* removing any one of them is a change to what a native client may do. This
|
||||
* file names each claim, the file that proves it and the test title that does,
|
||||
* and fails when a title moves or a file stops carrying it.
|
||||
*
|
||||
* The pattern is the repo's own — `record-session-fence-acceptance-map.test.ts`
|
||||
* does the same for the fence's ten cases, for the same reason.
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
interface NativeAuthorityClaim {
|
||||
/** What the contract promises. */
|
||||
claim: string;
|
||||
/** Where it is proved, relative to the repository root. */
|
||||
file: string;
|
||||
/** A fragment of the test title that proves it. */
|
||||
title: string;
|
||||
/** How it is proved, so a reader knows what the evidence is worth. */
|
||||
kind: "resolver unit" | "route unit" | "postgres integration";
|
||||
}
|
||||
|
||||
const NATIVE_AUTHORITY: NativeAuthorityClaim[] = [
|
||||
{
|
||||
claim: "A token exercises a MANAGE grant on a shared record.",
|
||||
file: "src/lib/__tests__/acting-account-resolver.test.ts",
|
||||
title: "admits a MANAGE grant over the Bearer transport too",
|
||||
kind: "resolver unit",
|
||||
},
|
||||
{
|
||||
claim: "A token exercises a Guardian's authority over a managed profile.",
|
||||
file: "src/lib/__tests__/acting-account-resolver.test.ts",
|
||||
title: "admits a Guardian over the Bearer transport, per request",
|
||||
kind: "resolver unit",
|
||||
},
|
||||
{
|
||||
claim:
|
||||
"A token cannot offer MANAGE, and is told so in a code it can branch on.",
|
||||
file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts",
|
||||
title: "refuses a Bearer caller with a code it can act on",
|
||||
kind: "route unit",
|
||||
},
|
||||
{
|
||||
claim:
|
||||
"The refusal lands before the invitee lookup, so it is no account oracle.",
|
||||
file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts",
|
||||
title:
|
||||
"refuses the Bearer caller before it discloses whether the account exists",
|
||||
kind: "route unit",
|
||||
},
|
||||
{
|
||||
claim: "A token keeps minting the two levels it always could.",
|
||||
file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts",
|
||||
title: "lets a Bearer caller keep minting the two levels it always could",
|
||||
kind: "route unit",
|
||||
},
|
||||
{
|
||||
claim: "A token cannot create a managed profile, at any permission width.",
|
||||
file: "tests/integration/managed-profile-surface.test.ts",
|
||||
title: "cannot be reached with a token, however wide its permissions",
|
||||
kind: "postgres integration",
|
||||
},
|
||||
{
|
||||
claim:
|
||||
"The positive control: a cookie session with a fresh factor creates one.",
|
||||
file: "tests/integration/managed-profile-surface.test.ts",
|
||||
title: "creates a profile from exactly the body the form composes",
|
||||
kind: "postgres integration",
|
||||
},
|
||||
{
|
||||
claim: "A token cannot read the Guardian roster either — cookie-only.",
|
||||
file: "tests/integration/managed-profile-surface.test.ts",
|
||||
title: "refuses a Bearer caller, because the family is cookie-only",
|
||||
kind: "postgres integration",
|
||||
},
|
||||
];
|
||||
|
||||
describe("the native client's authority over the third level", () => {
|
||||
it("claims a non-empty set, over more than one kind of evidence", () => {
|
||||
// A map that named nothing would pass every assertion below it.
|
||||
expect(NATIVE_AUTHORITY.length).toBeGreaterThan(5);
|
||||
expect(new Set(NATIVE_AUTHORITY.map((entry) => entry.kind)).size).toBe(3);
|
||||
expect(new Set(NATIVE_AUTHORITY.map((entry) => entry.file)).size).toBe(3);
|
||||
});
|
||||
|
||||
it.each(NATIVE_AUTHORITY)("is proved: $claim", ({ file, title }) => {
|
||||
const path = join(ROOT, file);
|
||||
expect(existsSync(path), `${file} is missing`).toBe(true);
|
||||
const source = readFileSync(path, "utf8");
|
||||
// The title as a test title, not as prose in a comment: a claim proved by
|
||||
// a docblock is the shape this repo has been burned by.
|
||||
expect(source, `${file} no longer carries "${title}"`).toContain(
|
||||
`"${title}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the mint refusal and the exercise on opposite sides", () => {
|
||||
// The one-sentence version of the contract, asserted on the source of the
|
||||
// gate rather than on prose about it: the transport check is what makes
|
||||
// the invitation cookie-only, and it sits above the step-up call so that a
|
||||
// token is never handed the session gate's "not authenticated".
|
||||
const invite = readFileSync(
|
||||
join(ROOT, "src/app/api/account/grants/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const transportCheck = invite.indexOf('auth.authMethod !== "cookie"');
|
||||
const stepUp = invite.indexOf("await requireFreshMfaIfEnrolled(");
|
||||
expect(transportCheck).toBeGreaterThan(-1);
|
||||
expect(stepUp).toBeGreaterThan(-1);
|
||||
expect(transportCheck).toBeLessThan(stepUp);
|
||||
|
||||
// And the managed-profile creation does not even have the softer gate:
|
||||
// `requireFreshMfa` refuses an account with no second factor enrolled,
|
||||
// where `requireFreshMfaIfEnrolled` would wave it through.
|
||||
const create = readFileSync(
|
||||
join(ROOT, "src/app/api/managed-profiles/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(create).toContain("requireFreshMfa(");
|
||||
expect(create).not.toContain("requireFreshMfaIfEnrolled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* v1.37.0 — the record-session fence is a cookie-transport contract, frozen.
|
||||
*
|
||||
* The fence refuses a browser request formed under a record context the session
|
||||
* has since left. Everything about it — the epoch, the two request headers, the
|
||||
* echo, the 409 — belongs to the cookie transport, and the native contract is
|
||||
* unchanged in every direction by it. That is the claim this file freezes,
|
||||
* because it is the one a client implementer has to be able to rely on without
|
||||
* reading the server: a token sends no fence header, receives none back, and is
|
||||
* never refused for the absence of one.
|
||||
*
|
||||
* ## What lives here rather than beside the fence
|
||||
*
|
||||
* `src/lib/sharing/__tests__/record-session-fence.test.ts` proves the verdict
|
||||
* table and the enforcement, including both Bearer arms. This file proves the
|
||||
* two things that face a CLIENT and had no test of their own:
|
||||
*
|
||||
* 1. `GET /api/auth/me` publishes `recordSession` on a cookie session and
|
||||
* `null` on a token — the field is how a Bearer client is told the fence
|
||||
* does not apply to it, rather than being left to infer that from silence;
|
||||
* 2. the PUBLISHED contract says so, in the two request-header parameters and
|
||||
* in the schema of the field, under the exact header names the code uses.
|
||||
*
|
||||
* The second is not a paraphrase check. A spec that named the headers slightly
|
||||
* differently from the server would be a contract nobody could implement
|
||||
* against, and the names live in a client-safe module precisely so both ends
|
||||
* can be held to one spelling.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
RECORD_EPOCH_HEADER,
|
||||
RECORD_FENCE_ERROR_CODE,
|
||||
RECORD_SCOPE_HEADER,
|
||||
RECORD_SCOPE_SELF,
|
||||
} from "@/lib/sharing/record-session-fence-contract";
|
||||
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
|
||||
import {
|
||||
recordEpochParameter,
|
||||
recordScopeParameter,
|
||||
} from "@/lib/openapi/routes/account-sharing";
|
||||
import type { AuthContext } from "@/lib/api-handler";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
/**
|
||||
* The published parameter, narrowed. `ZodOpenApiObject["components"].parameters`
|
||||
* admits a `$ref` as well as an object, so the union has no `name` — these two
|
||||
* are literal objects and the cast says which arm they are.
|
||||
*/
|
||||
function headerParameter(parameter: unknown): {
|
||||
name: string;
|
||||
in: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
} {
|
||||
return parameter as {
|
||||
name: string;
|
||||
in: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** The shape `recordSessionForPayload` reads, and nothing else. */
|
||||
function auth(
|
||||
authMethod: "cookie" | "bearer",
|
||||
session: { recordEpoch?: number; actingAsUserId?: string | null } = {},
|
||||
): AuthContext {
|
||||
return {
|
||||
authMethod,
|
||||
user: { id: "caller" },
|
||||
session: {
|
||||
recordEpoch: session.recordEpoch,
|
||||
actingAsUserId: session.actingAsUserId ?? null,
|
||||
},
|
||||
} as unknown as AuthContext;
|
||||
}
|
||||
|
||||
describe("what the account payload tells each transport about the fence", () => {
|
||||
it("publishes the context a browser adopts", () => {
|
||||
expect(
|
||||
recordSessionForPayload(
|
||||
auth("cookie", { recordEpoch: 3, actingAsUserId: "record-owner" }),
|
||||
),
|
||||
).toEqual({ epoch: 3, scope: "record-owner" });
|
||||
|
||||
// Its own record is `scope: null`, not the absence of the field: null is a
|
||||
// first-class answer here, and the header spelling for it is `self`.
|
||||
expect(recordSessionForPayload(auth("cookie", { recordEpoch: 0 }))).toEqual(
|
||||
{ epoch: 0, scope: null },
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes null on the Bearer transport, however switched the row looks", () => {
|
||||
// The stamped columns are ignored rather than absent — a token issued to an
|
||||
// account whose session row carries a selector still gets null, because the
|
||||
// token has no session and nothing to be stale about. Told explicitly, so a
|
||||
// native client can assert on the field instead of inferring from omission.
|
||||
expect(
|
||||
recordSessionForPayload(
|
||||
auth("bearer", { recordEpoch: 9, actingAsUserId: "record-owner" }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a session row without an epoch as the un-switched start", () => {
|
||||
expect(recordSessionForPayload(auth("cookie"))).toEqual({
|
||||
epoch: 0,
|
||||
scope: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the published contract says about the fence's headers", () => {
|
||||
const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8");
|
||||
|
||||
it("publishes both request headers under the names the code uses", () => {
|
||||
// One header, two spellings, and the difference is deliberate rather than
|
||||
// sloppy: the server matches the lower-case form because that is what an
|
||||
// HTTP/2 client sends and what `Headers.get` normalises to, while the spec
|
||||
// publishes the canonical capitalisation an implementer will type. Held
|
||||
// equal ignoring case, so a genuine rename cannot pass as a casing choice.
|
||||
expect(headerParameter(recordEpochParameter).name.toLowerCase()).toBe(
|
||||
RECORD_EPOCH_HEADER,
|
||||
);
|
||||
expect(headerParameter(recordScopeParameter).name.toLowerCase()).toBe(
|
||||
RECORD_SCOPE_HEADER,
|
||||
);
|
||||
expect(spec.toLowerCase()).toContain(RECORD_EPOCH_HEADER);
|
||||
expect(spec.toLowerCase()).toContain(RECORD_SCOPE_HEADER);
|
||||
});
|
||||
|
||||
it("marks both as cookie-transport only, in the description a client reads", () => {
|
||||
for (const parameter of [recordEpochParameter, recordScopeParameter]) {
|
||||
const { description } = headerParameter(parameter);
|
||||
expect(description.length).toBeGreaterThan(100);
|
||||
expect(description).toMatch(/[Cc]ookie transport only/);
|
||||
expect(parameter).toMatchObject({ in: "header", required: false });
|
||||
}
|
||||
|
||||
// The epoch's description additionally carries the two refusals a client
|
||||
// has to tell apart, because they mean opposite things: reconcile and
|
||||
// retry, versus leave the record and reload.
|
||||
const epoch = headerParameter(recordEpochParameter).description;
|
||||
expect(epoch).toContain(RECORD_FENCE_ERROR_CODE);
|
||||
expect(epoch).toContain("sharing.access.denied");
|
||||
});
|
||||
|
||||
it("publishes the field a client adopts, and that it is null on Bearer", () => {
|
||||
// Read off the generated document rather than the source object: the spec
|
||||
// is what an implementer holds, and a schema that never reached the YAML
|
||||
// would still satisfy an assertion made against the registry.
|
||||
const state = spec.slice(spec.indexOf(" RecordSessionState:"));
|
||||
// Up to the next schema key at the same indent — matched as "four spaces
|
||||
// then a non-space", because a plain four-space search would stop at the
|
||||
// first nested line instead.
|
||||
const next = /\n {4}\S/.exec(state.slice(1));
|
||||
const block = state.slice(0, next ? next.index + 1 : state.length);
|
||||
expect(block).toContain("epoch");
|
||||
expect(block).toContain("scope");
|
||||
expect(block).toMatch(/[Nn]ull on Bearer/);
|
||||
expect(block).toContain(RECORD_SCOPE_SELF);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* A refused sharing action says what happened, stays on screen, and can be
|
||||
* tried again.
|
||||
*
|
||||
* ## The state this file exists to prevent
|
||||
*
|
||||
* Three of the four mutations on the shared-access panel had no failure
|
||||
* rendering at all. `accept`, `revoke` and `renounce` fired, the request came
|
||||
* back 409 or 410 or never left the machine, and the panel did nothing: the
|
||||
* dialog had already closed, the list did not change, and the only signal was
|
||||
* the absence of one. The two refusals that matter most were exactly the two
|
||||
* that arrived silently — an invitation that had lapsed, and the last Guardian
|
||||
* being told they may not walk away from a profile that would then have nobody
|
||||
* looking after it. Both are recoverable, and neither was recoverable if
|
||||
* nobody knew it had happened.
|
||||
*
|
||||
* ## Why the failure lives on the mutation and not in a `useState`
|
||||
*
|
||||
* A local `useState` set from `onError` is a second copy of a fact TanStack
|
||||
* already holds, and the copy is the thing that goes stale: a re-render from
|
||||
* an unrelated invalidation clears it, or a second failure overwrites the
|
||||
* first row's message onto another row. `isError` / `error` / `variables`
|
||||
* survive on the mutation until something explicitly resets it, and
|
||||
* `variables` is what says WHICH row failed. That is also what makes this file
|
||||
* possible — the panel's failure states are reachable from a static render,
|
||||
* with no click, because they are props rather than the result of an
|
||||
* interaction.
|
||||
*
|
||||
* ## What a static render can and cannot prove
|
||||
*
|
||||
* It proves the alert exists, is announced, is addressed to the right row,
|
||||
* carries content contrast rather than muted meta, sits beside a retry control
|
||||
* and leaves the row's own affordances in place. It does NOT prove the retry
|
||||
* button re-fires the mutation with the same argument — there is no click
|
||||
* here. That claim is the browser journey's, and the pure resolver below is
|
||||
* where the mapping from a refusal to a sentence is actually pinned.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants";
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: grantList() };
|
||||
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
/** A mutation as the panel sees one: idle unless a case says otherwise. */
|
||||
interface FakeMutation {
|
||||
mutate: ReturnType<typeof vi.fn>;
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
variables: string | undefined;
|
||||
}
|
||||
|
||||
function idle(): FakeMutation {
|
||||
return {
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function failed(error: unknown, grantId: string): FakeMutation {
|
||||
return { ...idle(), isError: true, error, variables: grantId };
|
||||
}
|
||||
|
||||
const acceptRef = { value: idle() };
|
||||
const revokeRef = { value: idle() };
|
||||
const renounceRef = { value: idle() };
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/queries/use-account-grants")>();
|
||||
return {
|
||||
...actual,
|
||||
useAccountGrants: () => ({
|
||||
data: grantsRef.value,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useAcceptGrant: () => acceptRef.value,
|
||||
useRevokeGrant: () => revokeRef.value,
|
||||
useRenounceGrant: () => renounceRef.value,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
import {
|
||||
grantActionErrorKey,
|
||||
type GrantAction,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import { inviteErrorKey } from "@/components/settings/access/grant-invite-card";
|
||||
import { GrantsGivenCard } from "@/components/settings/access/grants-given-card";
|
||||
import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card";
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const BASE: GrantRow = {
|
||||
id: "g1",
|
||||
account: { id: "acct-1", username: "housemate", displayName: "Jo" },
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
state: "ACTIVE",
|
||||
invitedAt: "2026-01-02T10:00:00.000Z",
|
||||
acceptedAt: "2026-01-02T11:00:00.000Z",
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
};
|
||||
|
||||
const PENDING: GrantRow = { ...BASE, state: "PENDING", acceptedAt: null };
|
||||
|
||||
function apiError(status: number, errorCode?: string): ApiError {
|
||||
return new ApiError("refused", status, errorCode ? { errorCode } : undefined);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
acceptRef.value = idle();
|
||||
revokeRef.value = idle();
|
||||
renounceRef.value = idle();
|
||||
grantsRef.value = grantList();
|
||||
}
|
||||
|
||||
describe("what a refused sharing action says", () => {
|
||||
const cases: Array<{
|
||||
action: GrantAction;
|
||||
err: unknown;
|
||||
key: string;
|
||||
why: string;
|
||||
}> = [
|
||||
{
|
||||
action: "accept",
|
||||
err: apiError(410, "sharing.accept.expired"),
|
||||
key: "recordSharing.actionError.acceptExpired",
|
||||
why: "the invitation lapsed — the next step is to ask for a new one",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: apiError(410, "sharing.accept.revoked"),
|
||||
key: "recordSharing.actionError.acceptWithdrawn",
|
||||
why: "withdrawn is a different fact from lapsed, and has no next step",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: apiError(409, "sharing.accept.not_pending"),
|
||||
key: "recordSharing.actionError.acceptNotPending",
|
||||
why: "already accepted, most likely in another tab",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: apiError(404, "sharing.accept.not_found"),
|
||||
key: "recordSharing.actionError.acceptNotFound",
|
||||
why: "the row is gone",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: apiError(429),
|
||||
key: "recordSharing.actionError.rateLimited",
|
||||
why: "waiting is the recovery, so it must not read as a failure",
|
||||
},
|
||||
{
|
||||
action: "revoke",
|
||||
err: apiError(409, "managed_profile.guardian.required"),
|
||||
key: "recordSharing.actionError.lastGuardian",
|
||||
why: "the refusal a managed profile depends on, and it names a way out",
|
||||
},
|
||||
{
|
||||
action: "renounce",
|
||||
err: apiError(409, "managed_profile.guardian.required"),
|
||||
key: "recordSharing.actionError.lastGuardian",
|
||||
why: "the same refusal reached from the delegate's side",
|
||||
},
|
||||
{
|
||||
action: "revoke",
|
||||
err: apiError(409, "sharing.revoke.already_ended"),
|
||||
key: "recordSharing.actionError.alreadyEnded",
|
||||
why: "nothing to recover — the access is already closed",
|
||||
},
|
||||
{
|
||||
action: "renounce",
|
||||
err: apiError(409, "sharing.renounce.already_ended"),
|
||||
key: "recordSharing.actionError.alreadyEnded",
|
||||
why: "same, from the other side",
|
||||
},
|
||||
{
|
||||
action: "revoke",
|
||||
err: apiError(404, "sharing.revoke.not_found"),
|
||||
key: "recordSharing.actionError.notFound",
|
||||
why: "the row is gone",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(404, "managed_profile.not_found"),
|
||||
key: "recordSharing.actionError.profileNotFound",
|
||||
why: "the only refusal the delete route classifies, and it covers three situations the server will not tell apart",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: apiError(404, "managed_profile.not_found"),
|
||||
key: "recordSharing.actionError.profileNotFound",
|
||||
why: "the same refusal reached from the guardian-removal route",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: apiError(409, "managed_profile.guardian.required"),
|
||||
key: "recordSharing.actionError.lastGuardian",
|
||||
why: "the floor, still enforced by the server after the card has hidden the control",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(500),
|
||||
key: "recordSharing.actionError.deleteProfileFailed",
|
||||
why: "anything the delete route does not classify becomes the generic envelope and must not borrow a diagnosis",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(401, "auth.stepup.required"),
|
||||
key: "recordSharing.managed.errorStepUp",
|
||||
why: "the step-up window lapsed while the confirm dialog was open — the likeliest refusal of the two acts that carry a gate",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: apiError(401, "auth.stepup.required"),
|
||||
key: "recordSharing.managed.errorStepUp",
|
||||
why: "the same gate on the other confirmed act",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(401, "auth.stepup.mfa_not_enrolled"),
|
||||
key: "recordSharing.managed.errorMfaRequired",
|
||||
why: "no second factor at all: 'confirm your second factor' is a dead end, and enrolling is the only way through",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: apiError(401),
|
||||
key: "recordSharing.actionError.acceptFailed",
|
||||
why: "a 401 on a route with NO step-up gate is a lapsed session, and must not tell somebody to confirm a factor",
|
||||
},
|
||||
{
|
||||
action: "renounce",
|
||||
err: apiError(401),
|
||||
key: "recordSharing.actionError.renounceFailed",
|
||||
why: "same, from the delegate's side",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: new TypeError("Failed to fetch"),
|
||||
key: "recordSharing.actionError.offline",
|
||||
why: "never reached the server, so the guardian still has access and the row is unchanged",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: new TypeError("Failed to fetch"),
|
||||
key: "recordSharing.actionError.offline",
|
||||
why: "never reached the server — retrying is the whole recovery",
|
||||
},
|
||||
{
|
||||
action: "revoke",
|
||||
err: new TypeError("Failed to fetch"),
|
||||
key: "recordSharing.actionError.offline",
|
||||
why: "same, and the row is unchanged because nothing was sent",
|
||||
},
|
||||
{
|
||||
action: "renounce",
|
||||
err: new TypeError("Failed to fetch"),
|
||||
key: "recordSharing.actionError.offline",
|
||||
why: "same",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: apiError(500),
|
||||
key: "recordSharing.actionError.acceptFailed",
|
||||
why: "an unclassified failure still says which act failed",
|
||||
},
|
||||
{
|
||||
action: "revoke",
|
||||
err: apiError(500),
|
||||
key: "recordSharing.actionError.revokeFailed",
|
||||
why: "same",
|
||||
},
|
||||
{
|
||||
action: "renounce",
|
||||
err: apiError(500),
|
||||
key: "recordSharing.actionError.renounceFailed",
|
||||
why: "same",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { action, err, key, why } of cases) {
|
||||
it(`${action}: ${why}`, () => {
|
||||
expect(grantActionErrorKey(err, action)).toBe(key);
|
||||
});
|
||||
}
|
||||
|
||||
it("tells the refusals apart rather than collapsing them", () => {
|
||||
// The failure this table is written against is one message for
|
||||
// everything. Distinct codes must produce distinct sentences, and the
|
||||
// count is asserted so a resolver that returned one key for all of them
|
||||
// fails here rather than passing every row above by accident.
|
||||
const distinct = new Set(
|
||||
cases.map(({ err, action }) => grantActionErrorKey(err, action)),
|
||||
);
|
||||
expect(distinct.size).toBeGreaterThan(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a refused invitation keeps what was typed", () => {
|
||||
const INVITE_CARD = readFileSync(
|
||||
join(process.cwd(), "src/components/settings/access/grant-invite-card.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("separates a request that never arrived from one the server refused", () => {
|
||||
// "The invitation could not be sent" invites somebody to change the form,
|
||||
// and after a dropped connection the form is fine.
|
||||
expect(inviteErrorKey(new TypeError("Failed to fetch"))).toBe(
|
||||
"recordSharing.invite.errorOffline",
|
||||
);
|
||||
expect(inviteErrorKey(apiError(404))).toBe(
|
||||
"recordSharing.invite.errorNoAccount",
|
||||
);
|
||||
expect(inviteErrorKey(apiError(409, "sharing.invite.duplicate"))).toBe(
|
||||
"recordSharing.invite.errorDuplicate",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the form only where the invitation actually went", () => {
|
||||
// A static render cannot press the button, so the claim is made about the
|
||||
// code path instead: every field reset lives inside `onSuccess`. A reset
|
||||
// that migrated into `onError` — or out of the callbacks entirely — would
|
||||
// wipe a typed identifier and eight ticked sections on a failure the
|
||||
// person is being asked to retry.
|
||||
const start = INVITE_CARD.indexOf("onSuccess:");
|
||||
const split = INVITE_CARD.indexOf("onError:", start);
|
||||
// The options object ends at the first ` },` after the error arm, so
|
||||
// the slice stops at the mutation rather than running into the JSX below,
|
||||
// where a `setNarrowed(false)` on a radio would satisfy the matcher for
|
||||
// the wrong reason.
|
||||
const end = INVITE_CARD.indexOf("\n },", split);
|
||||
const onSuccess = INVITE_CARD.slice(start, split);
|
||||
const onError = INVITE_CARD.slice(split, end);
|
||||
|
||||
// Positive controls: both slices really contain the callback they name.
|
||||
expect(onSuccess).toContain("setInvited");
|
||||
expect(onError).toContain("inviteErrorKey");
|
||||
|
||||
for (const reset of [
|
||||
'setIdentifier("")',
|
||||
'setExpiresOn("")',
|
||||
'setAccess("READ")',
|
||||
"setNarrowed(false)",
|
||||
"setSections([])",
|
||||
]) {
|
||||
expect(onSuccess, reset).toContain(reset);
|
||||
expect(onError, reset).not.toContain(reset);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the refusal stays on screen, addressed to its own row", () => {
|
||||
it("announces a refused acceptance and leaves the invitation acceptable", () => {
|
||||
reset();
|
||||
grantsRef.value = grantList({ received: [PENDING] });
|
||||
acceptRef.value = failed(apiError(410, "sharing.accept.expired"), "g1");
|
||||
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
// Announced, not merely printed.
|
||||
expect(html).toContain('data-slot="grant-action-error"');
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("That invitation has lapsed");
|
||||
// Addressed to the row it belongs to, so a second invitation below it
|
||||
// does not wear somebody else's failure.
|
||||
expect(html).toContain('data-grant-error-for="g1"');
|
||||
// The state survives: the row is still there and still offers the act.
|
||||
expect(html).toContain('data-grant-id="g1"');
|
||||
expect(html).toContain('data-slot="grant-accept"');
|
||||
// And there is a way to try again.
|
||||
expect(html).toContain('data-slot="grant-action-retry"');
|
||||
});
|
||||
|
||||
it("does not paint the failure onto a row that did not fail", () => {
|
||||
reset();
|
||||
grantsRef.value = grantList({
|
||||
received: [PENDING, { ...PENDING, id: "g2" }],
|
||||
});
|
||||
acceptRef.value = failed(apiError(410, "sharing.accept.expired"), "g1");
|
||||
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
expect(html.match(/data-slot="grant-action-error"/g)).toHaveLength(1);
|
||||
expect(html).toContain('data-grant-error-for="g1"');
|
||||
expect(html).not.toContain('data-grant-error-for="g2"');
|
||||
});
|
||||
|
||||
it("names the last-Guardian refusal and the way out of it", () => {
|
||||
reset();
|
||||
grantsRef.value = grantList({ given: [BASE] });
|
||||
revokeRef.value = failed(
|
||||
apiError(409, "managed_profile.guardian.required"),
|
||||
"g1",
|
||||
);
|
||||
|
||||
const html = render(<GrantsGivenCard />);
|
||||
|
||||
expect(html).toContain('data-slot="grant-action-error"');
|
||||
// The sentence has to carry the recovery. "Could not end access" is true
|
||||
// and useless; the owner needs to know a second guardian is the fix.
|
||||
expect(html).toContain("Add another guardian first");
|
||||
// The row keeps its control, so the owner can act on the advice and
|
||||
// return to the same button.
|
||||
expect(html).toContain('data-slot="grant-revoke"');
|
||||
});
|
||||
|
||||
it("reaches the same refusal from the delegate's side", () => {
|
||||
reset();
|
||||
grantsRef.value = grantList({ received: [BASE] });
|
||||
renounceRef.value = failed(
|
||||
apiError(409, "managed_profile.guardian.required"),
|
||||
"g1",
|
||||
);
|
||||
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
expect(html).toContain('data-slot="grant-action-error"');
|
||||
expect(html).toContain("Add another guardian first");
|
||||
expect(html).toContain('data-slot="grant-renounce"');
|
||||
});
|
||||
|
||||
it("keeps material copy in content contrast, not muted meta", () => {
|
||||
reset();
|
||||
grantsRef.value = grantList({ received: [PENDING] });
|
||||
acceptRef.value = failed(new TypeError("Failed to fetch"), "g1");
|
||||
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
const alert = /<[^>]*data-slot="grant-action-error"[^>]*>/.exec(html)?.[0];
|
||||
|
||||
expect(alert).toBeDefined();
|
||||
// A refusal somebody has to act on is content. UI-STANDARDS §3 reserves
|
||||
// muted for meta, and a sentence nobody reads is a sentence nobody acts
|
||||
// on.
|
||||
expect(alert).toContain("text-destructive");
|
||||
expect(alert).not.toContain("text-muted-foreground");
|
||||
});
|
||||
|
||||
it("shows nothing at all while every mutation is idle", () => {
|
||||
reset();
|
||||
grantsRef.value = grantList({ given: [BASE], received: [PENDING] });
|
||||
|
||||
expect(render(<GrantsGivenCard />)).not.toContain(
|
||||
'data-slot="grant-action-error"',
|
||||
);
|
||||
expect(render(<GrantsReceivedCard />)).not.toContain(
|
||||
'data-slot="grant-action-error"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grants";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
|
||||
|
||||
describe("scoped sharing contract", () => {
|
||||
it("locks the closed eight-domain vocabulary", () => {
|
||||
expect(SHARE_DOMAINS).toEqual([
|
||||
"measurements",
|
||||
"medications",
|
||||
"labs",
|
||||
"profile",
|
||||
"illness",
|
||||
"mind",
|
||||
"cycle",
|
||||
"documents",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats null as whole-record while refusing malformed non-null scopes", () => {
|
||||
expect(normaliseScope(null)).toBeNull();
|
||||
|
||||
for (const malformed of [
|
||||
[],
|
||||
["labs", "labs"],
|
||||
["labs", "unknown"],
|
||||
["record"],
|
||||
"labs",
|
||||
{ labs: true },
|
||||
]) {
|
||||
const resolved = normaliseScope(malformed);
|
||||
expect(resolved, JSON.stringify(malformed)).not.toBeNull();
|
||||
expect(resolved?.size, JSON.stringify(malformed)).toBe(0);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(
|
||||
grantCoversDomain({ scopeJson: malformed }, domain),
|
||||
`${JSON.stringify(malformed)} must not open ${domain}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts whole or non-empty unique READ and WRITE scopes only", () => {
|
||||
for (const access of ["READ", "WRITE"] as const) {
|
||||
expect(
|
||||
inviteGrantSchema.safeParse({
|
||||
identifier: "delegate",
|
||||
access,
|
||||
scope: null,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
inviteGrantSchema.safeParse({
|
||||
identifier: "delegate",
|
||||
access,
|
||||
scope: ["labs", "medications"],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
expect(
|
||||
inviteGrantSchema.safeParse({
|
||||
identifier: "delegate",
|
||||
access: "READ",
|
||||
scope: ["labs", "labs"],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows MANAGE only for the whole record", () => {
|
||||
expect(
|
||||
inviteGrantSchema.safeParse({
|
||||
identifier: "delegate",
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
inviteGrantSchema.safeParse({
|
||||
identifier: "delegate",
|
||||
access: "MANAGE",
|
||||
scope: ["labs"],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ADMITTED_MUTATING_HANDLERS,
|
||||
SHARING_DOMAINS,
|
||||
} from "../../tests/fixtures/v137/sharing-matrix";
|
||||
import { SECURITY_PRINCIPALS } from "../../tests/fixtures/v137/security-principals";
|
||||
import { LEGACY_ACCOUNT_PAYLOADS } from "../../tests/fixtures/v137/legacy-account-payloads";
|
||||
import {
|
||||
NOTIFICATION_CHANNELS,
|
||||
NOTIFICATION_DELIVERY_MATRIX,
|
||||
} from "../../tests/fixtures/v137/notification-matrix";
|
||||
|
||||
describe("sharing and handler inventories", () => {
|
||||
it("freezes the exact sharing-domain vocabulary", () => {
|
||||
expect(SHARING_DOMAINS).toEqual([
|
||||
"measurements",
|
||||
"medications",
|
||||
"labs",
|
||||
"profile",
|
||||
"illness",
|
||||
"mind",
|
||||
"cycle",
|
||||
"documents",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(62);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ route, action }) => `${route}#${action}`,
|
||||
),
|
||||
).size,
|
||||
).toBe(ADMITTED_MUTATING_HANDLERS.length);
|
||||
|
||||
for (const handler of ADMITTED_MUTATING_HANDLERS) {
|
||||
expect(handler.ownRecord.expected).toBe("allow");
|
||||
expect(handler.deniedLevel.expected).toBe("deny");
|
||||
expect(handler.admittedLevel.expected).toBe("allow");
|
||||
expect(handler.actorAttribution.field).toBe("actorUserId");
|
||||
expect(handler.resultingEffect.expected).toBe("applied-once");
|
||||
expect(handler.encryptedFieldCarveOut).toBe(
|
||||
"never-assert-decrypted-field-content",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("security principals and compatibility", () => {
|
||||
it("keeps actor, record, recipient, origin, grant, and job identities distinct", () => {
|
||||
const { activeDelegation, revokedDelegation } = SECURITY_PRINCIPALS;
|
||||
|
||||
expect(new Set(Object.values(activeDelegation.identities)).size).toBe(3);
|
||||
expect(activeDelegation.origin).toBe("delegated-request");
|
||||
expect(activeDelegation.grant.state).toBe("active");
|
||||
expect(revokedDelegation.grant.state).toBe("revoked");
|
||||
expect(activeDelegation.job.id).not.toBe(revokedDelegation.job.id);
|
||||
});
|
||||
|
||||
it("covers every guardian, profile, and delivery channel exactly once", () => {
|
||||
expect(NOTIFICATION_CHANNELS).toEqual([
|
||||
"apns",
|
||||
"web-push",
|
||||
"telegram",
|
||||
"ntfy",
|
||||
]);
|
||||
expect(NOTIFICATION_DELIVERY_MATRIX).toHaveLength(16);
|
||||
expect(
|
||||
new Set(
|
||||
NOTIFICATION_DELIVERY_MATRIX.map(
|
||||
({ channel, recipientUserId, recordUserId }) =>
|
||||
`${recordUserId}:${recipientUserId}:${channel}`,
|
||||
),
|
||||
).size,
|
||||
).toBe(NOTIFICATION_DELIVERY_MATRIX.length);
|
||||
expect(
|
||||
NOTIFICATION_DELIVERY_MATRIX.every(
|
||||
({ interactive, recipientUserId, recordUserId }) =>
|
||||
!interactive && String(recipientUserId) !== String(recordUserId),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retains legacy access compatibility and fails closed for scoped payloads", () => {
|
||||
expect(LEGACY_ACCOUNT_PAYLOADS.map(({ name }) => name)).toEqual([
|
||||
"whole-read",
|
||||
"whole-write",
|
||||
"manage-as-write",
|
||||
"scoped-fail-closed",
|
||||
]);
|
||||
expect(LEGACY_ACCOUNT_PAYLOADS[2]).toMatchObject({
|
||||
access: "write",
|
||||
level: "manage",
|
||||
});
|
||||
expect(LEGACY_ACCOUNT_PAYLOADS[3]?.legacyDecoder).toBe("deny");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,20 @@
|
||||
* purge job uses, so the sentence in the UI cannot drift from the job that
|
||||
* makes it true — an instance running a 90-day window says 90.
|
||||
*
|
||||
* **And the window is now enforced, not only stated.** The query is bounded to
|
||||
* it. Without the filter the two halves of the same sentence disagreed: the
|
||||
* revoke dialog promised attribution "for the next N days" while this read
|
||||
* returned whatever the last hundred rows happened to be, which on a quiet
|
||||
* record reaches back past a shortened retention window and on a busy one
|
||||
* covers a fortnight. The bound the copy states is the bound the query uses.
|
||||
*
|
||||
* **The row cap says when it was hit.** A hundred rows is a display bound, and
|
||||
* a list that silently stops at its own ceiling is the more dangerous of the
|
||||
* two completeness lies — an owner scrolling to the bottom reads the oldest
|
||||
* line as the beginning. `truncated` is true when the cap was reached, and the
|
||||
* view says it is showing the most recent activity rather than all of it.
|
||||
* `false` means the list IS everything inside the window.
|
||||
*
|
||||
* Not delegable: bare `requireAuth()` refuses under a switch. A delegate must
|
||||
* not be able to read the owner's record of who has been in it — that list
|
||||
* names other delegates, and a grant is not an introduction to the household.
|
||||
@@ -34,6 +48,9 @@ export const dynamic = "force-dynamic";
|
||||
* How many rows the feed returns. One row per (delegate, day) for reads, so a
|
||||
* hundred covers months of a busy household — and a longer list answers a
|
||||
* question nobody asked while making the recent activity harder to see.
|
||||
*
|
||||
* The cap is a display bound, not a completeness claim, and the response says
|
||||
* which it hit. See {@link GET}.
|
||||
*/
|
||||
const MAX_ROWS = 100;
|
||||
|
||||
@@ -77,6 +94,11 @@ function readAccessCount(
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The same number the response publishes and the purge job reads, resolved
|
||||
// once so the filter and the sentence cannot disagree.
|
||||
const retentionDays = getAuditLogRetentionDays();
|
||||
const windowStart = new Date(Date.now() - retentionDays * 86_400_000);
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -85,6 +107,10 @@ export const GET = apiHandler(async () => {
|
||||
// report. `actingActorFor` already refuses to stamp one, so this is the
|
||||
// second end of that rule rather than its only enforcement.
|
||||
NOT: { actorUserId: user.id },
|
||||
// The window the copy promises. The purge job makes it true eventually;
|
||||
// this makes it true now, which matters on an instance whose retention
|
||||
// was shortened after rows were already written.
|
||||
createdAt: { gte: windowStart },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: MAX_ROWS,
|
||||
@@ -114,15 +140,23 @@ export const GET = apiHandler(async () => {
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
// Equality, not `>=`: `take` cannot return more than it was given, and a
|
||||
// `>` would be a comparison that can never be true.
|
||||
const truncated = rows.length === MAX_ROWS;
|
||||
|
||||
annotate({
|
||||
action: { name: "sharing.activity.list" },
|
||||
meta: { entry_count: entries.length },
|
||||
meta: { entry_count: entries.length, truncated },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
entries,
|
||||
// The bound the view states out loud. Resolved here so the UI never has to
|
||||
// hardcode a number the operator can change.
|
||||
retentionDays: getAuditLogRetentionDays(),
|
||||
retentionDays,
|
||||
// Whether the list is everything inside that window, or its most recent
|
||||
// hundred rows. The view says which; silence would let the oldest line
|
||||
// read as the beginning.
|
||||
truncated,
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
|
||||
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
|
||||
import { GrantError, renounceGrantAndClearSwitch } from "@/lib/sharing/grants";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -76,6 +77,11 @@ export const POST = apiHandler(
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GrantError) return renounceErrorResponse(err);
|
||||
if (err instanceof LastManagedGuardianError) {
|
||||
return apiError("Add another Guardian before ending this access", 409, {
|
||||
errorCode: "managed_profile.guardian.required",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
|
||||
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
|
||||
import { GrantError, revokeGrantAndClearSwitch } from "@/lib/sharing/grants";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -75,6 +76,11 @@ export const DELETE = apiHandler(
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GrantError) return revokeErrorResponse(err);
|
||||
if (err instanceof LastManagedGuardianError) {
|
||||
return apiError("Add another Guardian before ending this access", 409, {
|
||||
errorCode: "managed_profile.guardian.required",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* `POST /api/account/grants` — the two things v1.37.0 adds to an invitation.
|
||||
*
|
||||
* The sections it opens, and the step-up that gates the level which can delete
|
||||
* things. Both are asserted on what the route DID rather than on what it
|
||||
* answered with: the scope on the `data` object handed to `accountGrant.create`
|
||||
* (a response echoing the request would pass either way, and the failure this
|
||||
* pins is the one where a field is validated, annotated on, and dropped on the
|
||||
* way to the database), and the step-up on the gate having been CALLED, plus
|
||||
* the absence of a row when it refuses.
|
||||
*
|
||||
* What lives where, asserted rather than assumed: an absent `scope` becomes
|
||||
* NULL in the schema and nowhere else, and `inviteGrant` writes `Prisma.DbNull`
|
||||
* for it — a bare `null` on a nullable Json column stores the JSON value
|
||||
* `null`, which the fail-closed normaliser reads as the empty set. That is a
|
||||
* grant opening NOTHING written by the path meant to open everything, one
|
||||
* keystroke away, so it is pinned.
|
||||
*
|
||||
* Mutation checks, run and recorded in the stream's report:
|
||||
* - drop the enum from the `scope` schema (`z.array(z.string())`) → the
|
||||
* unknown-section case stops being refused and goes red;
|
||||
* - make the step-up branch unconditional (`if (true)`) → "a read invitation
|
||||
* is not gated" goes red; remove the branch entirely → the MANAGE legs go
|
||||
* red.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
requireAuth: vi.fn(),
|
||||
requireFreshMfaIfEnrolled: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
accountGrant: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { POST } from "../route";
|
||||
import {
|
||||
requireAuth,
|
||||
requireFreshMfaIfEnrolled,
|
||||
StepUpRequiredError,
|
||||
} from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const OWNER = { id: "owner-1" };
|
||||
const INVITEE = { id: "invitee-1", username: "housemate", displayName: null };
|
||||
|
||||
function request(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest("http://localhost/api/account/grants", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** The `data` object the route's write reached the client with. */
|
||||
function writtenRow(): Record<string, unknown> {
|
||||
const calls = vi.mocked(prisma.accountGrant.create).mock.calls;
|
||||
expect(calls).toHaveLength(1);
|
||||
return (calls[0][0] as { data: Record<string, unknown> }).data;
|
||||
}
|
||||
|
||||
/** Resolve the caller on the transport the argument names. */
|
||||
function callerOn(transport: "cookie" | "bearer") {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: OWNER,
|
||||
authMethod: transport,
|
||||
} as unknown as Awaited<ReturnType<typeof requireAuth>>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
callerOn("cookie");
|
||||
vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue({
|
||||
user: OWNER,
|
||||
} as unknown as Awaited<ReturnType<typeof requireFreshMfaIfEnrolled>>);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(
|
||||
INVITEE as never as Awaited<ReturnType<typeof prisma.user.findFirst>>,
|
||||
);
|
||||
vi.mocked(prisma.accountGrant.create).mockImplementation((async (args: {
|
||||
data: Record<string, unknown>;
|
||||
}) => ({
|
||||
id: "grant-1",
|
||||
grantorId: OWNER.id,
|
||||
granteeId: INVITEE.id,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
scopeJson: null,
|
||||
...args.data,
|
||||
})) as never);
|
||||
});
|
||||
|
||||
describe("the sections an invitation opens", () => {
|
||||
it("stores the set the owner picked", async () => {
|
||||
const res = await POST(
|
||||
request({ identifier: "housemate", scope: ["labs", "medications"] }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(writtenRow().scopeJson).toEqual(["labs", "medications"]);
|
||||
});
|
||||
|
||||
it("stores the whole record as DbNull, not as a JSON null", async () => {
|
||||
// The one-keystroke inversion: a bare `null` on a nullable Json column is
|
||||
// the stored JSON value `null`, which the fail-closed normaliser resolves
|
||||
// to the empty set — the entire record turned into nothing at all.
|
||||
const res = await POST(request({ identifier: "housemate" }));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(writtenRow().scopeJson).toBe(Prisma.DbNull);
|
||||
});
|
||||
|
||||
it("treats an explicit null the same as an absent field", async () => {
|
||||
const res = await POST(request({ identifier: "housemate", scope: null }));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(writtenRow().scopeJson).toBe(Prisma.DbNull);
|
||||
});
|
||||
|
||||
it("refuses a section outside the vocabulary, without writing a row", async () => {
|
||||
const res = await POST(
|
||||
request({ identifier: "housemate", scope: ["labs", "bank_details"] }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the whole-record sentinel as a section", async () => {
|
||||
// `record` is deliberately not a member of the vocabulary: a narrowing
|
||||
// that names the whole record is a narrowing that means its own absence,
|
||||
// and the resolver would refuse every domain for it.
|
||||
const res = await POST(
|
||||
request({ identifier: "housemate", scope: ["record"] }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an empty set, without writing a row", async () => {
|
||||
const res = await POST(request({ identifier: "housemate", scope: [] }));
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a scope beside MANAGE, without writing a row", async () => {
|
||||
// Management is whole-record by construction. "They can do anything, but
|
||||
// only to part of you" promises a boundary an edit cannot keep.
|
||||
const res = await POST(
|
||||
request({ identifier: "housemate", access: "MANAGE", scope: ["labs"] }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records the sections on the audit row", async () => {
|
||||
// "Who was given what" is the question this row exists to answer, and an
|
||||
// entry naming the level without the sections answers half of it.
|
||||
await POST(request({ identifier: "housemate", scope: ["mind"] }));
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"sharing.grant.invited",
|
||||
expect.objectContaining({
|
||||
userId: OWNER.id,
|
||||
details: expect.objectContaining({ scope: ["mind"] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records the whole record as an answer rather than an omission", async () => {
|
||||
await POST(request({ identifier: "housemate" }));
|
||||
|
||||
const call = vi.mocked(auditLog).mock.calls[0];
|
||||
const details = (call[1] as { details: Record<string, unknown> }).details;
|
||||
expect(details).toHaveProperty("scope", null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offering MANAGE", () => {
|
||||
it("mints the row once the step-up is satisfied", async () => {
|
||||
const res = await POST(
|
||||
request({ identifier: "housemate", access: "MANAGE" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(writtenRow().access).toBe("MANAGE");
|
||||
expect(writtenRow().scopeJson).toBe(Prisma.DbNull);
|
||||
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
|
||||
});
|
||||
|
||||
it("refuses without a fresh second factor, and writes nothing", async () => {
|
||||
// The refusal CODE, not merely the absence of a row: a route that threw
|
||||
// for any other reason would also leave no row, and this leg is meant to
|
||||
// prove the GATE ran.
|
||||
vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue(
|
||||
new StepUpRequiredError(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
POST(request({ identifier: "housemate", access: "MANAGE" })),
|
||||
).rejects.toMatchObject({ errorCode: "auth.stepup.required" });
|
||||
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not gate a read or a write invitation", async () => {
|
||||
// The friction belongs to the level that can delete things. An owner
|
||||
// sharing a reading with their partner re-proves nothing.
|
||||
for (const access of ["READ", "WRITE"] as const) {
|
||||
vi.clearAllMocks();
|
||||
callerOn("cookie");
|
||||
const res = await POST(request({ identifier: "housemate", access }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(requireFreshMfaIfEnrolled).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a Bearer caller with a code it can act on", async () => {
|
||||
// The decided consequence of a cookie-only step-up, said comprehensibly.
|
||||
// Left to fall through, the gate resolves the session cookie, finds none
|
||||
// and answers "Not authenticated" to a caller the route had just
|
||||
// authenticated — an obscure failure on the one transport that cannot
|
||||
// ever succeed here.
|
||||
callerOn("bearer");
|
||||
|
||||
const res = await POST(
|
||||
request({ identifier: "housemate", access: "MANAGE" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("sharing.invite.manage_browser_only");
|
||||
expect(requireFreshMfaIfEnrolled).not.toHaveBeenCalled();
|
||||
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the Bearer caller before it discloses whether the account exists", async () => {
|
||||
// The identifier lookup answers 404 for a name nobody carries, which is a
|
||||
// deliberate disclosure to an authenticated caller — but there is no
|
||||
// reason to hand it out on a request that cannot succeed.
|
||||
callerOn("bearer");
|
||||
|
||||
await POST(request({ identifier: "housemate", access: "MANAGE" }));
|
||||
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a Bearer caller keep minting the two levels it always could", async () => {
|
||||
callerOn("bearer");
|
||||
|
||||
const res = await POST(
|
||||
request({ identifier: "housemate", access: "WRITE" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(writtenRow().access).toBe("WRITE");
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,19 @@
|
||||
* client that has to call twice will eventually render half an answer.
|
||||
*
|
||||
* `POST` offers a grant to somebody already registered on this instance, at
|
||||
* the level the owner chose. There is no route that raises a live grant: the
|
||||
* level is settled when the invitation is written and the only way to a wider
|
||||
* one is a fresh invitation the delegate accepts again.
|
||||
* the level the owner chose and over the sections they chose. There is no
|
||||
* route that raises a live grant: both are settled when the invitation is
|
||||
* written and the only way to a wider one is a fresh invitation the delegate
|
||||
* accepts again.
|
||||
*
|
||||
* v1.37.0 — offering MANAGE is the one act on this surface with friction on
|
||||
* it. It asks for a fresh second factor when the account has one, which makes
|
||||
* it cookie-only by construction: the step-up gate resolves through the
|
||||
* session cookie and a token transport carries nothing that could satisfy it.
|
||||
* That consequence is deliberate rather than an oversight — the native client
|
||||
* cannot offer management of a health record, and the refusal below says so in
|
||||
* a code rather than leaving a token caller with an authentication error on a
|
||||
* request that authenticated fine.
|
||||
*
|
||||
* **Neither is delegable, and that is structural.** Both resolve through bare
|
||||
* `requireAuth()`, which refuses outright while the caller is acting on
|
||||
@@ -31,7 +41,12 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireAuth,
|
||||
requireFreshMfaIfEnrolled,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -41,6 +56,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
|
||||
@@ -87,11 +103,27 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({
|
||||
given: given.map((g) => toGrantView(g, g.grantee, now)),
|
||||
received: received.map((g) => toGrantView(g, g.grantor, now)),
|
||||
// v1.37.0 — the audit window, on the payload that carries the rows.
|
||||
//
|
||||
// The revoke dialog tells the owner how long "who entered what" stays
|
||||
// answerable after the access ends, and it can only say that with the
|
||||
// number the operator configured. It used to read that number off the
|
||||
// activity feed, a second and independent request, so whether the sentence
|
||||
// appeared depended on which of two reads finished first — and an owner
|
||||
// who clicked quickly ended a manage grant having been told less than one
|
||||
// who clicked slowly, with nothing on screen to say a sentence was
|
||||
// missing. Riding it here removes the race by construction: a client
|
||||
// holding a grant to revoke is holding the window it must disclose.
|
||||
//
|
||||
// Same resolver as `/api/account/activity` and as the purge job, so the
|
||||
// two surfaces cannot state different numbers.
|
||||
retentionDays: getAuditLogRetentionDays(),
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const auth = await requireAuth();
|
||||
const user = auth.user;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`sharing:invite:${user.id}`,
|
||||
@@ -114,7 +146,38 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { identifier, expiresAt, access } = parsed.data;
|
||||
const { identifier, expiresAt, access, scope } = parsed.data;
|
||||
|
||||
// ── The step-up, and only on the level that needs one ───────────────────
|
||||
//
|
||||
// MANAGE is the level that can rewrite and delete somebody's health record,
|
||||
// so offering it is gated on a fresh second factor the way account deletion
|
||||
// and the encrypted export are. READ and WRITE are untouched: an owner
|
||||
// sharing a reading with their partner should not have to re-prove anything.
|
||||
//
|
||||
// It runs BEFORE the invitee lookup on purpose. That lookup answers 404 for
|
||||
// an identifier nobody carries, which is a deliberate disclosure to an
|
||||
// authenticated caller — but there is no reason to hand it out on a request
|
||||
// that is about to be refused anyway.
|
||||
//
|
||||
// The transport is checked first and separately. `requireFreshMfaIfEnrolled`
|
||||
// escalates to `requireFreshMfa`, which resolves through the session cookie
|
||||
// and has no Bearer fall-through; a token caller would therefore be told
|
||||
// "Not authenticated" by a route that had just authenticated them, which is
|
||||
// the obscure failure this branch exists to prevent. The consequence is
|
||||
// deliberate and documented (the native client cannot mint MANAGE): it is
|
||||
// said here in a code a client can branch on, so the app can send the person
|
||||
// to a browser instead of showing them a login error.
|
||||
if (access === "MANAGE") {
|
||||
if (auth.authMethod !== "cookie") {
|
||||
return apiError(
|
||||
"Manage access can only be offered from a browser session",
|
||||
403,
|
||||
{ errorCode: "sharing.invite.manage_browser_only" },
|
||||
);
|
||||
}
|
||||
await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
}
|
||||
|
||||
// Either column, case-insensitively — the same lookup the login form does,
|
||||
// so the name somebody signs in with is the name they can be invited by.
|
||||
@@ -142,19 +205,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
grantorId: user.id,
|
||||
granteeId: invitee.id,
|
||||
access,
|
||||
// The sections the owner ticked, or null for the entire record. The
|
||||
// schema is where an absent field becomes null, the same way it is where
|
||||
// an absent level becomes READ; the domain module takes both as required
|
||||
// arguments so neither is decided twice. It re-refuses an empty set, an
|
||||
// unknown key and a scope beside MANAGE underneath this line — the
|
||||
// schema is the legible refusal, that one is the floor.
|
||||
scope: scope ?? null,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
});
|
||||
|
||||
// The level is on the audit row, not only in the response: "who was given
|
||||
// what, and by whom" is the question this row exists to answer, and an
|
||||
// entry that recorded the invitation without its level would answer half
|
||||
// of it.
|
||||
// The level AND the sections are on the audit row, not only in the
|
||||
// response: "who was given what, and by whom" is the question this row
|
||||
// exists to answer, and an entry that recorded the invitation without
|
||||
// either would answer half of it. `null` is written as null rather than
|
||||
// omitted, because "the entire record" is an answer and a missing key
|
||||
// would read as a row from before the question was asked.
|
||||
await auditLog("sharing.grant.invited", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
grantId: grant.id,
|
||||
granteeId: invitee.id,
|
||||
access: grant.access,
|
||||
scope: scope ?? null,
|
||||
},
|
||||
ipAddress: getClientIp(request),
|
||||
}).catch(() => {});
|
||||
@@ -164,6 +237,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
grant_id: grant.id,
|
||||
access: grant.access,
|
||||
// The count rather than the keys: a dashboard wants to know how often
|
||||
// owners narrow, and which sections one household shares is not a
|
||||
// thing to spread across the observability pipeline. Zero means the
|
||||
// entire record.
|
||||
scope_sections: scope?.length ?? 0,
|
||||
expires: expiresAt !== null && expiresAt !== undefined,
|
||||
},
|
||||
});
|
||||
@@ -192,6 +270,16 @@ function grantErrorResponse(err: GrantError): Response {
|
||||
return apiError("That account already has access", 409, {
|
||||
errorCode: "sharing.invite.duplicate",
|
||||
});
|
||||
// The request shape refuses the same three cases earlier and more
|
||||
// legibly (a 422 naming the `scope` field). Reaching this arm means a
|
||||
// caller got past the schema with a scope the domain module will not
|
||||
// store — an empty set, a key outside the vocabulary, or any scope beside
|
||||
// MANAGE. Same status, its own code, so a client can tell "your sections
|
||||
// are wrong" from "that is your own account".
|
||||
case "invalid_scope":
|
||||
return apiError("Those sections cannot be shared", 422, {
|
||||
errorCode: "sharing.invite.invalid_scope",
|
||||
});
|
||||
default:
|
||||
return apiError("Invitation refused", 422, {
|
||||
errorCode: "sharing.invite.refused",
|
||||
|
||||
@@ -21,11 +21,37 @@
|
||||
* authorises nothing, and the resolver re-checks the grant on every request
|
||||
* that follows. A value stranded here by a revocation seconds later confers
|
||||
* exactly nothing.
|
||||
*
|
||||
* v1.37.0 — unfenced, deliberately, and the reason is the same one that makes
|
||||
* it an actor surface: this is the way back. A session whose context cannot be
|
||||
* proved must still be able to leave the record it may or may not be in, and
|
||||
* fencing the exit would make an unprovable context unrecoverable.
|
||||
*
|
||||
* What it takes instead is an optional `expectedEpoch`, which turns the
|
||||
* selector write into a compare-and-set. Two tabs pressing the switcher at once
|
||||
* both name the epoch they saw; one `UPDATE` matches, the loser gets zero rows
|
||||
* and the same 409 the fence raises, and the browser reconciles through
|
||||
* `/api/auth/me` before retrying. That is the monotonic ordering a server-side
|
||||
* pending lease was proposed for, in one statement, with no intermediate state
|
||||
* for a crashed initiator to strand.
|
||||
*
|
||||
* An ABSENT `expectedEpoch` keeps the unconditional write, and the two halves
|
||||
* of what that means are worth stating together:
|
||||
*
|
||||
* * It is reachable only by a bundle that predates the fence. The switcher is
|
||||
* rendered from `/api/auth/me` data, so a fence-aware client has adopted a
|
||||
* context before it can offer a switch at all — it never omits the field.
|
||||
* * It can therefore clobber a concurrent fenced switch. That is accepted:
|
||||
* the fenced tab's next request asserts an epoch the row no longer holds,
|
||||
* it takes the 409, and it reconciles to the truth; the pre-fence tab
|
||||
* reloads into a fence-aware bundle immediately afterwards. Fail-closed and
|
||||
* self-healing, bounded to the deploy window.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
RecordSessionChangedError,
|
||||
requireActorAuth,
|
||||
SharingAccessDeniedError,
|
||||
} from "@/lib/api-handler";
|
||||
@@ -39,7 +65,10 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { pointSessionAt } from "@/lib/sharing/acting-session";
|
||||
import {
|
||||
pointSessionAt,
|
||||
pointSessionAtIfUnchanged,
|
||||
} from "@/lib/sharing/acting-session";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { switchAccountSchema } from "@/lib/validations/account-sharing";
|
||||
|
||||
@@ -66,18 +95,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { accountId } = parsed.data;
|
||||
const { accountId, expectedEpoch } = parsed.data;
|
||||
const ip = getClientIp(request);
|
||||
|
||||
/**
|
||||
* Move the selector, conditionally when the caller named an epoch.
|
||||
*
|
||||
* Returns the record context the session ends up in, so the response can
|
||||
* publish it — this endpoint and `/api/auth/me` are the only two responses a
|
||||
* client may ADOPT a context from, and both carry it in the body. Every other
|
||||
* response's echo is used to validate and never to adopt.
|
||||
*
|
||||
* `null` on the unconditional arm, and that is the honest answer rather than
|
||||
* a gap: the epoch after an unconditional write is whatever the trigger
|
||||
* decided, this statement did not read it back, and a number computed here
|
||||
* would be wrong the moment the target equalled the current selector (the
|
||||
* trigger's `IS DISTINCT FROM` clause declines to move on a no-op write). A
|
||||
* caller that omitted `expectedEpoch` is by construction a bundle that would
|
||||
* not read the field anyway, and `/api/auth/me` states the truth on its next
|
||||
* boot.
|
||||
*/
|
||||
async function moveSelector(
|
||||
target: string | null,
|
||||
): Promise<{ epoch: number; scope: string | null } | null | "stale"> {
|
||||
if (expectedEpoch === undefined) {
|
||||
await pointSessionAt(auth.session.id, auth.user.id, target);
|
||||
return null;
|
||||
}
|
||||
const outcome = await pointSessionAtIfUnchanged(
|
||||
auth.session.id,
|
||||
auth.user.id,
|
||||
target,
|
||||
expectedEpoch,
|
||||
);
|
||||
if (outcome.kind === "stale") return "stale";
|
||||
return { epoch: outcome.epoch, scope: target };
|
||||
}
|
||||
|
||||
if (accountId === null) {
|
||||
await pointSessionAt(auth.session.id, auth.user.id, null);
|
||||
const moved = await moveSelector(null);
|
||||
if (moved === "stale") {
|
||||
annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
|
||||
throw new RecordSessionChangedError();
|
||||
}
|
||||
await auditLog("sharing.switch.off", {
|
||||
userId: auth.user.id,
|
||||
details: {},
|
||||
ipAddress: ip,
|
||||
}).catch(() => {});
|
||||
annotate({ action: { name: "sharing.switch.off" } });
|
||||
return apiSuccess({ actingAs: null });
|
||||
return apiSuccess({ actingAs: null, recordSession: moved });
|
||||
}
|
||||
|
||||
// The same predicate the resolver uses, on the same table, with no second
|
||||
@@ -105,7 +172,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true, username: true, displayName: true },
|
||||
});
|
||||
|
||||
await pointSessionAt(auth.session.id, auth.user.id, owner.id);
|
||||
const moved = await moveSelector(owner.id);
|
||||
if (moved === "stale") {
|
||||
annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
|
||||
throw new RecordSessionChangedError();
|
||||
}
|
||||
|
||||
await auditLog("sharing.switch.on", {
|
||||
userId: auth.user.id,
|
||||
@@ -125,5 +196,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
displayName: owner.displayName,
|
||||
access: grant.access,
|
||||
},
|
||||
recordSession: moved,
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,6 @@ vi.mock("@/lib/crypto", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
|
||||
|
||||
const ADMIN_USER_ID = "admin-1";
|
||||
const ADMIN_CTX = {
|
||||
@@ -97,6 +96,8 @@ interface DiagnosticEnvelope {
|
||||
configPresent: boolean;
|
||||
}>;
|
||||
recentPushAttempts: Array<{
|
||||
recordUserId: string;
|
||||
recipientUserId: string;
|
||||
eventType: string;
|
||||
channel: string;
|
||||
result: string;
|
||||
@@ -341,6 +342,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
|
||||
// mapping step normalises to an ISO string under `at`.
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([
|
||||
{
|
||||
recordUserId: "managed-record",
|
||||
recipientUserId: ADMIN_USER_ID,
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
channel: "APNS",
|
||||
result: "ok",
|
||||
@@ -348,6 +351,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
|
||||
createdAt: new Date("2026-05-22T09:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
recordUserId: ADMIN_USER_ID,
|
||||
recipientUserId: ADMIN_USER_ID,
|
||||
eventType: "MOOD_REMINDER",
|
||||
channel: "TELEGRAM",
|
||||
result: "error",
|
||||
@@ -362,6 +367,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.recentPushAttempts).toEqual([
|
||||
{
|
||||
recordUserId: "managed-record",
|
||||
recipientUserId: ADMIN_USER_ID,
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
channel: "APNS",
|
||||
result: "ok",
|
||||
@@ -369,6 +376,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
|
||||
at: "2026-05-22T09:00:00.000Z",
|
||||
},
|
||||
{
|
||||
recordUserId: ADMIN_USER_ID,
|
||||
recipientUserId: ADMIN_USER_ID,
|
||||
eventType: "MOOD_REMINDER",
|
||||
channel: "TELEGRAM",
|
||||
result: "error",
|
||||
@@ -386,28 +395,24 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
|
||||
|
||||
expect(vi.mocked(prisma.pushAttempt.findMany)).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
|
||||
where: { userId: string };
|
||||
where: { recipientUserId: string };
|
||||
orderBy: { createdAt: "desc" };
|
||||
take: number;
|
||||
};
|
||||
expect(args.where.userId).toBe(ADMIN_USER_ID);
|
||||
expect(args.where.recipientUserId).toBe(ADMIN_USER_ID);
|
||||
expect(args.orderBy).toEqual({ createdAt: "desc" });
|
||||
expect(args.take).toBe(20);
|
||||
});
|
||||
|
||||
it("excludes reminder-dedup bookkeeping rows so the last 20 are real sends", async () => {
|
||||
// The medication tick anchors its per-slot dedup in this table under a
|
||||
// sentinel channel. Letting those rows into the trailing window would
|
||||
// push the actual delivery attempts — the thing an operator opens this
|
||||
// endpoint for — off the list.
|
||||
it("reads recipient-scoped delivery attempts without bookkeeping sentinels", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
|
||||
|
||||
await GET();
|
||||
|
||||
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
|
||||
where: { channel?: { not?: string } };
|
||||
where: { recipientUserId?: string };
|
||||
};
|
||||
expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL });
|
||||
expect(args.where).toEqual({ recipientUserId: ADMIN_USER_ID });
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import type { ChannelType } from "@/lib/notifications/types";
|
||||
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -59,6 +58,8 @@ interface DiagnosticChannel {
|
||||
}
|
||||
|
||||
interface DiagnosticPushAttempt {
|
||||
recordUserId: string;
|
||||
recipientUserId: string;
|
||||
eventType: string;
|
||||
channel: string;
|
||||
result: string;
|
||||
@@ -170,18 +171,19 @@ export const GET = apiHandler(async () => {
|
||||
where: { userId: user.id },
|
||||
select: { type: true, enabled: true, config: true },
|
||||
}),
|
||||
// v1.4.49 — trailing 20 push attempts for the calling admin's own
|
||||
// account. The `(user_id, created_at DESC)` index on `push_attempts`
|
||||
// turns the orderBy + take into a single index scan, so the cost is
|
||||
// bounded regardless of the user's lifetime push volume.
|
||||
// Trailing delivery attempts for the calling account as recipient. The
|
||||
// recipient index keeps the bounded diagnostic read independent of the
|
||||
// account's lifetime notification volume.
|
||||
prisma.pushAttempt.findMany({
|
||||
// The reminder tick's per-slot dedup anchors live in this table under
|
||||
// a sentinel channel. They are bookkeeping, not delivery attempts,
|
||||
// and would push the real sends out of the trailing window.
|
||||
where: { userId: user.id, channel: { not: REMINDER_DEDUP_CHANNEL } },
|
||||
where: {
|
||||
recipientUserId: user.id,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
userId: true,
|
||||
recordUserId: true,
|
||||
recipientUserId: true,
|
||||
eventType: true,
|
||||
channel: true,
|
||||
result: true,
|
||||
@@ -215,6 +217,8 @@ export const GET = apiHandler(async () => {
|
||||
// `DiagnosticPushAttempt` interface above (the existing consumer
|
||||
// contract uses `at`, not `createdAt`).
|
||||
const recentPushAttempts: DiagnosticPushAttempt[] = pushAttempts.map((a) => ({
|
||||
recordUserId: a.recordUserId ?? a.userId,
|
||||
recipientUserId: a.recipientUserId ?? a.userId,
|
||||
eventType: a.eventType,
|
||||
channel: a.channel,
|
||||
result: a.result,
|
||||
|
||||
@@ -37,7 +37,6 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
@@ -140,15 +139,12 @@ describe("GET /api/admin/notifications/health — aggregate", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes reminder-dedup bookkeeping rows from the channel picture", async () => {
|
||||
// The medication tick anchors its per-slot dedup in the same table
|
||||
// under a sentinel channel. Those rows are not delivery attempts, so
|
||||
// counting them would misreport what the channels are doing.
|
||||
it("groups every delivery attempt in the selected window", async () => {
|
||||
await GET(req());
|
||||
const args = vi.mocked(prisma.pushAttempt.groupBy).mock.calls[0][0] as {
|
||||
where: { channel?: { not?: string } };
|
||||
where: { createdAt?: { gte?: Date } };
|
||||
};
|
||||
expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL });
|
||||
expect(args.where.createdAt?.gte).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("defaults the window to 24h and clamps out-of-range values", async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -59,12 +58,8 @@ export const GET = apiHandler(async (request) => {
|
||||
const [grouped, disabled] = await Promise.all([
|
||||
prisma.pushAttempt.groupBy({
|
||||
by: ["channel", "result"],
|
||||
// The reminder tick anchors its per-slot dedup in the same table
|
||||
// under a sentinel channel. Those rows record a decision, not a
|
||||
// delivery, so they stay out of the per-channel health picture.
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
channel: { not: REMINDER_DEDUP_CHANNEL },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
|
||||
@@ -12,9 +12,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -31,7 +35,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.allergy.findUnique({ where: { id } });
|
||||
@@ -49,12 +53,26 @@ export const GET = apiHandler(
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting an allergy entry, with the replaced fields
|
||||
// on the audit row.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
// The pre-image the audit row needs (C4). Encrypted columns stay out of
|
||||
// the select: the feed names them and never quotes them.
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
deletedAt: true,
|
||||
substance: true,
|
||||
category: true,
|
||||
type: true,
|
||||
severity: true,
|
||||
status: true,
|
||||
onsetAt: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
@@ -101,7 +119,34 @@ export const PATCH = apiHandler(
|
||||
await auditLog("allergy.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
// C4 — the replaced fields. The reaction and the note are named and
|
||||
// never quoted; both are encrypted at rest and the audit table is not a
|
||||
// second store for them.
|
||||
details: {
|
||||
allergyId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
substance: existing.substance,
|
||||
category: existing.category,
|
||||
type: existing.type,
|
||||
severity: existing.severity,
|
||||
status: existing.status,
|
||||
onsetAt: existing.onsetAt,
|
||||
},
|
||||
after: {
|
||||
substance: updated.substance,
|
||||
category: updated.category,
|
||||
type: updated.type,
|
||||
severity: updated.severity,
|
||||
status: updated.status,
|
||||
onsetAt: updated.onsetAt,
|
||||
},
|
||||
redacted: [
|
||||
...(entry.reaction !== undefined ? ["reaction"] : []),
|
||||
...(entry.note !== undefined ? ["note"] : []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -114,12 +159,27 @@ export const PATCH = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and a hard delete: the row goes and the audit row is
|
||||
// what is left. C3 below is the condition this admission rests on.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
// Widened for the audit row (C3): what was destroyed, in plaintext columns
|
||||
// only. `reactionEncrypted` and `notesEncrypted` are deliberately absent —
|
||||
// lifting a decrypted reaction into the audit table would give it a second
|
||||
// retention story and no rotation.
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
substance: true,
|
||||
category: true,
|
||||
type: true,
|
||||
severity: true,
|
||||
status: true,
|
||||
onsetAt: true,
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Allergy not found", 404);
|
||||
@@ -136,7 +196,23 @@ export const DELETE = apiHandler(
|
||||
await auditLog("allergy.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
// C3 — the allergen, its severity and its recorded date, and never the
|
||||
// encrypted reaction text.
|
||||
details: {
|
||||
allergyId: id,
|
||||
...destroyedDetails({
|
||||
model: "Allergy",
|
||||
id,
|
||||
label: existing.substance,
|
||||
effectiveAt: existing.onsetAt,
|
||||
extra: {
|
||||
category: existing.category,
|
||||
type: existing.type,
|
||||
severity: existing.severity,
|
||||
status: existing.status,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { toAllergyDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = allergyListQuerySchema.safeParse({
|
||||
@@ -67,37 +67,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy));
|
||||
|
||||
async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
// v1.36.x — the GET above is delegable and this is not, which is the
|
||||
// opposite of where the classification landed and worth the paragraph.
|
||||
// v1.37.0 — MANAGE, and reachable.
|
||||
//
|
||||
// Nothing about the row changed: it is still a plain statement about the
|
||||
// record's own body, still the single most useful thing a caregiver could
|
||||
// contribute, and the argument for admitting it still holds. What it does
|
||||
// not have is a caller. The only place in the product that posts here is the
|
||||
// allergy manager in Settings → Anamnese, and `/settings/*` is not a
|
||||
// shared-record destination — the shell shows the "not part of what was
|
||||
// shared" panel there, so no delegate can reach the form at any level.
|
||||
// Recording an allergy for somebody whose record you manage is the case the
|
||||
// level exists for; on a managed profile it is the guardian's ordinary act.
|
||||
// Additive, audited, and the row belongs to the record owner.
|
||||
//
|
||||
// An admitted write with no reachable surface is the one-ended change this
|
||||
// repository keeps rediscovering (CLAUDE.md, "A two-ended change carries
|
||||
// both ends"): the permission ships, the consumer is the follow-up, and
|
||||
// nothing in the gate notices because every other check proves the other
|
||||
// end. The frozen list is built the other way round on purpose — its own
|
||||
// actor-surface note says the rest "arrive as their own diffs; naming them
|
||||
// before they exist would freeze a guess."
|
||||
// The surface is Settings → Anamnese inside the record
|
||||
// (`src/components/settings/record-anamnesis-section.tsx`, reached through
|
||||
// `record-settings-section-gate.tsx` for any destination classified
|
||||
// `manage-writable`). This arm carried a long paragraph through v1.36.x
|
||||
// arguing that the write was deliberately unreachable and waiting for that
|
||||
// surface; the surface exists now, so the paragraph is gone rather than left
|
||||
// to send the next reader back to close what it opens.
|
||||
//
|
||||
// So this arm waits for the surface that would exercise it, and the two
|
||||
// land together. Choosing that surface is design work, not a fix: allergies
|
||||
// and family history have exactly one home today, that home is a personal
|
||||
// account surface a switch rightly closes, and bolting a second copy onto a
|
||||
// shared page would split one concept across two places. Re-admitting is
|
||||
// one line here plus one entry in `delegable-surface-guard.test.ts` plus the
|
||||
// paragraph that argues it — which is exactly the reviewed diff that guard
|
||||
// exists to force.
|
||||
//
|
||||
// The half that was always the point is untouched: a caregiver can still
|
||||
// READ the allergy list inside the record.
|
||||
const { user } = await requireAuth();
|
||||
// A READ or WRITE delegate reaches neither: the destination is not on their
|
||||
// Settings list, and this line refuses them with `sharing.access.denied`
|
||||
// regardless.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const payload = await readHealthProfileFacts(user.id);
|
||||
annotate({
|
||||
action: { name: "anamnesis.facts.get" },
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/lib/modules/gate";
|
||||
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -173,5 +174,23 @@ export const GET = apiHandler(async () => {
|
||||
// directly. Always present — an account with no grants gets an empty list,
|
||||
// not a missing field.
|
||||
accountAccess,
|
||||
// v1.37.0 — the record-session fence's bootstrap, and the only place a
|
||||
// browser can learn its context from scratch.
|
||||
//
|
||||
// `epoch` is the session row's selector counter; `scope` is the account the
|
||||
// session is pointed at, or null for its own record. The client adopts both
|
||||
// and attaches them to every subsequent same-origin request, so the server
|
||||
// can refuse a request formed under a context that has since moved. This
|
||||
// response and `POST /api/account/switch` are the only two a client may
|
||||
// ADOPT from — every other response carries the context in headers, and
|
||||
// those are used to validate a response, never to learn one.
|
||||
//
|
||||
// Null on the Bearer transport, which has no session row and no switch
|
||||
// state to be stale about. A native client neither sends nor receives the
|
||||
// fence headers; the frozen contract is untouched.
|
||||
// Derived by the fence rather than read off the context here: the columns
|
||||
// behind it have one reader by design, and a route that projected them
|
||||
// itself would be a second statement of what a record context is.
|
||||
recordSession: recordSessionForPayload(auth),
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { PUT, DELETE } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -78,6 +79,9 @@ function delReq(): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.37.0 — the 422 breadcrumb goes through `auditLog()` and the route
|
||||
// chains `.catch()` on it; `resetAllMocks` clears the factory's resolution.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (
|
||||
ops: Promise<unknown>[],
|
||||
) => Promise.all(ops)) as never);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
@@ -64,7 +65,7 @@ function serialiseBiomarker(row: {
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.biomarker.findFirst({ where: { id } });
|
||||
@@ -83,7 +84,12 @@ export const GET = apiHandler(
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and the closest call in this domain: a reference range
|
||||
// recolours every reading under it, so this is the one admitted edit whose
|
||||
// error is silent rather than visible. C4 on the audit row is what puts it
|
||||
// in the owner's feed. The DELETE below stays refused — it takes the
|
||||
// readings with it and files an id.
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.biomarker.findFirst({ where: { id } });
|
||||
@@ -107,17 +113,16 @@ export const PUT = apiHandler(
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.biomarker.update.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues, biomarkerId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("labs.biomarker.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, biomarkerId: id },
|
||||
}).catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.biomarker.update.invalid",
|
||||
});
|
||||
@@ -175,7 +180,32 @@ export const PUT = apiHandler(
|
||||
await auditLog("biomarker.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { biomarkerId: id },
|
||||
// C4, and this is the edit that needs it most: a reference range
|
||||
// recolours every reading under it, so the error is silent. The feed has
|
||||
// to show the range that was there before. The context note is named and
|
||||
// never quoted; it is encrypted on the row.
|
||||
details: {
|
||||
biomarkerId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
name: existing.name,
|
||||
unit: existing.unit,
|
||||
lowerBound: existing.lowerBound,
|
||||
upperBound: existing.upperBound,
|
||||
panel: existing.panel,
|
||||
hidden: existing.hidden,
|
||||
},
|
||||
after: {
|
||||
name: updated.name,
|
||||
unit: updated.unit,
|
||||
lowerBound: updated.lowerBound,
|
||||
upperBound: updated.upperBound,
|
||||
panel: updated.panel,
|
||||
hidden: updated.hidden,
|
||||
},
|
||||
redacted: d.context !== undefined ? ["context"] : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -71,7 +71,7 @@ function serialiseBiomarker(row: {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
|
||||
// Visible markers first, then hidden — the manager renders them in two
|
||||
// sections and the lab-entry pickers drop the hidden tail client-side.
|
||||
@@ -95,7 +95,7 @@ async function postBiomarker(request: NextRequest) {
|
||||
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
|
||||
// to the same resolved id, so a delegate adding a marker the owner already
|
||||
// tracks gets the ordinary 409 rather than a second copy.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -79,7 +79,7 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
//
|
||||
// The module gate below resolves against the record, so the surface exists
|
||||
// for a delegate only where the OWNER switched the Coach on.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -38,7 +38,7 @@ function effectiveBound(
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -29,7 +29,7 @@ import { createCustomMetricSchema } from "@/lib/validations/custom-metrics";
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const rows = await prisma.customMetric.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
|
||||
@@ -196,8 +196,11 @@ describe("POST /api/cycle/period — external-id stability floor", () => {
|
||||
});
|
||||
|
||||
it("accepts the real HealthKit id and reaches the write transaction", async () => {
|
||||
// v1.37.0 — the boundary transaction reports the anchors it moved so the
|
||||
// audit row can name them (C4); an empty set is the no-neighbour case.
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue({
|
||||
cycleId: "cycle-1",
|
||||
moved: {},
|
||||
} as never);
|
||||
const res = await postPeriod(
|
||||
req("/api/cycle/period", {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -43,7 +43,7 @@ const DEFAULT_FORWARD_DAYS = 180;
|
||||
const MAX_SPAN_DAYS = 400;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -16,7 +16,10 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft, audited, and it emits a sync tombstone, so the
|
||||
// cycle is reconstructable from its own row. The whole-domain eraser
|
||||
// (`DELETE /api/cycle/all`) stays the owner's at every level.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -29,7 +29,7 @@ const DEFAULT_LIMIT = 24;
|
||||
const REGULAR_MAD_THRESHOLD = 2;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -32,7 +33,8 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Editing one day of the cycle log.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -44,6 +46,8 @@ export const PATCH = apiHandler(
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
// v1.37.0 — the day the audit row names (C4).
|
||||
date: true,
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
@@ -156,7 +160,21 @@ export const PATCH = apiHandler(
|
||||
await auditLog("cycle.day-log.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: id },
|
||||
// C4 in this domain's shape: the date and the fields replaced, named
|
||||
// and never valued. See the sibling create route for why the values
|
||||
// stay off the audit table in this domain.
|
||||
details: {
|
||||
dayLogId: id,
|
||||
date: existing.date,
|
||||
...overwriteDetails({
|
||||
before: {},
|
||||
after: {},
|
||||
redacted: Object.entries(body)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key]) => key)
|
||||
.sort(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -173,7 +191,9 @@ export const PATCH = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft delete: the row stays and the sync feed carries
|
||||
// it as a tombstone.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -48,7 +49,7 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
|
||||
* row id. Gated + owner-scoped + `deletedAt:null`.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -75,8 +76,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess(row ? toCycleDayLogDTO(row) : null);
|
||||
});
|
||||
|
||||
/**
|
||||
* The day-log fields a write actually carried, for the audit row. Names only:
|
||||
* every one of these is either intimate or encrypted at rest, and the audit
|
||||
* table is not a second store for either.
|
||||
*/
|
||||
function replacedDayLogFields(entry: Record<string, unknown>): string[] {
|
||||
const carried = ["date", "source", "externalId", "loggedAt"];
|
||||
return Object.entries(entry)
|
||||
.filter(([key, value]) => value !== undefined && !carried.includes(key))
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
|
||||
// here; the gate below resolves `user.gender` against the RECORD, so the
|
||||
// module follows the record rather than the caller.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -139,7 +156,21 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
await auditLog("cycle.day-log.upsert", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: result.id, existed: result.existed },
|
||||
// C4 in this domain's shape: the date and the fields the write replaced,
|
||||
// named and never valued. This is the product's most sensitive domain and
|
||||
// §7.5's "no full-row snapshots" is at its sharpest here — a feed line
|
||||
// saying which day was rewritten is the whole requirement, and the values
|
||||
// behind it stay on the row where they are already encrypted.
|
||||
details: {
|
||||
dayLogId: result.id,
|
||||
existed: result.existed,
|
||||
date: entry.date,
|
||||
...overwriteDetails({
|
||||
before: {},
|
||||
after: {},
|
||||
redacted: result.existed ? replacedDayLogFields(entry) : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* is causal.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -50,7 +50,7 @@ const WINDOW_DAYS = 365;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
@@ -40,8 +41,28 @@ import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
|
||||
|
||||
/**
|
||||
* v1.37.0 — the anchors a boundary write moves, carried out of the
|
||||
* transaction so the audit row can name them (C4).
|
||||
*/
|
||||
interface MovedAnchors {
|
||||
priorCycleId?: string;
|
||||
/** Day keys, the shape this domain stores its dates in. */
|
||||
priorEndDateBefore?: string | null;
|
||||
priorEndDateAfter?: string | null;
|
||||
priorLengthBefore?: number | null;
|
||||
priorLengthAfter?: number | null;
|
||||
openedEndDateBefore?: string | null;
|
||||
openedEndDateAfter?: string | null;
|
||||
periodEndDateBefore?: string | null;
|
||||
periodEndDateAfter?: string | null;
|
||||
}
|
||||
|
||||
async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles,
|
||||
// which is the destructive part of it: a handful of dates move and nothing
|
||||
// else records what they were. The audit row below carries them (C4).
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -81,9 +102,23 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true, startDate: true },
|
||||
// v1.37.0 — `endDate` and `lengthDays` are read for the audit row:
|
||||
// this write overwrites both on the prior cycle and they are the
|
||||
// dates C4 exists to name.
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lengthDays: true,
|
||||
},
|
||||
});
|
||||
const moved: MovedAnchors = {};
|
||||
if (prior) {
|
||||
moved.priorCycleId = prior.id;
|
||||
moved.priorEndDateBefore = prior.endDate;
|
||||
moved.priorEndDateAfter = addDays(date, -1);
|
||||
moved.priorLengthBefore = prior.lengthDays;
|
||||
moved.priorLengthAfter = dayDiff(date, prior.startDate);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: prior.id },
|
||||
data: {
|
||||
@@ -117,6 +152,8 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (next) {
|
||||
moved.openedEndDateBefore = cycle.endDate;
|
||||
moved.openedEndDateAfter = addDays(next.startDate, -1);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: {
|
||||
@@ -126,23 +163,30 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
}
|
||||
return { cycleId: cycle.id as string | null };
|
||||
return { cycleId: cycle.id as string | null, moved };
|
||||
}
|
||||
|
||||
// `end`: stamp the current cycle's periodEndDate.
|
||||
const current = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { lte: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true },
|
||||
// v1.37.0 — `periodEndDate` for the audit row: this arm overwrites it.
|
||||
select: { id: true, periodEndDate: true },
|
||||
});
|
||||
if (!current) {
|
||||
return { cycleId: null };
|
||||
return { cycleId: null, moved: {} satisfies MovedAnchors };
|
||||
}
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: current.id },
|
||||
data: { periodEndDate: date, syncVersion: { increment: 1 } },
|
||||
});
|
||||
return { cycleId: current.id as string | null };
|
||||
return {
|
||||
cycleId: current.id as string | null,
|
||||
moved: {
|
||||
periodEndDateBefore: current.periodEndDate,
|
||||
periodEndDateAfter: date,
|
||||
} satisfies MovedAnchors,
|
||||
};
|
||||
});
|
||||
|
||||
if (txResult.cycleId === null) {
|
||||
@@ -188,7 +232,32 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
await auditLog("cycle.period.boundary", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { action, date, cycleId },
|
||||
// C4 — the dates this boundary moved on the neighbouring cycles. They are
|
||||
// the only unrecoverable part of the write: the day log it upserts is a
|
||||
// row, the re-anchoring is an overwrite of two columns on somebody else's
|
||||
// cycle history.
|
||||
details: {
|
||||
action,
|
||||
date,
|
||||
cycleId,
|
||||
...(txResult.moved.priorCycleId
|
||||
? { priorCycleId: txResult.moved.priorCycleId }
|
||||
: {}),
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
priorEndDate: txResult.moved.priorEndDateBefore,
|
||||
priorLengthDays: txResult.moved.priorLengthBefore,
|
||||
openedEndDate: txResult.moved.openedEndDateBefore,
|
||||
periodEndDate: txResult.moved.periodEndDateBefore,
|
||||
},
|
||||
after: {
|
||||
priorEndDate: txResult.moved.priorEndDateAfter,
|
||||
priorLengthDays: txResult.moved.priorLengthAfter,
|
||||
openedEndDate: txResult.moved.openedEndDateAfter,
|
||||
periodEndDate: txResult.moved.periodEndDateAfter,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { toCycleProfileDTO } from "@/lib/cycle/dto";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
Loaded 100 of 482 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user