mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge pull request #963 from MBombeck/release/v1.38.21
Release v1.38.21
This commit is contained in:
68 files changed
+4667
-307
No files matched your search
+11
-4
@@ -521,10 +521,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# LOG_SLOW_THRESHOLD_MS="3000"
|
||||
# LOG_INCLUDE_STACK="false"
|
||||
|
||||
# Optional log shipping to a Loki instance. LOKI_ENDPOINT is the push URL and
|
||||
# is what turns shipping on; the username/password pair is only needed when
|
||||
# your Loki sits behind basic auth. Leave all three unset and logs stay on
|
||||
# stdout for the Docker log driver to rotate.
|
||||
# Optional log shipping to a Loki instance. LOKI_ENDPOINT turns shipping on.
|
||||
# Either form works: the base URL (http://loki:3100) or the full push URL
|
||||
# (http://loki:3100/loki/api/v1/push). A Loki on your own network is fine,
|
||||
# private addresses and internal hostnames included; only link-local,
|
||||
# cloud-metadata and the unspecified address (0.0.0.0, ::) are refused. The
|
||||
# username/password pair is only needed when your Loki sits behind basic
|
||||
# auth. When a push fails (Loki down, wrong path, rejected credentials) the
|
||||
# app writes one line to stderr naming the Loki origin, the reason and how
|
||||
# many events were dropped, at most once every five minutes per reason; the
|
||||
# password never appears in it. Leave all three unset and logs stay on stdout
|
||||
# for the Docker log driver to rotate.
|
||||
# LOKI_ENDPOINT=""
|
||||
# LOKI_USERNAME=""
|
||||
# LOKI_PASSWORD=""
|
||||
|
||||
@@ -1,5 +1,95 @@
|
||||
# Changelog
|
||||
|
||||
## [1.38.21] — 2026-09-13
|
||||
|
||||
A channel test says why it failed, a Gotify server gets the message it
|
||||
expects, and Settings is back inside a profile you look after.
|
||||
|
||||
### Added
|
||||
|
||||
- **The webhook can send in Gotify's format.** Gotify expects the priority
|
||||
as a number and refused HealthLog's messages, which carry it as a word.
|
||||
Choose Gotify under Payload format on the webhook card and the message
|
||||
arrives the way Gotify wants it: routine events with sound, medication
|
||||
reminders as a banner on Gotify's Android app, urgent events at the top
|
||||
of the scale. Existing webhooks keep sending exactly what they sent
|
||||
before. The notifications guide now shows both message formats and how
|
||||
to set up Gotify, with the URL ending in `/message` and the token in the
|
||||
`X-Gotify-Key` header. Thanks to @sreeramachandramurthy for #947.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Pressing Test on the webhook, ntfy or email card now tells you what
|
||||
went wrong.** When the other end refused the message, the card said
|
||||
"Test failed" and nothing else, although the server knew the answer; one
|
||||
self-hoster had to dig through the container log to learn that their
|
||||
relay had answered 400. The card now names the cause, such as a refused
|
||||
token, a wrong address, a relay that rejected the message or a mail
|
||||
server that did not answer in time, with the status code. For webhook
|
||||
and ntfy it also quotes the first part of the relay's own error message,
|
||||
unless it looks like a token or contains one of your saved secrets. Real
|
||||
deliveries were never affected; only the test button hid the reason.
|
||||
The notification test in the admin area shows the same cause for each
|
||||
channel, and a relay refusing a test no longer counts as a server error
|
||||
in the operator's log. Thanks to @sreeramachandramurthy for #947.
|
||||
- **The hint under the webhook address no longer says it must be public.**
|
||||
A target on your own network works when the server operator lists it in
|
||||
`NOTIFICATION_PRIVATE_ORIGINS`, which has been possible since 1.38.17.
|
||||
|
||||
- **Settings is back in the menu while you are inside a profile you look
|
||||
after.** Switching into a managed profile removed Settings from the menu
|
||||
entirely, on the desktop and on the phone, even though the profile's own
|
||||
settings page existed and worked: which modules it tracks, its targets and
|
||||
where its notifications go. The only way there was typing the address.
|
||||
The menu now offers Settings inside that profile and opens the profile's
|
||||
own page. If someone gave you manage access to their own account, Settings
|
||||
opens their medical history, the one settings page that access covers.
|
||||
Notifications stays out of the menu while you are inside someone else's
|
||||
record. Thanks to @sreeramachandramurthy for asking in #939.
|
||||
- **The note on a manage invitation says when a second factor is needed.**
|
||||
It said offering manage access always asks for your second factor. It
|
||||
asks only if you have one set up; that it works only in a browser is true
|
||||
either way.
|
||||
|
||||
- **A different app on the address where HealthLog used to run now loads
|
||||
normally.** If you stopped HealthLog and started something else on the
|
||||
same address and port, HealthLog's offline worker in your browser kept
|
||||
answering: the other app got HealthLog's cached files and your changes to
|
||||
it never showed up, until you removed the worker by hand in the browser's
|
||||
developer tools. The worker now checks whether the page still comes from
|
||||
HealthLog, and when it does not, it clears its cache and removes itself.
|
||||
When HealthLog is only down, or a reverse proxy in front of it answers
|
||||
with an error, nothing changes and the offline page still appears. If
|
||||
you had push notifications switched on in that browser, the worker stays
|
||||
registered and keeps them, and only stops serving its cached files. Thanks
|
||||
to @el-abcd for describing this in #847.
|
||||
- **If you ship logs to Loki, a Loki on your own network works now, and a
|
||||
failed push says so.** Only public addresses were allowed, so a Loki on
|
||||
the same Docker network or on your LAN was refused without a word, and
|
||||
events were dropped. `LOKI_ENDPOINT` comes from your server environment,
|
||||
so the address you set there is now trusted as it is; link-local and
|
||||
cloud metadata addresses stay refused. It also takes either the base URL
|
||||
or the full push URL ending in `/loki/api/v1/push`; pasting the full URL
|
||||
used to break shipping just as quietly. When a push fails, the container
|
||||
log gets one line with the Loki address, the reason and how many events
|
||||
were lost, at most every five minutes per reason, and never the password.
|
||||
Thanks to @sreeramachandramurthy for the empty Grafana in #947.
|
||||
|
||||
### Changed
|
||||
|
||||
- **If you write against the API, a failed channel test answers 502 with
|
||||
the cause.** `POST /api/settings/webhook/test`, `/ntfy/test` and
|
||||
`/email/test` used to answer 500 for every refusal from the other end.
|
||||
They answer 502 now, with `meta.errorCode` (one of
|
||||
`credentials_rejected`, `endpoint_not_found`, `rate_limited`,
|
||||
`upstream_error`, `upstream_rejected`, `redirected`, `timeout`,
|
||||
`connection_failed`), `meta.upstreamStatus`, and for webhook and ntfy
|
||||
`meta.upstreamBody` with at most 200 characters of the relay's reply;
|
||||
email carries `meta.smtpCode` instead. The 422 for a private address
|
||||
that is not allowed is unchanged, and 500 is left for failures the
|
||||
server cannot name. `GET /api/settings/webhook` gains `format`
|
||||
(`generic` or `gotify`); a `PUT` without it keeps the stored value.
|
||||
|
||||
## [1.38.20] — 2026-09-11
|
||||
|
||||
Signing in no longer tells anybody which addresses have an account here.
|
||||
|
||||
+5
-4
@@ -345,10 +345,11 @@ x-healthlog-env: &healthlog_env
|
||||
LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}"
|
||||
LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}"
|
||||
LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}"
|
||||
# Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the push
|
||||
# URL to turn it on; the username/password pair is only needed when your
|
||||
# Loki is behind basic auth. Leave all three unset and logs stay on stdout
|
||||
# for the Docker log driver to rotate.
|
||||
# Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the base
|
||||
# URL or the full push URL to turn it on; a Loki on your own network works.
|
||||
# The username/password pair is only needed when your Loki is behind basic
|
||||
# auth. Push failures show up as a rate-limited line on stderr. Leave all
|
||||
# three unset and logs stay on stdout for the Docker log driver to rotate.
|
||||
LOKI_ENDPOINT: "${LOKI_ENDPOINT:-}"
|
||||
LOKI_USERNAME: "${LOKI_USERNAME:-}"
|
||||
LOKI_PASSWORD: "${LOKI_PASSWORD:-}"
|
||||
|
||||
+83
-11
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.38.20
|
||||
version: 1.38.21
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -12354,7 +12354,8 @@ paths:
|
||||
|
||||
|
||||
Refuses with 400 when the instance has no SMTP transport, when the account has no email channel, or when the
|
||||
channel carries no recipient — three distinguishable prose messages on one status. A transport failure is a 500.
|
||||
channel carries no recipient — three distinguishable prose messages on one status. A failure at the mail server
|
||||
or on the way to it is a 502 that names its cause; only a failure the server cannot name is a 500.
|
||||
responses:
|
||||
"200":
|
||||
description: The provider accepted the message. Not proof it was delivered.
|
||||
@@ -12365,6 +12366,22 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"500":
|
||||
description: The send failed in a way the server could not name. An internal fault, not a statement about the mail server.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"502":
|
||||
description: "The mail server, or the way to it, failed. `meta.errorCode` names the cause: `credentials_rejected` (the
|
||||
server refused the SMTP login), `upstream_rejected` (a permanent 5xx rejection, e.g. an unknown mailbox or
|
||||
relay denied, or an envelope or message refused without a reply code), `upstream_error` (a temporary 4xx
|
||||
rejection), `timeout`, or `connection_failed` (including a TLS handshake or protocol failure).
|
||||
`meta.smtpCode` carries the SMTP reply code when the mail server sent one."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/settings/ntfy/test:
|
||||
post:
|
||||
tags:
|
||||
@@ -12372,9 +12389,10 @@ paths:
|
||||
summary: Send a test message to the saved ntfy topic
|
||||
description: Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per
|
||||
5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server
|
||||
URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode`
|
||||
= `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`;
|
||||
the test and the scheduled delivery take the same decision.
|
||||
URL or topic. A failure at the ntfy server or on the way to it is a 502 that carries the upstream status and,
|
||||
when safe, what the server answered; only a failure the server cannot name is a 500. A server on a private
|
||||
network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its
|
||||
exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.
|
||||
responses:
|
||||
"200":
|
||||
description: The provider accepted the message. Not proof it was delivered.
|
||||
@@ -12392,6 +12410,25 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a5
|
||||
"500":
|
||||
description: The send failed in a way the server could not name. An internal fault, not a statement about the ntfy server.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"502":
|
||||
description: "The ntfy server, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the
|
||||
other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found`
|
||||
(404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or
|
||||
`connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and
|
||||
`meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it
|
||||
is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved auth
|
||||
token, the topic, or a credential in the server URL (userinfo, a query value, a path segment of 16 or more
|
||||
characters), literal, JSON-escaped or percent-encoded."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/settings/telegram/test:
|
||||
post:
|
||||
tags:
|
||||
@@ -12421,11 +12458,12 @@ paths:
|
||||
- Notifications
|
||||
summary: Send a test payload to the saved webhook
|
||||
description: Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per
|
||||
user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a
|
||||
500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal
|
||||
host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless
|
||||
the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take
|
||||
the same decision.
|
||||
user. Refuses with 400 when no webhook channel is saved or the saved config has no URL. A failure at the relay
|
||||
or on the way to it is a 502 that carries the upstream status and, when safe, what the relay answered; only a
|
||||
failure the server cannot name is a 500. The URL was checked against the SSRF floor when it was saved, so a test
|
||||
cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` =
|
||||
`private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the
|
||||
test and the scheduled delivery take the same decision.
|
||||
responses:
|
||||
"200":
|
||||
description: The provider accepted the message. Not proof it was delivered.
|
||||
@@ -12443,6 +12481,25 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a5
|
||||
"500":
|
||||
description: The send failed in a way the server could not name. An internal fault, not a statement about the relay.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"502":
|
||||
description: "The relay, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end
|
||||
refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found`
|
||||
(404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or
|
||||
`connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and
|
||||
`meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it
|
||||
is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved header
|
||||
value (and its token part), the URL's userinfo, a query value, or a path segment of 16 or more characters
|
||||
(the token in a Discord or Slack webhook URL), literal, JSON-escaped or percent-encoded."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/settings/data:
|
||||
delete:
|
||||
tags:
|
||||
@@ -25863,6 +25920,14 @@ components:
|
||||
maxLength: 500
|
||||
- type: string
|
||||
const: ""
|
||||
format:
|
||||
description: Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message`
|
||||
expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved
|
||||
before the choice existed.
|
||||
type: string
|
||||
enum:
|
||||
- generic
|
||||
- gotify
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
@@ -25872,7 +25937,7 @@ components:
|
||||
re-checked at dispatch time; a private origin passes both only when the operator listed it in
|
||||
`NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason
|
||||
as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this
|
||||
endpoint."
|
||||
endpoint. An omitted `format` keeps the stored body shape."
|
||||
DeleteAccountRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -57447,11 +57512,18 @@ components:
|
||||
hasHeaderValue:
|
||||
type: boolean
|
||||
description: Whether a header value (the shared secret) is stored. The value itself is never returned.
|
||||
format:
|
||||
type: string
|
||||
enum:
|
||||
- generic
|
||||
- gotify
|
||||
description: Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice.
|
||||
required:
|
||||
- enabled
|
||||
- url
|
||||
- headerName
|
||||
- hasHeaderValue
|
||||
- format
|
||||
additionalProperties: false
|
||||
description: Stored generic-webhook channel config, with the secret header value reduced to a presence flag.
|
||||
PutWebhookSettingsResponse:
|
||||
|
||||
@@ -191,7 +191,7 @@ and surface on the lock screen.
|
||||
|
||||
The generic webhook POSTs every enabled event as JSON to a URL you own —
|
||||
wire it into your own automation (Home Assistant, n8n, a Discord/Slack
|
||||
relay, whatever routes the payload). You supply a public URL and,
|
||||
relay, whatever routes the payload). You supply the URL and,
|
||||
optionally, one custom header (e.g. an auth token).
|
||||
|
||||
1. In HealthLog open `/settings/notifications`, fill the Webhook card's
|
||||
@@ -207,6 +207,80 @@ Home Assistant on your own network needs the operator grant described in
|
||||
the next section. The webhook carries the event's urgency flag so you can
|
||||
route urgent events differently.
|
||||
|
||||
### What the webhook sends
|
||||
|
||||
Every event is one `POST` with `Content-Type: application/json` and the
|
||||
one header you configured. It goes to the URL exactly as you saved it:
|
||||
nothing is added to the path or the query. The body depends on the
|
||||
**Payload format** you choose on the card. The examples below are what the
|
||||
Test button sends.
|
||||
|
||||
**Generic JSON** is the default, and what every webhook saved before the
|
||||
choice existed keeps sending:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "HealthLog Test",
|
||||
"message": "HealthLog: Connection successful! Webhook notifications are active.",
|
||||
"eventType": "SYSTEM_ALERT",
|
||||
"priority": "default"
|
||||
}
|
||||
```
|
||||
|
||||
- `title` and `message` are plain text. HTML tags are stripped, and routine
|
||||
reminders lose their decorative emoji.
|
||||
- `eventType` is the event name, for routing rules. With discreet cycle
|
||||
notifications on, a cycle event arrives as `reminder`, and its title and
|
||||
message are masked as they are on every other channel.
|
||||
- `priority` is a word: `urgent` for an event flagged urgent, `high` for
|
||||
medication reminders, `default` for everything else.
|
||||
|
||||
**Gotify** sends the body Gotify's `POST /message` endpoint expects:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "HealthLog Test",
|
||||
"message": "HealthLog: Connection successful! Webhook notifications are active.",
|
||||
"priority": 5,
|
||||
"extras": {
|
||||
"client::display": { "contentType": "text/plain" },
|
||||
"healthlog::event": { "type": "SYSTEM_ALERT" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `priority` is an integer: `5` for most events, `8` for medication
|
||||
reminders, `10` for urgent events. The Gotify Android app plays a sound
|
||||
from 4 and shows a heads-up banner from 8, so routine events are audible
|
||||
and reminders pop up on screen.
|
||||
- `extras` tells Gotify clients to show the message as plain text and
|
||||
carries the event name, which is `reminder` for a discreet cycle event.
|
||||
|
||||
The generic body does not work with Gotify. Gotify reads `priority` as a
|
||||
number, so the word `default` makes it answer `400 Bad Request`.
|
||||
|
||||
### Sending to Gotify
|
||||
|
||||
1. In Gotify, create an application and copy its token.
|
||||
2. On the Webhook card, set **URL** to your Gotify address ending in
|
||||
`/message`, for example `https://gotify.example.com/message`.
|
||||
3. Set **Header name** to `X-Gotify-Key` and **Header value** to the
|
||||
application token. Gotify also accepts the token as `?token=` on the URL,
|
||||
but in a header it stays out of proxy access logs.
|
||||
4. Set **Payload format** to **Gotify**, save, and press Test.
|
||||
|
||||
If the test fails, the card shows the status Gotify answered and, when it is
|
||||
short, Gotify's own error text. A `401` means the token is wrong. A `400`
|
||||
means Gotify could not read the body; check that the format is set to
|
||||
Gotify. A Gotify on your own network also needs the operator grant below.
|
||||
|
||||
The detail reaches the card inside the server's `502` response. A reverse
|
||||
proxy that replaces error pages for 5xx responses, such as Traefik's
|
||||
`errors` middleware or nginx with `proxy_intercept_errors on`, swaps that
|
||||
response for its own page, and the card can then only say "Test failed".
|
||||
Exclude the `/api/` paths from the error-page rule to keep the detail. This
|
||||
applies to the ntfy and email tests too.
|
||||
|
||||
## Notification targets on a private network
|
||||
|
||||
Every webhook and ntfy target is resolved at send time and refused when
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
* control is absent and the page says why, in a sentence, where the
|
||||
* control would have been.
|
||||
*
|
||||
* A third journey reaches the profile's Settings from the navigation (#939).
|
||||
* The Settings shell has always listed a managed profile's own configuration,
|
||||
* and the section gate has always admitted it, but no navigation led there:
|
||||
* the utility entries disappeared for every shared record. It walks the
|
||||
* desktop sidebar and the mobile user menu to the Modules card.
|
||||
*
|
||||
* Modelled on `v137-sharing-managed-profiles.spec.ts`; the switch helpers are
|
||||
* the same ones, for the same reasons written there.
|
||||
*/
|
||||
@@ -137,6 +143,73 @@ test.describe.serial("a guardian's controls inside a managed profile", () => {
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("reaches the profile's Modules settings from the navigation on both widths", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openRecord(page, managed.username);
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "managed");
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
const accountId = await banner.getAttribute("data-account-id");
|
||||
expect(accountId).toBeTruthy();
|
||||
|
||||
// The banner renders from the same `/api/auth/me` answer the navigation
|
||||
// reads, and the shell paints nothing before it resolves. Waiting on it
|
||||
// above is what makes every presence and absence below a statement about
|
||||
// the loaded shell rather than about the hydration gate.
|
||||
const sidebar = page.locator('aside[aria-label="Sidebar"]');
|
||||
const settings = sidebar.locator('[data-slot="nav-settings-link"]');
|
||||
await expect(settings).toHaveCount(1);
|
||||
await expect(settings).toHaveAttribute("href", "/settings/account");
|
||||
await expect(sidebar.locator('a[href="/notifications"]')).toHaveCount(0);
|
||||
|
||||
await settings.click();
|
||||
await expect(page).toHaveURL(/\/settings\/account$/);
|
||||
await expect(
|
||||
page.locator('[data-record-settings-family="profile"]'),
|
||||
).toHaveAttribute("data-record-id", accountId as string, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page
|
||||
.locator('a[href="/settings/modules"]')
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/settings\/modules$/);
|
||||
const modules = page.locator(
|
||||
'[data-record-settings-family="modules"][aria-busy="false"]',
|
||||
);
|
||||
await expect(modules).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modules).toHaveAttribute(
|
||||
"data-record-id",
|
||||
accountId as string,
|
||||
);
|
||||
|
||||
// The phone layout: the utilities live in the top-bar user menu there.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
await expect(banner).toBeVisible({ timeout: 30_000 });
|
||||
const topBar = page.locator('[data-slot="top-bar"]');
|
||||
await expect(topBar).toBeVisible();
|
||||
await topBar.getByRole("button", { name: "User menu" }).click();
|
||||
const menu = page.getByRole("menu");
|
||||
const menuSettings = menu.locator('[data-slot="nav-settings-link"]');
|
||||
await expect(menuSettings).toHaveAttribute("href", "/settings/account");
|
||||
// The menu is open and populated, so this absence is the menu's answer.
|
||||
await expect(menu.locator('a[href="/notifications"]')).toHaveCount(0);
|
||||
await menuSettings.click();
|
||||
await expect(page).toHaveURL(/\/settings\/account$/);
|
||||
await expect(
|
||||
page.locator('[data-record-settings-family="profile"]'),
|
||||
).toHaveAttribute("data-record-id", accountId as string, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("withholds the vault's upload control and says why", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
+8
-3
@@ -5587,9 +5587,13 @@
|
||||
"webhookDescription": "Benachrichtigungen per POST an eine beliebige URL — Gotify, Discord, Slack, Matrix oder Home Assistant.",
|
||||
"webhookEnable": "Webhook aktivieren",
|
||||
"webhookUrl": "Webhook-URL",
|
||||
"webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.",
|
||||
"webhookUrlHint": "Die vollständige URL für den POST. Eine Adresse im privaten Netz muss der Betreiber des Servers freigeben.",
|
||||
"webhookHeaderName": "Header-Name (optional)",
|
||||
"webhookHeaderValue": "Header-Wert (optional)",
|
||||
"webhookFormat": "Nachrichtenformat",
|
||||
"webhookFormatGeneric": "Generisches JSON",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Nutze die URL, die auf /message endet, und trage das App-Token als Header X-Gotify-Key ein.",
|
||||
"email": "E-Mail",
|
||||
"emailDescription": "Warnungen und Erinnerungen per E-Mail empfangen.",
|
||||
"emailEnable": "E-Mail aktivieren",
|
||||
@@ -6136,6 +6140,7 @@
|
||||
"rate_limited": "Vom Upstream rate-limitiert",
|
||||
"timeout": "Request hat das Zeitlimit überschritten",
|
||||
"upstream_error": "Upstream hat einen Fehler gemeldet",
|
||||
"upstream_rejected": "Die Gegenstelle hat die Nachricht abgelehnt",
|
||||
"connection_failed": "Verbindung fehlgeschlagen",
|
||||
"not_configured": "Nicht konfiguriert",
|
||||
"url_not_public": "URL ist kein öffentlicher Endpunkt",
|
||||
@@ -10389,7 +10394,7 @@
|
||||
"accessManageLabel": "Darf verwalten",
|
||||
"accessManageCapability": "Sie kann Einträge anlegen, ändern und löschen, auch solche, die du selbst erfasst hast.",
|
||||
"accessManageDetail": "Sie kann außerdem 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.",
|
||||
"manageStepUpNote": "Das geht nur im Browser, und falls du einen zweiten Faktor eingerichtet hast, wird er abgefragt.",
|
||||
"scopeLegend": "Was die Person öffnen darf",
|
||||
"scopeAllLabel": "Gesamte Akte",
|
||||
"scopeAllCapability": "Jeder Teil davon, so wie das Teilen bisher funktioniert hat.",
|
||||
@@ -10413,7 +10418,7 @@
|
||||
"errorRateLimited": "Zu viele Einladungen. Bitte später 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.",
|
||||
"errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben.",
|
||||
"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."
|
||||
},
|
||||
|
||||
+8
-3
@@ -5587,9 +5587,13 @@
|
||||
"webhookDescription": "POST notifications to any URL — Gotify, Discord, Slack, Matrix, or Home Assistant.",
|
||||
"webhookEnable": "Enable webhook",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlHint": "The full URL to POST to. Must be publicly reachable.",
|
||||
"webhookUrlHint": "The full URL to POST to. An address on a private network needs approval from the server operator.",
|
||||
"webhookHeaderName": "Header name (optional)",
|
||||
"webhookHeaderValue": "Header value (optional)",
|
||||
"webhookFormat": "Payload format",
|
||||
"webhookFormatGeneric": "Generic JSON",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Use the URL ending in /message and put the application token in the header X-Gotify-Key.",
|
||||
"email": "Email",
|
||||
"emailDescription": "Receive alerts and reminders by email.",
|
||||
"emailEnable": "Enable email",
|
||||
@@ -6136,6 +6140,7 @@
|
||||
"rate_limited": "Rate-limited by the upstream",
|
||||
"timeout": "Request timed out",
|
||||
"upstream_error": "Upstream returned an error",
|
||||
"upstream_rejected": "The other end refused the message",
|
||||
"connection_failed": "Connection failed",
|
||||
"not_configured": "Not configured",
|
||||
"url_not_public": "URL is not a public endpoint",
|
||||
@@ -10389,7 +10394,7 @@
|
||||
"accessManageLabel": "Can manage",
|
||||
"accessManageCapability": "They can add, change and remove entries, including ones you entered yourself.",
|
||||
"accessManageDetail": "They can also read the insights generated from your record. 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.",
|
||||
"manageStepUpNote": "Offering this only works in a browser, and asks for your second factor if you have one.",
|
||||
"scopeLegend": "What they can open",
|
||||
"scopeAllLabel": "Entire record",
|
||||
"scopeAllCapability": "Every part of it, the way sharing has always worked.",
|
||||
@@ -10413,7 +10418,7 @@
|
||||
"errorRateLimited": "Too many invitations. Please try again later.",
|
||||
"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.",
|
||||
"errorManageBrowserOnly": "Manage access can only be offered in a browser.",
|
||||
"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."
|
||||
},
|
||||
|
||||
+8
-3
@@ -5587,9 +5587,13 @@
|
||||
"webhookDescription": "Envía notificaciones por POST a cualquier URL: Gotify, Discord, Slack, Matrix o Home Assistant.",
|
||||
"webhookEnable": "Activar webhook",
|
||||
"webhookUrl": "URL del webhook",
|
||||
"webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.",
|
||||
"webhookUrlHint": "La URL completa para el POST. Una dirección de una red privada necesita la aprobación del administrador del servidor.",
|
||||
"webhookHeaderName": "Nombre de cabecera (opcional)",
|
||||
"webhookHeaderValue": "Valor de cabecera (opcional)",
|
||||
"webhookFormat": "Formato del mensaje",
|
||||
"webhookFormatGeneric": "JSON genérico",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Usa la URL que termina en /message y pon el token de la aplicación en la cabecera X-Gotify-Key.",
|
||||
"email": "Correo electrónico",
|
||||
"emailDescription": "Recibe alertas y recordatorios por correo electrónico.",
|
||||
"emailEnable": "Activar correo electrónico",
|
||||
@@ -6136,6 +6140,7 @@
|
||||
"rate_limited": "Limitado por el servidor remoto",
|
||||
"timeout": "La solicitud agotó el tiempo de espera",
|
||||
"upstream_error": "El servidor remoto devolvió un error",
|
||||
"upstream_rejected": "El servidor remoto rechazó el mensaje",
|
||||
"connection_failed": "Falló la conexión",
|
||||
"not_configured": "No configurado",
|
||||
"url_not_public": "La URL no es un endpoint público",
|
||||
@@ -10389,7 +10394,7 @@
|
||||
"accessManageLabel": "Puede gestionar",
|
||||
"accessManageCapability": "Puede añadir, cambiar y eliminar entradas, incluidas las que has escrito tú.",
|
||||
"accessManageDetail": "También puede leer los análisis generados a partir de tu ficha. 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.",
|
||||
"manageStepUpNote": "Solo se puede hacer en un navegador, y pide tu segundo factor si tienes uno.",
|
||||
"scopeLegend": "Qué puede abrir",
|
||||
"scopeAllLabel": "Todo el historial",
|
||||
"scopeAllCapability": "Todas sus partes, tal y como ha funcionado siempre el uso compartido.",
|
||||
@@ -10413,7 +10418,7 @@
|
||||
"errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.",
|
||||
"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.",
|
||||
"errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador.",
|
||||
"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."
|
||||
},
|
||||
|
||||
+8
-3
@@ -5587,9 +5587,13 @@
|
||||
"webhookDescription": "Envoyer les notifications par POST vers n’importe quelle URL — Gotify, Discord, Slack, Matrix ou Home Assistant.",
|
||||
"webhookEnable": "Activer le webhook",
|
||||
"webhookUrl": "URL du webhook",
|
||||
"webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.",
|
||||
"webhookUrlHint": "L’URL complète pour le POST. Une adresse sur un réseau privé doit être autorisée par l’administrateur du serveur.",
|
||||
"webhookHeaderName": "Nom d’en-tête (facultatif)",
|
||||
"webhookHeaderValue": "Valeur d’en-tête (facultatif)",
|
||||
"webhookFormat": "Format du message",
|
||||
"webhookFormatGeneric": "JSON générique",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Utilisez l’URL se terminant par /message et placez le jeton de l’application dans l’en-tête X-Gotify-Key.",
|
||||
"email": "E-mail",
|
||||
"emailDescription": "Recevoir les alertes et les rappels par e-mail.",
|
||||
"emailEnable": "Activer l’e-mail",
|
||||
@@ -6136,6 +6140,7 @@
|
||||
"rate_limited": "Limité par le service en amont",
|
||||
"timeout": "Délai de la requête dépassé",
|
||||
"upstream_error": "Le service en amont a renvoyé une erreur",
|
||||
"upstream_rejected": "Le service distant a refusé le message",
|
||||
"connection_failed": "Échec de la connexion",
|
||||
"not_configured": "Non configuré",
|
||||
"url_not_public": "L’URL n’est pas un point de terminaison public",
|
||||
@@ -10389,7 +10394,7 @@
|
||||
"accessManageLabel": "Peut gérer",
|
||||
"accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées, y compris celles que vous avez saisies vous-même.",
|
||||
"accessManageDetail": "Elle peut aussi lire les analyses tirées de votre dossier. 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.",
|
||||
"manageStepUpNote": "Cette offre n'est possible que dans un navigateur, et demande votre deuxième facteur si vous en avez un.",
|
||||
"scopeLegend": "Ce que cette personne peut ouvrir",
|
||||
"scopeAllLabel": "Dossier complet",
|
||||
"scopeAllCapability": "Toutes ses parties, comme le partage a toujours fonctionné.",
|
||||
@@ -10413,7 +10418,7 @@
|
||||
"errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.",
|
||||
"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.",
|
||||
"errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur.",
|
||||
"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."
|
||||
},
|
||||
|
||||
+8
-3
@@ -5587,9 +5587,13 @@
|
||||
"webhookDescription": "Invia le notifiche via POST a qualsiasi URL — Gotify, Discord, Slack, Matrix o Home Assistant.",
|
||||
"webhookEnable": "Attiva webhook",
|
||||
"webhookUrl": "URL del webhook",
|
||||
"webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.",
|
||||
"webhookUrlHint": "L’URL completo per il POST. Un indirizzo su una rete privata deve essere autorizzato dall’amministratore del server.",
|
||||
"webhookHeaderName": "Nome intestazione (opzionale)",
|
||||
"webhookHeaderValue": "Valore intestazione (opzionale)",
|
||||
"webhookFormat": "Formato del messaggio",
|
||||
"webhookFormatGeneric": "JSON generico",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Usa l’URL che termina con /message e inserisci il token dell’applicazione nell’intestazione X-Gotify-Key.",
|
||||
"email": "Email",
|
||||
"emailDescription": "Ricevi avvisi e promemoria via email.",
|
||||
"emailEnable": "Attiva email",
|
||||
@@ -6136,6 +6140,7 @@
|
||||
"rate_limited": "Limitato dal servizio upstream",
|
||||
"timeout": "Richiesta scaduta",
|
||||
"upstream_error": "Il servizio upstream ha restituito un errore",
|
||||
"upstream_rejected": "Il servizio remoto ha rifiutato il messaggio",
|
||||
"connection_failed": "Connessione non riuscita",
|
||||
"not_configured": "Non configurato",
|
||||
"url_not_public": "L’URL non è un endpoint pubblico",
|
||||
@@ -10389,7 +10394,7 @@
|
||||
"accessManageLabel": "Può gestire",
|
||||
"accessManageCapability": "Può aggiungere, modificare ed eliminare voci, comprese quelle inserite da te.",
|
||||
"accessManageDetail": "Può anche leggere le analisi generate dalla tua cartella. 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.",
|
||||
"manageStepUpNote": "Si può fare solo da browser, e viene richiesto il tuo secondo fattore se ne hai uno.",
|
||||
"scopeLegend": "Che cosa può aprire",
|
||||
"scopeAllLabel": "Intera cartella",
|
||||
"scopeAllCapability": "Ogni sua parte, come la condivisione ha sempre funzionato.",
|
||||
@@ -10413,7 +10418,7 @@
|
||||
"errorRateLimited": "Troppi inviti. Riprova più tardi.",
|
||||
"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.",
|
||||
"errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser.",
|
||||
"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."
|
||||
},
|
||||
|
||||
+8
-3
@@ -5587,9 +5587,13 @@
|
||||
"webhookDescription": "Gotify, Discord, Slack, Matrix, Home Assistant 등 어떤 URL로든 알림을 POST해요.",
|
||||
"webhookEnable": "웹훅 켜기",
|
||||
"webhookUrl": "웹훅 URL",
|
||||
"webhookUrlHint": "POST를 보낼 전체 URL이에요. 외부에서 접근할 수 있어야 해요.",
|
||||
"webhookUrlHint": "POST를 보낼 전체 URL이에요. 사설 네트워크 주소는 서버 운영자가 허용해야 해요.",
|
||||
"webhookHeaderName": "헤더 이름 (선택)",
|
||||
"webhookHeaderValue": "헤더 값 (선택)",
|
||||
"webhookFormat": "메시지 형식",
|
||||
"webhookFormatGeneric": "일반 JSON",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.",
|
||||
"email": "이메일",
|
||||
"emailDescription": "경고와 알림을 이메일로 받아요.",
|
||||
"emailEnable": "이메일 켜기",
|
||||
@@ -6136,6 +6140,7 @@
|
||||
"rate_limited": "상대 서버에서 요청 수를 제한했어요",
|
||||
"timeout": "요청 시간이 초과됐어요",
|
||||
"upstream_error": "상대 서버가 오류를 반환했어요",
|
||||
"upstream_rejected": "상대 서버가 메시지를 거부했어요",
|
||||
"connection_failed": "연결에 실패했어요",
|
||||
"not_configured": "설정되지 않았어요",
|
||||
"url_not_public": "URL이 공개 엔드포인트가 아니에요",
|
||||
@@ -10389,7 +10394,7 @@
|
||||
"accessManageLabel": "관리 가능",
|
||||
"accessManageCapability": "내가 직접 입력한 것까지 포함해 기록을 추가·수정·삭제할 수 있어요.",
|
||||
"accessManageDetail": "내 기록에서 만들어진 인사이트도 볼 수 있어요. 내 로그인, 보안 설정, 연동, 다른 사람의 권한은 바꿀 수 없어요. 언제든 이 권한을 끝낼 수 있어요.",
|
||||
"manageStepUpNote": "이 권한을 주려면 2단계 인증이 필요해서 브라우저에서만 할 수 있어요.",
|
||||
"manageStepUpNote": "브라우저에서만 할 수 있고, 2단계 인증을 설정했다면 인증을 요청해요.",
|
||||
"scopeLegend": "상대가 열 수 있는 범위",
|
||||
"scopeAllLabel": "기록 전체",
|
||||
"scopeAllCapability": "기록의 모든 부분. 지금까지의 공유 방식 그대로예요.",
|
||||
@@ -10413,7 +10418,7 @@
|
||||
"errorRateLimited": "초대가 너무 많아요. 잠시 후 다시 시도해 주세요.",
|
||||
"errorFailed": "초대를 보내지 못했어요. 다시 시도해 주세요.",
|
||||
"errorScope": "그 섹션들은 그렇게 공유할 수 없어요. 섹션을 하나 이상 고르거나 기록 전체를 공유하세요.",
|
||||
"errorManageBrowserOnly": "관리 권한은 2단계 인증이 필요해서 브라우저에서만 줄 수 있어요.",
|
||||
"errorManageBrowserOnly": "관리 권한은 브라우저에서만 줄 수 있어요.",
|
||||
"errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorOffline": "서버에 닿지 못했어요. 초대 내용은 그대로 있으니 연결을 확인하고 다시 시도해 주세요."
|
||||
},
|
||||
|
||||
+8
-3
@@ -5587,9 +5587,13 @@
|
||||
"webhookDescription": "Wysyłaj powiadomienia metodą POST na dowolny URL — Gotify, Discord, Slack, Matrix lub Home Assistant.",
|
||||
"webhookEnable": "Włącz webhook",
|
||||
"webhookUrl": "Adres URL webhooka",
|
||||
"webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.",
|
||||
"webhookUrlHint": "Pełny adres URL dla żądania POST. Adres w sieci prywatnej musi zatwierdzić administrator serwera.",
|
||||
"webhookHeaderName": "Nazwa nagłówka (opcjonalnie)",
|
||||
"webhookHeaderValue": "Wartość nagłówka (opcjonalnie)",
|
||||
"webhookFormat": "Format wiadomości",
|
||||
"webhookFormatGeneric": "Ogólny JSON",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Użyj adresu URL kończącego się na /message, a token aplikacji wpisz w nagłówku X-Gotify-Key.",
|
||||
"email": "E-mail",
|
||||
"emailDescription": "Otrzymuj alerty i przypomnienia e-mailem.",
|
||||
"emailEnable": "Włącz e-mail",
|
||||
@@ -6136,6 +6140,7 @@
|
||||
"rate_limited": "Ograniczone przez usługę nadrzędną",
|
||||
"timeout": "Przekroczono limit czasu żądania",
|
||||
"upstream_error": "Usługa nadrzędna zwróciła błąd",
|
||||
"upstream_rejected": "Usługa docelowa odrzuciła wiadomość",
|
||||
"connection_failed": "Połączenie nie powiodło się",
|
||||
"not_configured": "Nieskonfigurowano",
|
||||
"url_not_public": "Adres URL nie jest publicznym punktem końcowym",
|
||||
@@ -10389,7 +10394,7 @@
|
||||
"accessManageLabel": "Może zarządzać",
|
||||
"accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy, także te, które wprowadziłeś samodzielnie.",
|
||||
"accessManageDetail": "Może też czytać analizy wygenerowane na podstawie Twojej kartoteki. 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.",
|
||||
"manageStepUpNote": "Można to zrobić tylko w przeglądarce, a jeśli masz drugi składnik logowania, poprosimy o niego.",
|
||||
"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.",
|
||||
@@ -10413,7 +10418,7 @@
|
||||
"errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.",
|
||||
"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.",
|
||||
"errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce.",
|
||||
"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."
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.38.20",
|
||||
"version": "1.38.21",
|
||||
"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",
|
||||
|
||||
+189
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.38.20";
|
||||
/* @sw-version-fallback */ "v1.38.21";
|
||||
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/*`
|
||||
@@ -61,6 +61,21 @@ const CURRENT_HEALTHLOG_CACHES = new Set([
|
||||
PAGE_CACHE,
|
||||
DATA_CACHE,
|
||||
]);
|
||||
// Presence marker `src/proxy.ts` sets on every HealthLog page response. See
|
||||
// `isForeignNavigationResponse` for why the worker needs it and the proxy for
|
||||
// why it carries no version. Keep the name in lockstep with
|
||||
// `HEALTHLOG_PAGE_MARKER_HEADER` there.
|
||||
const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page";
|
||||
// Set as soon as a navigation looks like another app (see
|
||||
// `isForeignNavigationResponse`). While it is set the fetch handler declines
|
||||
// every request, so the page and its assets come from the network. It is
|
||||
// cleared again when `confirmForeignOrigin` finds HealthLog after all, and
|
||||
// stays set for the rest of the worker's life when the origin is confirmed
|
||||
// foreign and the worker retires.
|
||||
let foreignOrigin = false;
|
||||
// One confirmation at a time: a burst of navigations must not fire a burst of
|
||||
// probes, or race one probe's "HealthLog after all" against another's retire.
|
||||
let foreignOriginCheck = null;
|
||||
const MAX_STATIC_ENTRIES = 150;
|
||||
const MAX_PAGE_ENTRIES = 30;
|
||||
const MAX_DATA_ENTRIES = 60;
|
||||
@@ -168,6 +183,10 @@ self.addEventListener("activate", (event) => {
|
||||
|
||||
// ── Fetch: strategy per resource type ────────────────────────────────────────
|
||||
self.addEventListener("fetch", (event) => {
|
||||
// The origin serves another app now (see `retireFromForeignOrigin`): no
|
||||
// `respondWith`, no cache, the browser's own fetch for everything.
|
||||
if (foreignOrigin) return;
|
||||
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -335,6 +354,168 @@ function isCacheableNavigation(request, response) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this navigation response come from something other than HealthLog?
|
||||
*
|
||||
* The worker is registered with scope `/` because HealthLog serves everything
|
||||
* from `/`. When HealthLog is stopped and a different app starts on the same
|
||||
* origin (the usual case is `localhost:3000` on a developer machine), this
|
||||
* worker kept answering: `/_next/static/*` is cache-first, so the other app
|
||||
* ran on HealthLog's cached chunks, and the only way out was unregistering by
|
||||
* hand in the browser's developer tools.
|
||||
*
|
||||
* Only a response that positively looks like another app counts:
|
||||
*
|
||||
* - a redirect is opaque to the worker (`opaqueredirect`, status 0, no
|
||||
* readable headers), and HealthLog redirects every signed-out page, so it
|
||||
* proves nothing; status 0 fails the `ok` check below;
|
||||
* - a non-2xx answer proves nothing either: a reverse proxy or auth gateway
|
||||
* in front of a stopped HealthLog answers 502 or 401 without the marker,
|
||||
* and unregistering there would also drop the Web Push subscription;
|
||||
* - a network failure never reaches this check (it throws into the offline
|
||||
* fallback in `networkFirst`).
|
||||
*
|
||||
* What is left is a successful page without the marker HealthLog's proxy puts
|
||||
* on every page response. That is a suspicion, not a verdict: a reverse proxy
|
||||
* with a response-header allowlist strips the marker from every genuine page.
|
||||
* `confirmForeignOrigin` settles it before anything is deleted.
|
||||
*/
|
||||
function isForeignNavigationResponse(response) {
|
||||
if (!response || !response.ok) return false;
|
||||
return !response.headers.has(HEALTHLOG_PAGE_MARKER_HEADER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the origin positively not HealthLog? Asked of `/api/version`, which is
|
||||
* public (on the proxy's `PUBLIC_PATHS` list, no session needed) and answers
|
||||
* `{ data: { version, buildSha, ... } }`. A body check survives any proxy that
|
||||
* rewrites headers.
|
||||
*
|
||||
* Retire only on a positive answer from something else: a 404, a 2xx that is
|
||||
* not JSON (another app's HTML fallback), or JSON without the HealthLog shape.
|
||||
* `buildSha` is `null` on an image not built by the release workflow, so the
|
||||
* shape is "`version` is a string and `buildSha` is present", not "both are
|
||||
* strings". Anything that is not an answer (a thrown fetch, a 5xx, a 401 from
|
||||
* an auth gateway, a redirect) proves nothing, and the worker resumes normal
|
||||
* service.
|
||||
*
|
||||
* One gateway shape still reads as "another app": a same-origin auth gateway
|
||||
* that answers an expired session with its sign-in page and status 200 (nginx
|
||||
* `auth_request` with `error_page 401 = /oauth2/sign_in` does this). Both the
|
||||
* navigation and this probe then come back as HTML. The probe sends the
|
||||
* gateway cookie (`credentials: "same-origin"`), so a valid gateway session
|
||||
* reaches HealthLog; with an expired one the verdict is wrong, which is why
|
||||
* `retireFromForeignOrigin` never unregisters while a push subscription exists.
|
||||
*
|
||||
* The probe is the worker's own `fetch`, which never passes through this
|
||||
* worker's fetch handler; `no-store` keeps the HTTP cache out of it and
|
||||
* `redirect: "manual"` keeps a login redirect from reading as an answer.
|
||||
*/
|
||||
async function confirmForeignOrigin() {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch("/api/version", {
|
||||
cache: "no-store",
|
||||
redirect: "manual",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (response.status === 404) return true;
|
||||
if (!response.ok) return false;
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(await response.text());
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const data = body && typeof body === "object" ? body.data : null;
|
||||
const isHealthlog =
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
typeof data.version === "string" &&
|
||||
"buildSha" in data;
|
||||
return !isHealthlog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm, then either retire or resume. Shared by concurrent navigations.
|
||||
*/
|
||||
function checkForeignOrigin() {
|
||||
if (!foreignOriginCheck) {
|
||||
foreignOriginCheck = (async () => {
|
||||
let foreign = false;
|
||||
try {
|
||||
foreign = await confirmForeignOrigin();
|
||||
} catch {
|
||||
foreign = false;
|
||||
}
|
||||
if (foreign) {
|
||||
await retireFromForeignOrigin();
|
||||
return;
|
||||
}
|
||||
// HealthLog after all, or no answer: serve normally again, and let a
|
||||
// later suspicious navigation ask again.
|
||||
foreignOrigin = false;
|
||||
foreignOriginCheck = null;
|
||||
})();
|
||||
}
|
||||
return foreignOriginCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this registration holds a Web Push subscription. Unregistering
|
||||
* destroys the subscription and nothing in the app re-creates it (the push
|
||||
* card only reads it), so reminders would stop without notice. A missing
|
||||
* `pushManager` or a throwing lookup counts as "has one": keeping a worker
|
||||
* that only passes requests through costs nothing, losing reminders does.
|
||||
*/
|
||||
async function hasPushSubscription() {
|
||||
try {
|
||||
const pushManager = self.registration.pushManager;
|
||||
if (!pushManager || typeof pushManager.getSubscription !== "function") {
|
||||
return true;
|
||||
}
|
||||
return (await pushManager.getSubscription()) !== null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave an origin that no longer serves HealthLog: delete every HealthLog
|
||||
* cache (only ours, matched by name, never another app's CacheStorage) and
|
||||
* unregister, so the next load of that origin runs without this worker. The
|
||||
* `foreignOrigin` flag set by the caller stays set, so the requests the
|
||||
* current page still makes go to the network. Each step is best-effort and
|
||||
* independent: a failed cache delete must not keep the worker registered.
|
||||
*
|
||||
* With a push subscription the worker does not unregister: the verdict may
|
||||
* be a gateway's 200 sign-in page (see `confirmForeignOrigin`), and
|
||||
* unregistering would destroy the subscription. It deletes the caches and
|
||||
* stays in pass-through for the rest of its life instead, so another app on
|
||||
* the origin still gets network content. The next worker start probes again.
|
||||
*/
|
||||
async function retireFromForeignOrigin() {
|
||||
try {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k))
|
||||
.map((k) => caches.delete(k)),
|
||||
);
|
||||
} catch {
|
||||
// CacheStorage unavailable; unregistering still matters more.
|
||||
}
|
||||
if (await hasPushSubscription()) return;
|
||||
try {
|
||||
await self.registration.unregister();
|
||||
} catch {
|
||||
// Nothing further to do from inside the worker.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-first: try network (preferring the navigation-preload response
|
||||
* when the browser already started it), fall back to cache.
|
||||
@@ -351,6 +532,13 @@ async function networkFirst(event, cacheName) {
|
||||
const response =
|
||||
(event.preloadResponse ? await event.preloadResponse : null) ||
|
||||
(await fetch(request));
|
||||
if (isForeignNavigationResponse(response)) {
|
||||
// Step aside at once (declining a request only sends it to the
|
||||
// network), but delete nothing until the origin is confirmed foreign.
|
||||
foreignOrigin = true;
|
||||
event.waitUntil(checkForeignOrigin());
|
||||
return response;
|
||||
}
|
||||
if (response.ok && isCacheableNavigation(request, response)) {
|
||||
event.waitUntil(
|
||||
bestEffortCacheWrite(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
|
||||
const pathnameRef = { value: "/settings/security" };
|
||||
const activeRecordRef = { value: true };
|
||||
@@ -31,6 +32,12 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
recordKind: recordRef.value.kind,
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
// Bound as the server publishes it for a whole-record grant.
|
||||
manageableDomains: delegatedDomains(
|
||||
recordRef.value.level as "read" | "write" | "manage",
|
||||
null,
|
||||
"manage",
|
||||
),
|
||||
},
|
||||
accounts: [],
|
||||
canSwitch: true,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The page marker `public/sw.js` uses to recognise that its origin still
|
||||
* serves HealthLog (#847). A successful navigation without it makes the
|
||||
* worker delete its caches and unregister, so the marker has to be on every
|
||||
* page the proxy lets through, signed in or not, and it must never carry a
|
||||
* version.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { HEALTHLOG_PAGE_MARKER_HEADER, proxy } from "../proxy";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function request(pathname: string, signedIn = true): NextRequest {
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
headers: signedIn
|
||||
? { cookie: "healthlog_session=sess-1", accept: "text/html" }
|
||||
: { accept: "text/html" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy.ts page marker for the service worker", () => {
|
||||
it.each(["/", "/measurements", "/settings/profile", "/c/hls_abc123"])(
|
||||
"marks the signed-in page %s",
|
||||
(path) => {
|
||||
const res = proxy(request(path));
|
||||
expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["/auth/login", "/privacy", "/onboarding"])(
|
||||
"marks the public page %s",
|
||||
(path) => {
|
||||
const res = proxy(request(path, false));
|
||||
expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["/api/version", "/api/measurements", "/api/auth/me"])(
|
||||
"leaves the API response %s unmarked",
|
||||
(path) => {
|
||||
const res = proxy(request(path));
|
||||
expect(res.headers.has(HEALTHLOG_PAGE_MARKER_HEADER)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("carries presence only, never a version or build id", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "9.9.9");
|
||||
const res = proxy(request("/"));
|
||||
expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
|
||||
});
|
||||
|
||||
it("uses the header name public/sw.js checks", () => {
|
||||
const sw = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8");
|
||||
expect(sw).toContain(
|
||||
`const HEALTHLOG_PAGE_MARKER_HEADER = "${HEALTHLOG_PAGE_MARKER_HEADER}";`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,11 @@
|
||||
* `event.preloadResponse` when the browser supplies it;
|
||||
* 4. `trimCache` reads the key list once and deletes the excess prefix
|
||||
* in a single pass (the previous loop re-fetched all keys per delete).
|
||||
*
|
||||
* A HealthLog page response carries the `X-HealthLog-Page` marker the proxy
|
||||
* sets, so the navigation fixtures below build pages with `healthlogPage()`.
|
||||
* A successful page without it is another app on the same origin (#847),
|
||||
* which the "foreign origin" block covers.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -38,6 +43,15 @@ const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`;
|
||||
const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`;
|
||||
const CURRENT_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`;
|
||||
|
||||
const PAGE_MARKER = "X-HealthLog-Page";
|
||||
|
||||
/** A page response as HealthLog's proxy delivers it: marker included. */
|
||||
function healthlogPage(body: string, init: ResponseInit = {}): Response {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set(PAGE_MARKER, "1");
|
||||
return new Response(body, { ...init, headers });
|
||||
}
|
||||
|
||||
class FakeCache {
|
||||
map = new Map<string, Response>();
|
||||
keysCalls = 0;
|
||||
@@ -121,6 +135,9 @@ interface SwHarness {
|
||||
listeners: Map<string, (event: unknown) => void>;
|
||||
cacheStorage: FakeCacheStorage;
|
||||
navPreload: { enabled: boolean; enable: () => Promise<void> };
|
||||
unregisterCalls: { count: number };
|
||||
/** What `pushManager.getSubscription()` does; default: no subscription. */
|
||||
push: { getSubscription: () => Promise<unknown> };
|
||||
lifetimePromises: Promise<unknown>[];
|
||||
context: Record<string, unknown>;
|
||||
}
|
||||
@@ -129,6 +146,8 @@ function bootServiceWorker(): SwHarness {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const cacheStorage = new FakeCacheStorage();
|
||||
const lifetimePromises: Promise<unknown>[] = [];
|
||||
const unregisterCalls = { count: 0 };
|
||||
const push = { getSubscription: async (): Promise<unknown> => null };
|
||||
const navPreload = {
|
||||
enabled: false,
|
||||
enable: async () => {
|
||||
@@ -147,6 +166,13 @@ function bootServiceWorker(): SwHarness {
|
||||
clients: { claim: async () => {} },
|
||||
registration: {
|
||||
navigationPreload: navPreload,
|
||||
unregister: async () => {
|
||||
unregisterCalls.count += 1;
|
||||
return true;
|
||||
},
|
||||
pushManager: {
|
||||
getSubscription: () => push.getSubscription(),
|
||||
},
|
||||
showNotification: async (title: string, opts: { tag?: string }) => {
|
||||
shown.push({ title, tag: opts?.tag, closed: false });
|
||||
},
|
||||
@@ -194,7 +220,15 @@ function bootServiceWorker(): SwHarness {
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
return { listeners, cacheStorage, navPreload, lifetimePromises, context };
|
||||
return {
|
||||
listeners,
|
||||
cacheStorage,
|
||||
navPreload,
|
||||
unregisterCalls,
|
||||
push,
|
||||
lifetimePromises,
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchActivate(harness: SwHarness): Promise<void> {
|
||||
@@ -389,7 +423,7 @@ describe("sw.js — best-effort cache writes", () => {
|
||||
const harness = bootServiceWorker();
|
||||
const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
pageCache.putError = new Error("CacheStorage quota exceeded");
|
||||
const networkResponse = new Response("network shell", {
|
||||
const networkResponse = healthlogPage("network shell", {
|
||||
headers: { "X-Network-Response": "original" },
|
||||
});
|
||||
harness.context.fetch = async () => networkResponse;
|
||||
@@ -413,7 +447,7 @@ describe("sw.js — best-effort cache writes", () => {
|
||||
notifyPutStarted = resolve;
|
||||
});
|
||||
pageCache.onPut = notifyPutStarted;
|
||||
const networkResponse = new Response("network shell");
|
||||
const networkResponse = healthlogPage("network shell");
|
||||
harness.context.fetch = async () => networkResponse;
|
||||
|
||||
const responsePromise = dispatchNavigationFetch(harness, "/measurements");
|
||||
@@ -572,7 +606,7 @@ describe("sw.js — networkFirst offline fallback", () => {
|
||||
const res = await dispatchNavigationFetch(
|
||||
harness,
|
||||
"/",
|
||||
Promise.resolve(new Response("preloaded shell")),
|
||||
Promise.resolve(healthlogPage("preloaded shell")),
|
||||
);
|
||||
|
||||
expect(await res.clone().text()).toBe("preloaded shell");
|
||||
@@ -587,7 +621,7 @@ describe("sw.js — networkFirst privacy gate", () => {
|
||||
it("does not cache a navigation response that carries Cache-Control: no-store", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
harness.context.fetch = async () =>
|
||||
new Response("private shell", {
|
||||
healthlogPage("private shell", {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
|
||||
@@ -600,7 +634,7 @@ describe("sw.js — networkFirst privacy gate", () => {
|
||||
|
||||
it("does not cache the /c/ clinician-share view even without no-store", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
harness.context.fetch = async () => new Response("share shell");
|
||||
harness.context.fetch = async () => healthlogPage("share shell");
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/c/hls_abc123");
|
||||
expect(await res.clone().text()).toBe("share shell");
|
||||
@@ -611,7 +645,7 @@ describe("sw.js — networkFirst privacy gate", () => {
|
||||
|
||||
it("still caches an ordinary navigation response", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
harness.context.fetch = async () => new Response("app shell");
|
||||
harness.context.fetch = async () => healthlogPage("app shell");
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/measurements");
|
||||
expect(await res.clone().text()).toBe("app shell");
|
||||
@@ -621,6 +655,430 @@ describe("sw.js — networkFirst privacy gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch any request and report whether the worker took it. Used where the
|
||||
* assertion IS whether `respondWith` was called: a declined request goes to
|
||||
* the network through the browser, untouched by this worker.
|
||||
*/
|
||||
function dispatchAnyFetch(
|
||||
harness: SwHarness,
|
||||
path: string,
|
||||
headers: Record<string, string> = {},
|
||||
): { responded: boolean; response: Promise<Response> | null } {
|
||||
let captured: Promise<Response> | null = null;
|
||||
let responded = false;
|
||||
harness.listeners.get("fetch")!({
|
||||
request: new Request(`${ORIGIN}${path}`, { headers }),
|
||||
respondWith: (p: Promise<Response>) => {
|
||||
responded = true;
|
||||
captured = p;
|
||||
},
|
||||
waitUntil: (p: Promise<unknown>) => {
|
||||
harness.lifetimePromises.push(p);
|
||||
},
|
||||
});
|
||||
return { responded, response: captured };
|
||||
}
|
||||
|
||||
const HEALTHLOG_VERSION_BODY = JSON.stringify({
|
||||
data: { version: "1.38.21", buildSha: null, builtAt: null },
|
||||
error: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Route the harness `fetch`: `/api/version` (the worker's confirmation probe)
|
||||
* gets `version`, everything else gets `page`. Records every requested path.
|
||||
*/
|
||||
function routeFetch(
|
||||
harness: SwHarness,
|
||||
page: () => Response,
|
||||
version: () => Response | Promise<Response>,
|
||||
): string[] {
|
||||
const calls: string[] = [];
|
||||
harness.context.fetch = async (input: RequestInfo | URL) => {
|
||||
const url = new URL(
|
||||
typeof input === "string" || input instanceof URL ? input : input.url,
|
||||
ORIGIN,
|
||||
);
|
||||
calls.push(url.pathname);
|
||||
return url.pathname === "/api/version" ? version() : page();
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
const foreignHtml = () =>
|
||||
new Response("<html>another app</html>", {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
|
||||
async function seedHealthlogCaches(harness: SwHarness): Promise<void> {
|
||||
const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
|
||||
await statics.put(
|
||||
`${ORIGIN}/_next/static/chunks/app.js`,
|
||||
new Response("healthlog chunk"),
|
||||
);
|
||||
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
await pages.put(`${ORIGIN}/`, healthlogPage("healthlog shell"));
|
||||
const legacy = await harness.cacheStorage.open("healthlog-data-v0.0.1");
|
||||
await legacy.put(`${ORIGIN}/api/version`, new Response("{}"));
|
||||
// CacheStorage the other app owns on the same origin.
|
||||
const other = await harness.cacheStorage.open("next-app-runtime");
|
||||
await other.put(`${ORIGIN}/x`, new Response("theirs"));
|
||||
}
|
||||
|
||||
describe("sw.js — foreign origin (#847)", () => {
|
||||
it("keeps serving and caching while the page carries the HealthLog marker", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
harness.context.fetch = async () => healthlogPage("fresh shell");
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(await res.text()).toBe("fresh shell");
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
|
||||
const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js");
|
||||
expect(asset.responded).toBe(true);
|
||||
expect(await (await asset.response!).text()).toBe("healthlog chunk");
|
||||
});
|
||||
|
||||
it("retires when the page lacks the marker and the version probe finds another app's HTML", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
const foreignPage = foreignHtml();
|
||||
const calls = routeFetch(harness, () => foreignPage, foreignHtml);
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
|
||||
// The other app's page is returned untouched, and the worker declines
|
||||
// requests at once, before the probe has answered.
|
||||
expect(res).toBe(foreignPage);
|
||||
expect(
|
||||
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
|
||||
).toBe(false);
|
||||
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(calls).toEqual(["/", "/api/version"]);
|
||||
expect(harness.unregisterCalls.count).toBe(1);
|
||||
// Every HealthLog cache is gone, current and legacy; the other app's is not.
|
||||
expect([...harness.cacheStorage.stores.keys()]).toEqual([
|
||||
"next-app-runtime",
|
||||
]);
|
||||
|
||||
// Still declining after the retire: static, HTML and API all go to the
|
||||
// network through the browser.
|
||||
expect(
|
||||
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
|
||||
).toBe(false);
|
||||
expect(
|
||||
dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded,
|
||||
).toBe(false);
|
||||
expect(dispatchAnyFetch(harness, "/api/measurements").responded).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("retires when the version probe answers 404", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
routeFetch(
|
||||
harness,
|
||||
foreignHtml,
|
||||
() => new Response("Not Found", { status: 404 }),
|
||||
);
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(harness.unregisterCalls.count).toBe(1);
|
||||
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(false);
|
||||
});
|
||||
|
||||
it("retires when the version probe answers JSON without the HealthLog shape", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
routeFetch(
|
||||
harness,
|
||||
foreignHtml,
|
||||
() => new Response(JSON.stringify({ version: "3.0.0" })),
|
||||
);
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(harness.unregisterCalls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("probes with no-store, a manual redirect and same-origin credentials", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
let probeInit: RequestInit | undefined;
|
||||
harness.context.fetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
const path = new URL(
|
||||
String(input instanceof Request ? input.url : input),
|
||||
ORIGIN,
|
||||
).pathname;
|
||||
if (path === "/api/version") {
|
||||
probeInit = init;
|
||||
return new Response(HEALTHLOG_VERSION_BODY);
|
||||
}
|
||||
return foreignHtml();
|
||||
};
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(probeInit?.cache).toBe("no-store");
|
||||
expect(probeInit?.redirect).toBe("manual");
|
||||
// A valid same-origin gateway cookie must reach /api/version.
|
||||
expect(probeInit?.credentials).toBe("same-origin");
|
||||
});
|
||||
|
||||
it("does not retire when a header-stripping proxy hides the marker from a real HealthLog", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
// The page is HealthLog's, minus the marker a proxy allowlist removed.
|
||||
routeFetch(
|
||||
harness,
|
||||
() => new Response("healthlog page without marker"),
|
||||
() =>
|
||||
new Response(HEALTHLOG_VERSION_BODY, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
expect(await res.text()).toBe("healthlog page without marker");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
|
||||
// Normal service resumed: the cached chunk is served cache-first again.
|
||||
const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js");
|
||||
expect(asset.responded).toBe(true);
|
||||
expect(await (await asset.response!).text()).toBe("healthlog chunk");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"the probe throws (network)",
|
||||
() => Promise.reject(new TypeError("network down")),
|
||||
],
|
||||
["the probe answers 500", () => new Response("boom", { status: 500 })],
|
||||
[
|
||||
"the probe answers 401 from an auth gateway",
|
||||
() => new Response("Unauthorized", { status: 401 }),
|
||||
],
|
||||
])(
|
||||
"does not retire when %s, and resumes service",
|
||||
async (_label, version) => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
routeFetch(harness, foreignHtml, version as () => Promise<Response>);
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
|
||||
expect(
|
||||
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("asks again on a later suspicious navigation after an inconclusive probe", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
let probeStatus = 503;
|
||||
const calls = routeFetch(harness, foreignHtml, () =>
|
||||
probeStatus === 503
|
||||
? new Response("down", { status: 503 })
|
||||
: new Response("Not Found", { status: 404 }),
|
||||
);
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
|
||||
probeStatus = 404;
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(calls.filter((p) => p === "/api/version")).toHaveLength(2);
|
||||
expect(harness.unregisterCalls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("recognises a foreign page delivered through navigation preload", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
const calls = routeFetch(
|
||||
harness,
|
||||
() => healthlogPage("unexpected"),
|
||||
() => new Response("Not Found", { status: 404 }),
|
||||
);
|
||||
|
||||
const res = await dispatchNavigationFetch(
|
||||
harness,
|
||||
"/",
|
||||
Promise.resolve(new Response("another app")),
|
||||
);
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(await res.text()).toBe("another app");
|
||||
// The page came from the preload; the only network call is the probe.
|
||||
expect(calls).toEqual(["/api/version"]);
|
||||
expect(harness.unregisterCalls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a worker that holds a push subscription: caches deleted, no unregister, pass-through", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
harness.push.getSubscription = async () => ({
|
||||
endpoint: "https://push.example/abc",
|
||||
});
|
||||
// A gateway's 200 sign-in page for both the navigation and the probe.
|
||||
routeFetch(harness, foreignHtml, foreignHtml);
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
expect([...harness.cacheStorage.stores.keys()]).toEqual([
|
||||
"next-app-runtime",
|
||||
]);
|
||||
expect(
|
||||
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
|
||||
).toBe(false);
|
||||
expect(
|
||||
dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"the subscription lookup throws",
|
||||
(h: SwHarness) => {
|
||||
h.push.getSubscription = async () => {
|
||||
throw new Error("push service unavailable");
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
"pushManager is missing",
|
||||
(h: SwHarness) => {
|
||||
delete (h.context.self as { registration: { pushManager?: unknown } })
|
||||
.registration.pushManager;
|
||||
},
|
||||
],
|
||||
])("does not unregister when %s", async (_label, arrange) => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
arrange(harness);
|
||||
routeFetch(harness, foreignHtml, foreignHtml);
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(false);
|
||||
});
|
||||
|
||||
it("still unregisters when deleting the caches fails", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
harness.cacheStorage.keys = async () => {
|
||||
throw new Error("CacheStorage unavailable");
|
||||
};
|
||||
routeFetch(harness, foreignHtml, foreignHtml);
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(harness.unregisterCalls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the offline fallback when the network fails (HealthLog is down, not replaced)", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
// Default harness fetch throws: network down.
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(await res.text()).toBe("healthlog shell");
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an error page from HealthLog itself",
|
||||
healthlogPage("boom", { status: 500 }),
|
||||
],
|
||||
[
|
||||
"a gateway error from a proxy in front of a stopped HealthLog",
|
||||
new Response("Bad Gateway", { status: 502 }),
|
||||
],
|
||||
[
|
||||
"a 401 from an auth gateway",
|
||||
new Response("Unauthorized", { status: 401 }),
|
||||
],
|
||||
])("does not treat %s as foreign", async (_label, response) => {
|
||||
const harness = bootServiceWorker();
|
||||
await seedHealthlogCaches(harness);
|
||||
harness.context.fetch = async () => response;
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(res).toBe(response);
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
expect(
|
||||
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat an opaque redirect (a signed-out HealthLog page) as foreign", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const redirect = Response.redirect(`${ORIGIN}/auth/login`, 307);
|
||||
// A navigation fetched with `redirect: "manual"` surfaces as an opaque
|
||||
// redirect in a real worker; model the type the worker sees.
|
||||
Object.defineProperty(redirect, "type", { value: "opaqueredirect" });
|
||||
Object.defineProperty(redirect, "headers", { value: new Headers() });
|
||||
Object.defineProperty(redirect, "ok", { value: false });
|
||||
harness.context.fetch = async () => redirect;
|
||||
|
||||
await dispatchNavigationFetch(harness, "/");
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the one-shot auth navigation path unchanged", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
let fetchCalls = 0;
|
||||
harness.context.fetch = async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response("network callback");
|
||||
};
|
||||
|
||||
const { responded, response } = dispatchAuthNavigation(
|
||||
harness,
|
||||
"/api/auth/oidc/callback?code=abc&state=xyz",
|
||||
Promise.resolve(new Response("preloaded callback")),
|
||||
);
|
||||
|
||||
expect(responded).toBe(true);
|
||||
expect(await (await response!).text()).toBe("preloaded callback");
|
||||
expect(fetchCalls).toBe(0);
|
||||
// An auth response has no page marker and must not retire the worker.
|
||||
await Promise.all(harness.lifetimePromises);
|
||||
expect(harness.unregisterCalls.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sw.js — trimCache", () => {
|
||||
it("reads the key list once and deletes only the oldest excess entries", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
|
||||
@@ -65,6 +65,12 @@ vi.mock("@/lib/notifications/senders/web-push", () => ({
|
||||
vi.mock("@/lib/notifications/senders/apns", () => ({
|
||||
sendViaApns: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/webhook", () => ({
|
||||
sendViaWebhook: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/email", () => ({
|
||||
sendViaEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/server-translator", () => ({
|
||||
getServerTranslator: vi.fn(() => ({
|
||||
@@ -78,6 +84,8 @@ import { requireAdmin } from "@/lib/api-handler";
|
||||
import { sendViaApns } from "@/lib/notifications/senders/apns";
|
||||
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
|
||||
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
|
||||
import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
|
||||
import { sendViaEmail } from "@/lib/notifications/senders/email";
|
||||
|
||||
const ADMIN_USER_ID = "admin-1";
|
||||
const ADMIN_CTX = {
|
||||
@@ -92,7 +100,7 @@ const ADMIN_CTX = {
|
||||
interface FakeChannelRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
|
||||
type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS" | "WEBHOOK" | "EMAIL";
|
||||
enabled: boolean;
|
||||
config: string;
|
||||
preferences: Array<{ eventType: string; enabled: boolean }>;
|
||||
@@ -119,6 +127,10 @@ interface ResultEnvelope {
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
upstreamStatus?: number;
|
||||
smtpCode?: number;
|
||||
upstreamBody?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -299,3 +311,129 @@ describe("POST /api/admin/notifications/test — preserved branches", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/notifications/test — a failure keeps its cause (#947)", () => {
|
||||
function channel(
|
||||
type: FakeChannelRow["type"],
|
||||
config: string,
|
||||
): FakeChannelRow {
|
||||
return {
|
||||
id: `ch-${type}`,
|
||||
userId: ADMIN_USER_ID,
|
||||
type,
|
||||
enabled: true,
|
||||
config,
|
||||
preferences: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("carries the ntfy status and code instead of a bare sentence", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
channel("NTFY", 'enc({"topic":"hl-test"})'),
|
||||
] as never);
|
||||
vi.mocked(sendViaNtfy).mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "ntfy_400",
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
const body = (await (await POST()).json()) as ResultEnvelope;
|
||||
|
||||
expect(body.data.results[0]).toMatchObject({
|
||||
channel: "NTFY",
|
||||
success: false,
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
});
|
||||
expect(body.data.results[0].error).toContain("HTTP 400");
|
||||
expect(body.data.results[0].error).not.toContain("hl-test");
|
||||
});
|
||||
|
||||
it("tests the webhook channel rather than calling it unknown", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
channel("WEBHOOK", 'enc({"url":"https://relay.example.com/message"})'),
|
||||
] as never);
|
||||
vi.mocked(sendViaWebhook).mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "webhook_400",
|
||||
statusCode: 400,
|
||||
upstreamBody: "priority must be an integer",
|
||||
});
|
||||
|
||||
const body = (await (await POST()).json()) as ResultEnvelope;
|
||||
|
||||
expect(sendViaWebhook).toHaveBeenCalledWith(
|
||||
{ url: "https://relay.example.com/message" },
|
||||
expect.objectContaining({ eventType: "SYSTEM_ALERT" }),
|
||||
);
|
||||
expect(body.data.results[0]).toMatchObject({
|
||||
channel: "WEBHOOK",
|
||||
success: false,
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
});
|
||||
// The relay body belongs to the settings card, not the admin toast.
|
||||
expect(body.data.results[0].upstreamBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tests the email channel and forwards the SMTP code", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
channel("EMAIL", 'enc({"recipient":"admin@example.com"})'),
|
||||
] as never);
|
||||
vi.mocked(sendViaEmail).mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: true,
|
||||
reason: "email_smtp_5xx",
|
||||
failureCode: "upstream_rejected",
|
||||
smtpCode: 550,
|
||||
});
|
||||
|
||||
const body = (await (await POST()).json()) as ResultEnvelope;
|
||||
|
||||
expect(body.data.results[0]).toMatchObject({
|
||||
channel: "EMAIL",
|
||||
success: false,
|
||||
errorCode: "upstream_rejected",
|
||||
smtpCode: 550,
|
||||
});
|
||||
expect(body.data.results[0].error).toContain("SMTP 550");
|
||||
});
|
||||
|
||||
it("forwards a private-origin refusal with its own code", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
channel("WEBHOOK", 'enc({"url":"http://10.0.0.5/message"})'),
|
||||
] as never);
|
||||
vi.mocked(sendViaWebhook).mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "webhook_private_origin_refused",
|
||||
errorCode: "private_origin_not_approved",
|
||||
});
|
||||
|
||||
const body = (await (await POST()).json()) as ResultEnvelope;
|
||||
|
||||
expect(body.data.results[0]).toMatchObject({
|
||||
channel: "WEBHOOK",
|
||||
success: false,
|
||||
errorCode: "private_origin_not_approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a delivered webhook and email as successes", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
channel("WEBHOOK", 'enc({"url":"https://relay.example.com"})'),
|
||||
channel("EMAIL", 'enc({"recipient":"admin@example.com"})'),
|
||||
] as never);
|
||||
vi.mocked(sendViaWebhook).mockResolvedValue({ ok: true, statusCode: 200 });
|
||||
vi.mocked(sendViaEmail).mockResolvedValue({ ok: true });
|
||||
|
||||
const body = (await (await POST()).json()) as ResultEnvelope;
|
||||
|
||||
expect(body.data.results).toEqual([
|
||||
{ channel: "WEBHOOK", success: true },
|
||||
{ channel: "EMAIL", success: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,19 @@ import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
|
||||
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
|
||||
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
|
||||
import { sendViaApns } from "@/lib/notifications/senders/apns";
|
||||
import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
|
||||
import { sendViaEmail } from "@/lib/notifications/senders/email";
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import {
|
||||
testFailureDetail,
|
||||
testFailureSentence,
|
||||
type TestFailureDetail,
|
||||
} from "@/lib/notifications/test-delivery-failure";
|
||||
import type {
|
||||
TelegramChannelConfig,
|
||||
NtfyChannelConfig,
|
||||
WebhookChannelConfig,
|
||||
EmailChannelConfig,
|
||||
ChannelType,
|
||||
NotificationPayload,
|
||||
} from "@/lib/notifications/types";
|
||||
@@ -62,6 +72,45 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The admin toast renders `errorCode` and the status; the relay body stays on the settings card. */
|
||||
type TestResult = {
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
} & Omit<TestFailureDetail, "upstreamBody">;
|
||||
|
||||
/**
|
||||
* A failed HTTP or SMTP send, with the code, status and SMTP reply code the
|
||||
* user-facing test routes answer with, instead of a sentence that drops
|
||||
* them. A private-origin refusal forwards its own code.
|
||||
*/
|
||||
function failedSend(
|
||||
channel: string,
|
||||
label: string,
|
||||
result: SendOutcome,
|
||||
): TestResult {
|
||||
const { errorCode, upstreamStatus, smtpCode } = result.errorCode
|
||||
? {
|
||||
errorCode: result.errorCode,
|
||||
upstreamStatus: undefined,
|
||||
smtpCode: undefined,
|
||||
}
|
||||
: testFailureDetail(result);
|
||||
const cause = result.errorCode
|
||||
? `refused by the private-origin policy (${result.errorCode})`
|
||||
: errorCode
|
||||
? testFailureSentence(label, { errorCode, upstreamStatus, smtpCode })
|
||||
: (result.reason ?? "send failed");
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: cause,
|
||||
...(errorCode ? { errorCode } : {}),
|
||||
...(upstreamStatus !== undefined ? { upstreamStatus } : {}),
|
||||
...(smtpCode !== undefined ? { smtpCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
return (
|
||||
value === "de" ||
|
||||
@@ -121,11 +170,7 @@ export const POST = apiHandler(async () => {
|
||||
message: t("notifications.admin.testNotificationBody"),
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> = [];
|
||||
const results: TestResult[] = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
const pref = channel.preferences[0];
|
||||
@@ -166,11 +211,34 @@ export const POST = apiHandler(async () => {
|
||||
const ntfyResult = await sendViaNtfy(config, payload);
|
||||
success = ntfyResult.ok;
|
||||
if (!success) {
|
||||
results.push({
|
||||
channel: "NTFY",
|
||||
success: false,
|
||||
error: `Send failed (topic: ${config.topic})`,
|
||||
});
|
||||
// The topic is not echoed: on a public server it is the secret.
|
||||
results.push(failedSend("NTFY", "The ntfy server", ntfyResult));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// The generic webhook and email are first-class channels in the
|
||||
// dispatcher; without these arms they reported "Unknown channel type".
|
||||
case "WEBHOOK": {
|
||||
const config = JSON.parse(
|
||||
decrypt(channel.config),
|
||||
) as WebhookChannelConfig;
|
||||
const webhookResult = await sendViaWebhook(config, payload);
|
||||
success = webhookResult.ok;
|
||||
if (!success) {
|
||||
results.push(failedSend("WEBHOOK", "The webhook", webhookResult));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "EMAIL": {
|
||||
const config = JSON.parse(
|
||||
decrypt(channel.config),
|
||||
) as EmailChannelConfig;
|
||||
const emailResult = await sendViaEmail(config, payload);
|
||||
success = emailResult.ok;
|
||||
if (!success) {
|
||||
results.push(failedSend("EMAIL", "The mail server", emailResult));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* POST /api/settings/email/test — a failed send names its cause.
|
||||
*
|
||||
* Before this the route answered every failure with a bare 500, so the card
|
||||
* read "Test failed" whether the mail server refused the login, timed out,
|
||||
* or rejected the recipient.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { notificationChannel: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
||||
}));
|
||||
const sendViaEmailMock = vi.fn();
|
||||
vi.mock("@/lib/notifications/senders/email", () => ({
|
||||
sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/email-config", () => ({
|
||||
isEmailConfigured: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../email/test/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function post(): Promise<Response> {
|
||||
return (POST as (request: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/settings/email/test", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config: 'encrypted:{"recipient":"you@example.com"}',
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/settings/email/test", () => {
|
||||
it.each([
|
||||
["credentials_rejected", 535, "rejected the credentials (SMTP 535)"],
|
||||
["upstream_rejected", 550, "refused the message (SMTP 550)"],
|
||||
])(
|
||||
"answers an SMTP rejection with 502, %s and the reply code",
|
||||
async (failureCode, smtpCode, sentence) => {
|
||||
sendViaEmailMock.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: smtpCode >= 500,
|
||||
reason: "email_smtp_5xx",
|
||||
failureCode,
|
||||
smtpCode,
|
||||
});
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: failureCode, smtpCode });
|
||||
expect(json.error).toContain(sentence);
|
||||
},
|
||||
);
|
||||
|
||||
it("answers a timeout with 502 and the timeout code", async () => {
|
||||
sendViaEmailMock.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "email_smtp_error",
|
||||
failureCode: "timeout",
|
||||
});
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
meta: { errorCode: "timeout" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a 500 for a failure the sender could not name", async () => {
|
||||
sendViaEmailMock.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "email_smtp_error",
|
||||
});
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("answers a delivered test with 200", async () => {
|
||||
sendViaEmailMock.mockResolvedValue({ ok: true });
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@
|
||||
* of a generic "Invalid data", and a listed literal address saves although
|
||||
* the plain public floor would refuse it. The test routes forward the
|
||||
* sender's own refusal as a 422 with the same code instead of the bare 500
|
||||
* that made the reporter read the wide-event log to learn it was policy.
|
||||
* that made the reporter read the wide-event log to learn it was policy, and
|
||||
* answer a relay's error status with a 502 that carries the status and what
|
||||
* the relay said.
|
||||
*
|
||||
* The last block is the structural half: the test button and the dispatcher
|
||||
* must reach the SAME sender function, so the decision cannot differ between
|
||||
@@ -50,7 +52,10 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
const emitIfSampledMock = vi.fn();
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: (...args: unknown[]) => emitIfSampledMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -351,20 +356,106 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
["ntfy", sendViaNtfyMock],
|
||||
])("%s: a plain delivery failure is still a 500", async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_503`,
|
||||
statusCode: 503,
|
||||
});
|
||||
])(
|
||||
"%s: a relay that answered 400 is a 502 naming the status and what it said, not a bare 500 (#947: the card could only say Test failed)",
|
||||
async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_400`,
|
||||
statusCode: 400,
|
||||
upstreamBody: '{"error":"Bad Request","errorCode":400}',
|
||||
});
|
||||
|
||||
const response = await post(path);
|
||||
const response = await post(path);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toBeUndefined();
|
||||
});
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
upstreamBody: '{"error":"Bad Request","errorCode":400}',
|
||||
});
|
||||
expect(json.error).toContain("HTTP 400");
|
||||
// A user's relay refusing is not an operator error: the event is warn.
|
||||
expect(emitIfSampledMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "warn" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[401, "credentials_rejected"],
|
||||
[403, "credentials_rejected"],
|
||||
[404, "endpoint_not_found"],
|
||||
[410, "endpoint_not_found"],
|
||||
[415, "upstream_rejected"],
|
||||
[422, "upstream_rejected"],
|
||||
[429, "rate_limited"],
|
||||
[503, "upstream_error"],
|
||||
[307, "redirected"],
|
||||
])(
|
||||
"webhook: an upstream %i maps to %s, without a body when the sender dropped it",
|
||||
async (status, code) => {
|
||||
sendViaWebhookMock.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `webhook_${status}`,
|
||||
statusCode: status,
|
||||
});
|
||||
|
||||
const response = await post("webhook");
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: code, upstreamStatus: status });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
["ntfy", sendViaNtfyMock],
|
||||
])(
|
||||
"%s: a timeout without a status is a 502 with the timeout code",
|
||||
async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_network_error`,
|
||||
failureCode: "timeout",
|
||||
});
|
||||
|
||||
const response = await post(path);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
meta: { errorCode: "timeout" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
["ntfy", sendViaNtfyMock],
|
||||
])(
|
||||
"%s: only a failure the sender could not name stays a 500",
|
||||
async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_network_error`,
|
||||
});
|
||||
|
||||
const response = await post(path);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toBeUndefined();
|
||||
expect(emitIfSampledMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* The webhook payload format at the settings route (#947).
|
||||
*
|
||||
* Absent means generic, so a config saved before the choice existed keeps
|
||||
* sending the body it always sent. Only the Gotify choice is written, an
|
||||
* omitted format keeps the stored one, and the GET reports the resolved
|
||||
* value rather than leaving the client to guess the default.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
notificationChannel: {
|
||||
findUnique: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `encrypted:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../webhook/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
type Route = (request: Request) => Promise<Response>;
|
||||
|
||||
function put(body: unknown): Promise<Response> {
|
||||
return (PUT as Route)(
|
||||
new Request("http://localhost/api/settings/webhook", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function get(): Promise<Response> {
|
||||
return (GET as Route)(new Request("http://localhost/api/settings/webhook"));
|
||||
}
|
||||
|
||||
function stored(): string {
|
||||
const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
|
||||
return call.create.config as string;
|
||||
}
|
||||
|
||||
function existing(config: Record<string, unknown>) {
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
enabled: true,
|
||||
config: `encrypted:${JSON.stringify(config)}`,
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/settings/webhook — payload format", () => {
|
||||
it("stores the Gotify choice", async () => {
|
||||
const response = await put({
|
||||
url: "https://gotify.example.com/message",
|
||||
headerName: "X-Gotify-Key",
|
||||
headerValue: "AbCdEfGh123",
|
||||
format: "gotify",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({
|
||||
url: "https://gotify.example.com/message",
|
||||
headerName: "X-Gotify-Key",
|
||||
headerValue: "AbCdEfGh123",
|
||||
format: "gotify",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes nothing for generic, so the stored config keeps its old shape", async () => {
|
||||
await put({
|
||||
url: "https://relay.example.com/hook",
|
||||
format: "generic",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}');
|
||||
});
|
||||
|
||||
it("keeps a stored Gotify choice when the body omits the field", async () => {
|
||||
existing({
|
||||
url: "https://gotify.example.com/message",
|
||||
headerValue: "AbCdEfGh123",
|
||||
format: "gotify",
|
||||
});
|
||||
|
||||
await put({
|
||||
url: "https://gotify.example.com/message",
|
||||
headerValue: "NewToken456",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({
|
||||
headerValue: "NewToken456",
|
||||
format: "gotify",
|
||||
});
|
||||
});
|
||||
|
||||
it("switches a Gotify channel back to generic when asked", async () => {
|
||||
existing({ url: "https://gotify.example.com/message", format: "gotify" });
|
||||
|
||||
await put({
|
||||
url: "https://gotify.example.com/message",
|
||||
format: "generic",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(stored()).toBe(
|
||||
'encrypted:{"url":"https://gotify.example.com/message"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a format it does not know", async () => {
|
||||
const response = await put({
|
||||
url: "https://relay.example.com/hook",
|
||||
format: "discord",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/settings/webhook — payload format", () => {
|
||||
it("reports generic for a config saved before the choice existed", async () => {
|
||||
existing({ url: "https://relay.example.com/hook" });
|
||||
|
||||
const json = await (await get()).json();
|
||||
|
||||
expect(json.data.format).toBe("generic");
|
||||
});
|
||||
|
||||
it("reports the stored Gotify choice", async () => {
|
||||
existing({ url: "https://gotify.example.com/message", format: "gotify" });
|
||||
|
||||
const json = await (await get()).json();
|
||||
|
||||
expect(json.data.format).toBe("gotify");
|
||||
});
|
||||
|
||||
it("reports generic when no channel exists", async () => {
|
||||
const json = await (await get()).json();
|
||||
|
||||
expect(json.data).toEqual({
|
||||
enabled: false,
|
||||
url: "",
|
||||
headerName: "",
|
||||
hasHeaderValue: false,
|
||||
format: "generic",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isEmailConfigured } from "@/lib/notifications/senders/email-config";
|
||||
import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
|
||||
|
||||
/**
|
||||
* POST: send a test email to the configured recipient.
|
||||
@@ -46,7 +47,13 @@ export const POST = apiHandler(async () => {
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return apiError("Failed to send test email", 500);
|
||||
// The mail server, or the way to it, failed: 502 with the code and the
|
||||
// SMTP reply code. Only an unnamed internal fault is a 500.
|
||||
return answerTestDeliveryFailure(result, {
|
||||
channel: "email",
|
||||
label: "The mail server",
|
||||
fallbackMessage: "Failed to send test email",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NtfyChannelConfig } from "@/lib/notifications/types";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
|
||||
|
||||
/**
|
||||
* POST: Send a test notification via ntfy.
|
||||
@@ -58,7 +59,13 @@ export const POST = apiHandler(async () => {
|
||||
{ errorCode: result.errorCode },
|
||||
);
|
||||
}
|
||||
return apiError("Failed to send test message", 500);
|
||||
// The server, or the way to it, failed: 502 with the upstream status
|
||||
// and what it said (#947). Only an unnamed internal fault is a 500.
|
||||
return answerTestDeliveryFailure(result, {
|
||||
channel: "ntfy",
|
||||
label: "The ntfy server",
|
||||
fallbackMessage: "Failed to send test message",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({ action: { name: "settings.ntfy.test" }, meta: { success: true } });
|
||||
|
||||
@@ -20,6 +20,7 @@ import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { WebhookChannelConfig } from "@/lib/notifications/types";
|
||||
|
||||
/**
|
||||
* The save-time schema evaluates the same policy the sender does: the public
|
||||
@@ -61,22 +62,23 @@ export const GET = apiHandler(async () => {
|
||||
url: "",
|
||||
headerName: "",
|
||||
hasHeaderValue: false,
|
||||
format: "generic",
|
||||
});
|
||||
}
|
||||
|
||||
const config = JSON.parse(decrypt(channel.config)) as {
|
||||
url: string;
|
||||
headerName?: string;
|
||||
headerValue?: string;
|
||||
};
|
||||
const config = JSON.parse(
|
||||
decrypt(channel.config),
|
||||
) as Partial<WebhookChannelConfig>;
|
||||
|
||||
annotate({ action: { name: "settings.webhook.get" } });
|
||||
|
||||
return apiSuccess({
|
||||
enabled: channel.enabled,
|
||||
url: config.url,
|
||||
url: config.url ?? "",
|
||||
headerName: config.headerName ?? "",
|
||||
hasHeaderValue: !!config.headerValue,
|
||||
// A config saved before the choice existed carries no format: generic.
|
||||
format: config.format === "gotify" ? "gotify" : "generic",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +168,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return apiValidationError("Invalid data", issues, 422);
|
||||
}
|
||||
|
||||
const { url, headerName, headerValue, enabled } = parsed.data;
|
||||
const { url, headerName, headerValue, format, enabled } = parsed.data;
|
||||
|
||||
if (enabled && !url) {
|
||||
return apiError("Webhook URL is required when the webhook is enabled", 422);
|
||||
@@ -174,24 +176,30 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// Preserve an existing header value when the client sends an empty one (the
|
||||
// GET path never returns the secret, so a save round-trip would otherwise
|
||||
// wipe it). A non-empty value replaces it.
|
||||
// wipe it). A non-empty value replaces it. An omitted format keeps the
|
||||
// stored one too, so a client that does not know the field cannot flip a
|
||||
// Gotify channel back to the generic body by saving.
|
||||
let nextHeaderValue = headerValue || undefined;
|
||||
if (!nextHeaderValue) {
|
||||
let nextFormat = format;
|
||||
if (!nextHeaderValue || nextFormat === undefined) {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
|
||||
});
|
||||
if (existing) {
|
||||
const prev = JSON.parse(decrypt(existing.config)) as {
|
||||
headerValue?: string;
|
||||
};
|
||||
nextHeaderValue = prev.headerValue || undefined;
|
||||
const prev = JSON.parse(
|
||||
decrypt(existing.config),
|
||||
) as Partial<WebhookChannelConfig>;
|
||||
if (!nextHeaderValue) nextHeaderValue = prev.headerValue || undefined;
|
||||
if (nextFormat === undefined) nextFormat = prev.format;
|
||||
}
|
||||
}
|
||||
|
||||
// Absent means generic, so only the Gotify choice is written.
|
||||
const config = JSON.stringify({
|
||||
url: url || "",
|
||||
...(headerName ? { headerName } : {}),
|
||||
...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}),
|
||||
...(nextFormat === "gotify" ? { format: "gotify" } : {}),
|
||||
});
|
||||
|
||||
const encryptedConfig = encrypt(config);
|
||||
@@ -207,7 +215,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
update: { enabled, config: encryptedConfig },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } });
|
||||
annotate({
|
||||
action: { name: "settings.webhook.update" },
|
||||
meta: { enabled, format: nextFormat === "gotify" ? "gotify" : "generic" },
|
||||
});
|
||||
|
||||
return apiSuccess({ saved: true });
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type { WebhookChannelConfig } from "@/lib/notifications/types";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
|
||||
|
||||
/**
|
||||
* POST: send a test notification via the configured generic webhook.
|
||||
@@ -57,7 +58,14 @@ export const POST = apiHandler(async () => {
|
||||
{ errorCode: result.errorCode },
|
||||
);
|
||||
}
|
||||
return apiError("Failed to send test message", 500);
|
||||
// The relay, or the way to it, failed: 502 with the upstream status and
|
||||
// what it said, so the card can show "the relay answered 400" instead
|
||||
// of "Test failed" (#947). Only an unnamed internal fault is a 500.
|
||||
return answerTestDeliveryFailure(result, {
|
||||
channel: "webhook",
|
||||
label: "The webhook",
|
||||
fallbackMessage: "Failed to send test message",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The admin notification test toast names each failing channel's cause
|
||||
* (review L5): the code the route sends is translated and the status is
|
||||
* shown, so the per-channel fields in the response have a reader.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { describeChannelTestFailures } from "../notification-test-failures";
|
||||
|
||||
const MESSAGES: Record<string, string> = {
|
||||
"settings.testConnection.errors.upstream_rejected":
|
||||
"The other end refused the message",
|
||||
"settings.testConnection.errors.credentials_rejected": "Credentials rejected",
|
||||
"admin.notificationTestFailed": "Failed to send test notification",
|
||||
};
|
||||
const t = (key: string) => MESSAGES[key] ?? key;
|
||||
|
||||
describe("describeChannelTestFailures", () => {
|
||||
it("lists only failing channels, with the translated cause and the status", () => {
|
||||
expect(
|
||||
describeChannelTestFailures(
|
||||
[
|
||||
{ channel: "APNS", success: true },
|
||||
{
|
||||
channel: "WEBHOOK",
|
||||
success: false,
|
||||
error: "The webhook answered HTTP 400.",
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
},
|
||||
{
|
||||
channel: "EMAIL",
|
||||
success: false,
|
||||
error: "The mail server rejected the credentials (SMTP 535).",
|
||||
errorCode: "credentials_rejected",
|
||||
smtpCode: 535,
|
||||
},
|
||||
],
|
||||
t,
|
||||
),
|
||||
).toEqual([
|
||||
"Webhook: The other end refused the message (HTTP 400)",
|
||||
"Email: Credentials rejected (SMTP 535)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the route's sentence when there is no known code", () => {
|
||||
expect(
|
||||
describeChannelTestFailures(
|
||||
[
|
||||
{
|
||||
channel: "NTFY",
|
||||
success: false,
|
||||
error: "SYSTEM_ALERT disabled in settings",
|
||||
},
|
||||
{ channel: "MYSTERY", success: false, errorCode: "unlisted" },
|
||||
],
|
||||
t,
|
||||
),
|
||||
).toEqual([
|
||||
"ntfy: SYSTEM_ALERT disabled in settings",
|
||||
"MYSTERY: Failed to send test notification",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is what the admin toast renders", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/admin/reminders-section.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toMatch(/describeChannelTestFailures\(data\?\.results/);
|
||||
expect(src).toMatch(/description:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CHANNEL_TYPE_LABELS,
|
||||
type ChannelType,
|
||||
} from "@/lib/notifications/types";
|
||||
|
||||
/** One channel's row in the admin notification test response. */
|
||||
export interface ChannelTestResult {
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
upstreamStatus?: number;
|
||||
smtpCode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per failing channel for the admin test toast: the channel name,
|
||||
* the cause in the admin's language (the same `settings.testConnection.errors`
|
||||
* vocabulary the settings cards use), and the HTTP status or SMTP reply code.
|
||||
* A row without a known code falls back to the route's own sentence.
|
||||
*/
|
||||
export function describeChannelTestFailures(
|
||||
results: ReadonlyArray<ChannelTestResult>,
|
||||
t: (key: string) => string,
|
||||
): string[] {
|
||||
return results
|
||||
.filter((r) => !r.success)
|
||||
.map((r) => {
|
||||
const label = CHANNEL_TYPE_LABELS[r.channel as ChannelType] ?? r.channel;
|
||||
const key = r.errorCode
|
||||
? `settings.testConnection.errors.${r.errorCode}`
|
||||
: null;
|
||||
const translated = key ? t(key) : null;
|
||||
const cause =
|
||||
translated && translated !== key
|
||||
? translated
|
||||
: (r.error ?? t("admin.notificationTestFailed"));
|
||||
const code =
|
||||
typeof r.upstreamStatus === "number"
|
||||
? ` (HTTP ${r.upstreamStatus})`
|
||||
: typeof r.smtpCode === "number"
|
||||
? ` (SMTP ${r.smtpCode})`
|
||||
: "";
|
||||
return `${label}: ${cause}${code}`;
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
describeChannelTestFailures,
|
||||
type ChannelTestResult,
|
||||
} from "./notification-test-failures";
|
||||
|
||||
interface NotificationHealth {
|
||||
windowHours: number;
|
||||
@@ -132,19 +136,23 @@ export function RemindersSection() {
|
||||
return apiPost<
|
||||
| {
|
||||
message?: string;
|
||||
results?: Array<{
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
results?: ChannelTestResult[];
|
||||
}
|
||||
| undefined
|
||||
>("/api/admin/notifications/test");
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const hasFailures = data?.results?.some((r) => !r.success);
|
||||
if (hasFailures) {
|
||||
toast.error(data?.message ?? t("admin.notificationTestFailed"));
|
||||
const failures = describeChannelTestFailures(data?.results ?? [], t);
|
||||
if (failures.length > 0) {
|
||||
toast.error(data?.message ?? t("admin.notificationTestFailed"), {
|
||||
description: (
|
||||
<ul className="space-y-0.5">
|
||||
{failures.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.success(data?.message ?? t("admin.notificationTestSuccess"));
|
||||
}
|
||||
|
||||
@@ -14,9 +14,35 @@
|
||||
* Mutation check, run: flip the default in `isNavDestinationVisible` so an
|
||||
* unflagged destination survives a switch → "hides everything that has not
|
||||
* been classified" goes red naming Insights and the Coach.
|
||||
*
|
||||
* Mutation checks for the utility tail, run against `visibleUtilityDestinations`:
|
||||
* - restore the old empty tail for every shared record → both "offers
|
||||
* Settings" cases and the parity case go red (3);
|
||||
* - point the switched entry at the fixed `/settings/account` → the adult
|
||||
* MANAGE case and the parity case go red (2);
|
||||
* - keep Notifications in the switched tail beside the remapped Settings
|
||||
* entry → "never offers Notifications under a switch" goes red, with the
|
||||
* two "offers Settings" cases and the parity case (4);
|
||||
* - drop the manageable-section check from
|
||||
* `isSettingsDestinationListedForRecord` → "offers no Settings entry to a
|
||||
* MANAGE share whose sections do not include profile" and "keeps a
|
||||
* managed profile's guardian configuration …" go red (2).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
|
||||
import {
|
||||
RECORD_CONTENT_WRITE_DOMAINS,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
isSettingsDestinationListedForRecord,
|
||||
type SettingsRecordContext,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import {
|
||||
DOMAIN_WRITE_SUPPORT,
|
||||
delegatedDomains,
|
||||
} from "@/lib/sharing/domain-write-support";
|
||||
import type { AccountAccessLevel } from "@/lib/sharing/account-access-view";
|
||||
|
||||
import {
|
||||
BOTTOM_NAV_PRIMARY_SLOT_HREFS,
|
||||
NAV_DESTINATIONS,
|
||||
@@ -116,11 +142,180 @@ describe("a module the RECORD does not track", () => {
|
||||
});
|
||||
|
||||
describe("the utility tail under a switch", () => {
|
||||
it("disappears entirely", () => {
|
||||
// Settings holds credentials, integrations, notification channels and
|
||||
// grant management; Notifications is the delegate's own device business.
|
||||
expect(visibleUtilityDestinations({ sharedRecord: true })).toEqual([]);
|
||||
expect(visibleUtilityDestinations().length).toBeGreaterThan(0);
|
||||
/**
|
||||
* The context as the server publishes it for a whole-record grant: the
|
||||
* manageable list is the real `delegatedDomains` answer, not a literal, so
|
||||
* a change to the write-support table reaches these cases.
|
||||
*/
|
||||
const context = (
|
||||
recordKind: "managed" | "shared",
|
||||
level: AccountAccessLevel | null,
|
||||
sections: SettingsRecordContext["manageableDomains"] | null = null,
|
||||
): SettingsRecordContext => ({
|
||||
recordKind,
|
||||
level,
|
||||
manageableDomains:
|
||||
level === null
|
||||
? []
|
||||
: delegatedDomains(
|
||||
level,
|
||||
sections === null ? null : [...sections],
|
||||
"manage",
|
||||
),
|
||||
});
|
||||
const MANAGED_AT_MANAGE = context("managed", "manage");
|
||||
const SHARED_AT_MANAGE = context("shared", "manage");
|
||||
|
||||
const hrefs = (record: SettingsRecordContext | null) =>
|
||||
visibleUtilityDestinations({ record }).map((d) => d.href);
|
||||
|
||||
it("keeps both utilities in one's own record", () => {
|
||||
expect(hrefs(null)).toEqual(["/settings/account", "/notifications"]);
|
||||
expect(visibleUtilityDestinations().map((d) => d.href)).toEqual(
|
||||
hrefs(null),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* #939 — a self-hoster looking after a managed profile was told to open
|
||||
* Settings → Modules inside it and could not find Settings anywhere: the
|
||||
* tail used to be empty for every shared record, while the Settings shell
|
||||
* lists the profile's own configuration there. The entry is offered again,
|
||||
* and it opens on a page the shell admits.
|
||||
*/
|
||||
it("offers Settings inside a managed profile, because the shell has pages for it there", () => {
|
||||
const tail = visibleUtilityDestinations({ record: MANAGED_AT_MANAGE });
|
||||
expect(tail.map((d) => d.tKey)).toEqual(["nav.settings"]);
|
||||
// The profile card: `account` is classified managed-guardian and is the
|
||||
// first section the shell lists for a managed record.
|
||||
expect(tail[0].href).toBe("/settings/account");
|
||||
});
|
||||
|
||||
it("offers Settings to an adult MANAGE share, landing on the only page it opens", () => {
|
||||
// `/settings/account` is refused in an adult share; the one destination
|
||||
// listed there is the record content, so the entry must not point at the
|
||||
// account page the bar uses in one's own record.
|
||||
expect(hrefs(SHARED_AT_MANAGE)).toEqual(["/settings/anamnesis"]);
|
||||
});
|
||||
|
||||
it("offers nothing to a READ or WRITE share, where the shell lists nothing", () => {
|
||||
for (const recordKind of ["shared", "managed"] as const) {
|
||||
for (const level of ["read", "write"] as const) {
|
||||
expect(hrefs(context(recordKind, level))).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("offers nothing to a refused or pending switch, which carries no level", () => {
|
||||
// `resolveRecordCapabilities` answers `recordKind: "shared", level: null`
|
||||
// for both; a Settings entry there would lead into a context nobody has
|
||||
// proven.
|
||||
expect(hrefs(context("shared", null))).toEqual([]);
|
||||
});
|
||||
|
||||
it("never offers Notifications under a switch", () => {
|
||||
// Notifications is the delegate's own device business; its routes refuse
|
||||
// under a switch at every level and for every record kind.
|
||||
for (const recordKind of ["shared", "managed"] as const) {
|
||||
for (const level of ["read", "write", "manage", null] as const) {
|
||||
expect(hrefs(context(recordKind, level))).not.toContain(
|
||||
"/notifications",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The anamnesis forms post to `requireRecordAuth("manage", "profile")`,
|
||||
* which refuses a grant whose sections do not reach `profile`. A MANAGE
|
||||
* grant is whole-record today, so these contexts are synthetic; they pin
|
||||
* that the list stays true if a scoped one ever reaches the client.
|
||||
*/
|
||||
it("offers no Settings entry to a MANAGE share whose sections do not include profile", () => {
|
||||
const scoped = context("shared", "manage", ["labs"]);
|
||||
// Non-zero: the scoped grant still manages something, so the empty tail
|
||||
// is the domain check and not a grant that manages nothing.
|
||||
expect(scoped.manageableDomains).toEqual(["labs"]);
|
||||
expect(hrefs(scoped)).toEqual([]);
|
||||
// And the shell lists nothing for it either.
|
||||
expect(
|
||||
SETTINGS_SECTIONS.filter((section) =>
|
||||
isSettingsDestinationListedForRecord(section.slug, scoped),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lands a MANAGE share that manages profile on anamnesis, as the shell lists it", () => {
|
||||
const scoped = context("shared", "manage", ["profile"]);
|
||||
expect(hrefs(scoped)).toEqual(["/settings/anamnesis"]);
|
||||
expect(
|
||||
SETTINGS_SECTIONS.filter((section) =>
|
||||
isSettingsDestinationListedForRecord(section.slug, scoped),
|
||||
).map((section) => section.slug),
|
||||
).toEqual(["anamnesis"]);
|
||||
});
|
||||
|
||||
it("keeps a managed profile's guardian configuration independent of the manageable sections", () => {
|
||||
// Guardian destinations resolve `requireGuardianAuth`, not a section, so
|
||||
// only the record-content page depends on `profile`.
|
||||
const scoped = context("managed", "manage", ["labs"]);
|
||||
const listed = SETTINGS_SECTIONS.filter((section) =>
|
||||
isSettingsDestinationListedForRecord(section.slug, scoped),
|
||||
).map((section) => section.slug);
|
||||
expect(listed).toContain("modules");
|
||||
expect(listed).not.toContain("anamnesis");
|
||||
expect(hrefs(scoped)).toEqual(["/settings/account"]);
|
||||
// The whole-record guardian grant is unchanged: anamnesis stays listed.
|
||||
expect(
|
||||
SETTINGS_SECTIONS.filter((section) =>
|
||||
isSettingsDestinationListedForRecord(section.slug, MANAGED_AT_MANAGE),
|
||||
).map((section) => section.slug),
|
||||
).toContain("anamnesis");
|
||||
});
|
||||
|
||||
it("names the managed section of every record-content destination", () => {
|
||||
const recordContent = Object.entries(SETTINGS_DESTINATION_INVENTORY)
|
||||
.filter(([, classification]) => classification.kind === "manage-writable")
|
||||
.map(([slug]) => slug);
|
||||
expect(recordContent.length).toBeGreaterThan(0);
|
||||
const domains = RECORD_CONTENT_WRITE_DOMAINS as Partial<
|
||||
Record<string, keyof typeof DOMAIN_WRITE_SUPPORT>
|
||||
>;
|
||||
for (const slug of recordContent) {
|
||||
const domain = domains[slug];
|
||||
expect(domain, slug).toBeDefined();
|
||||
// A section with no MANAGE route could never satisfy the check.
|
||||
expect(
|
||||
DOMAIN_WRITE_SUPPORT[domain as keyof typeof DOMAIN_WRITE_SUPPORT]
|
||||
.manage,
|
||||
slug,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("lands on the first section the Settings shell lists, for every record kind", () => {
|
||||
// The parity net. The navigation walks the slug registry, the shell walks
|
||||
// its own ordered section list; both filter with the same predicate, and
|
||||
// this holds the first answers equal so a reorder on either side cannot
|
||||
// land somebody on a page the shell does not open with.
|
||||
const contexts: SettingsRecordContext[] = [
|
||||
MANAGED_AT_MANAGE,
|
||||
SHARED_AT_MANAGE,
|
||||
context("shared", "manage", ["profile"]),
|
||||
context("managed", "manage", ["labs"]),
|
||||
];
|
||||
for (const record of contexts) {
|
||||
const firstListed = SETTINGS_SECTIONS.find((section) =>
|
||||
isSettingsDestinationListedForRecord(section.slug, record),
|
||||
);
|
||||
// Non-zero: a predicate that listed nothing would make both sides
|
||||
// agree on "no entry" and prove nothing about the landing.
|
||||
expect(firstListed, record.recordKind).toBeDefined();
|
||||
// A module-gated landing could vanish from the shell when its module is
|
||||
// off, leaving the entry pointing at a section the list no longer shows.
|
||||
expect(firstListed?.moduleGate, record.recordKind).toBeUndefined();
|
||||
expect(hrefs(record)).toEqual([`/settings/${firstListed?.slug}`]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// `<SidebarNav>` reads the active route from `usePathname()`. Stub
|
||||
@@ -48,6 +50,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SidebarNav } from "../sidebar-nav";
|
||||
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
|
||||
import { visibleUtilityDestinations } from "../nav-model";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
|
||||
function render({
|
||||
pathname = "/",
|
||||
@@ -243,3 +246,94 @@ describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", ()
|
||||
expect(html).not.toMatch(/aria-label="Admin sections"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> inside somebody else's record (#939)", () => {
|
||||
/**
|
||||
* A self-hoster looking after a managed profile could not find Settings
|
||||
* anywhere after switching into it, although the Settings shell lists the
|
||||
* profile's own configuration there. The footer entry is back wherever the
|
||||
* shell has pages for the record, and points at the first of them; the
|
||||
* identity block stays a plain block, because it names the person at the
|
||||
* keyboard and must not open somebody else's settings under that name.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - `FooterIdentity` always rendering the link → all three switched cases
|
||||
* go red: the managed case on the account-settings label, the adult
|
||||
* MANAGE and READ cases on the identity link's `/settings/account` href;
|
||||
* - the empty tail restored for shared records in
|
||||
* `visibleUtilityDestinations` → both MANAGE cases go red on the missing
|
||||
* Settings link, while the READ case stays green.
|
||||
*/
|
||||
const accountSettingsLabel = (
|
||||
JSON.parse(
|
||||
readFileSync(join(process.cwd(), "messages/en.json"), "utf8"),
|
||||
) as { nav: { accountSettings: string } }
|
||||
).nav.accountSettings;
|
||||
|
||||
function renderInRecord(
|
||||
active: {
|
||||
recordKind: "managed" | "shared";
|
||||
level: "read" | "write" | "manage";
|
||||
} | null,
|
||||
) {
|
||||
const user = mockUserRef.value as typeof mockUserRef.value & {
|
||||
accountAccess?: unknown;
|
||||
};
|
||||
user.accountAccess = active
|
||||
? {
|
||||
accounts: [],
|
||||
canSwitch: true,
|
||||
active: {
|
||||
accountId: "record-1",
|
||||
username: "record",
|
||||
displayName: null,
|
||||
fullName: null,
|
||||
recordKind: active.recordKind,
|
||||
level: active.level,
|
||||
accessLevel: active.level === "read" ? "read" : "write",
|
||||
canWrite: active.level !== "read",
|
||||
sections: null,
|
||||
writableDomains: [],
|
||||
manageableDomains: delegatedDomains(active.level, null, "manage"),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
return render();
|
||||
} finally {
|
||||
delete user.accountAccess;
|
||||
}
|
||||
}
|
||||
|
||||
const settingsLinks = (html: string) =>
|
||||
[...html.matchAll(/<a\b[^>]*data-slot="nav-settings-link"[^>]*>/g)].map(
|
||||
(match) => /href="([^"]+)"/.exec(match[0])?.[1],
|
||||
);
|
||||
|
||||
it("keeps the account settings door in one's own record", () => {
|
||||
const html = renderInRecord(null);
|
||||
expect(settingsLinks(html)).toEqual(["/settings/account"]);
|
||||
expect(html).toContain(`aria-label="${accountSettingsLabel}"`);
|
||||
});
|
||||
|
||||
it("offers Settings inside a managed profile and keeps the identity block a plain block", () => {
|
||||
const html = renderInRecord({ recordKind: "managed", level: "manage" });
|
||||
expect(settingsLinks(html)).toEqual(["/settings/account"]);
|
||||
expect(html).not.toContain(`aria-label="${accountSettingsLabel}"`);
|
||||
expect(html).not.toContain('href="/notifications"');
|
||||
});
|
||||
|
||||
it("lands an adult MANAGE share on the one Settings page it opens", () => {
|
||||
const html = renderInRecord({ recordKind: "shared", level: "manage" });
|
||||
expect(settingsLinks(html)).toEqual(["/settings/anamnesis"]);
|
||||
expect(html).not.toContain('href="/settings/account"');
|
||||
});
|
||||
|
||||
it("offers no Settings entry to a READ share", () => {
|
||||
const html = renderInRecord({ recordKind: "shared", level: "read" });
|
||||
// Non-zero proof that the sidebar rendered at all.
|
||||
expect(html).toContain('href="/measurements"');
|
||||
expect(settingsLinks(html)).toEqual([]);
|
||||
expect(html).not.toContain('href="/settings/');
|
||||
});
|
||||
});
|
||||
@@ -56,9 +56,10 @@ interface NavLink {
|
||||
// (`nav-model.ts`), the same ordered list the desktop sidebar renders.
|
||||
// The bar keeps its ergonomic 5-slot shape, but the hub is computed by the
|
||||
// model's `mobileMoreHubDestinations()` (feature list minus the primary
|
||||
// slots, plus the shared utility tail) — so the two surfaces tell one story
|
||||
// instead of two hand-curated ones that drift, and the headline invariant
|
||||
// is a tested model function rather than inline bar logic.
|
||||
// slots; Settings and Notifications live in the top-bar menu) — so the two
|
||||
// surfaces tell one story instead of two hand-curated ones that drift,
|
||||
// and the headline invariant is a tested model function rather than
|
||||
// inline bar logic.
|
||||
// The Meds slot is module-gated exactly like the Insights slot below:
|
||||
// `/medications` renders nothing when the medications module is off, so a
|
||||
// pinned tab would be a visible control over a blank page.
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
// The leaf module, not the `@/lib/record-settings` barrel, for the reason
|
||||
// `auth-shell.tsx` gives: the barrel drags server-side schemas into the chrome.
|
||||
import {
|
||||
recordSettingsLandingDestination,
|
||||
type SettingsRecordContext,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
@@ -277,19 +283,18 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
/**
|
||||
* v1.17.1 (F-1 residue) — the shared UTILITY tail.
|
||||
*
|
||||
* Settings and Notifications are reachable on both bars but are not feature
|
||||
* destinations: on desktop they live in the sidebar footer + avatar menu, on
|
||||
* mobile at the tail of the "More" hub. They used to be a second hand-curated
|
||||
* list on each bar — the exact drift the one-model contract above set out to
|
||||
* kill, just pushed down a level. This list is the single source both bars
|
||||
* consume for the tail, so the two surfaces can no longer disagree on which
|
||||
* utility links exist or in which order.
|
||||
* Settings and Notifications are account utilities, not feature destinations:
|
||||
* on desktop they live in the sidebar footer + avatar menu, on mobile in the
|
||||
* top-bar user menu (UI-STANDARDS §10; the More hub carries features only).
|
||||
* They used to be a second hand-curated list on each bar — the exact drift the
|
||||
* one-model contract above set out to kill, just pushed down a level. This
|
||||
* list is the single source both bars consume, so the two surfaces can no
|
||||
* longer disagree on which utility links exist or in which order.
|
||||
*
|
||||
* Order is the footer/hub order: Settings → Notifications.
|
||||
* Order is the footer/menu order: Settings → Notifications.
|
||||
*
|
||||
* Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only
|
||||
* surface (the mobile bar never exposes it), so it is not a shared tail
|
||||
* destination and stays local to the sidebar.
|
||||
* Admin is intentionally NOT here: it is a role-gated surface that never
|
||||
* appears under a switch, so it stays local to each bar.
|
||||
*/
|
||||
export interface NavUtilityDestination {
|
||||
href: string;
|
||||
@@ -303,21 +308,41 @@ export const NAV_UTILITY_DESTINATIONS: ReadonlyArray<NavUtilityDestination> = [
|
||||
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
|
||||
];
|
||||
|
||||
/** Is this utility entry the Settings door, wherever it lands. */
|
||||
export function isSettingsUtilityDestination(
|
||||
d: Pick<NavUtilityDestination, "href">,
|
||||
): boolean {
|
||||
return d.href.startsWith("/settings/");
|
||||
}
|
||||
|
||||
/**
|
||||
* The utility tail visible to this account. Both bars consume this for their
|
||||
* tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
|
||||
* surfaces share one definition of the utility links.
|
||||
* The utility tail visible to this session. Both bars consume it, so the
|
||||
* sidebar footer and the mobile user menu share one definition.
|
||||
*
|
||||
* `record` is the shared record the browser is acting on, as its
|
||||
* server-resolved kind and grant level; absent or null is one's own record.
|
||||
*
|
||||
* Under a switch, Notifications is never offered: it is the delegate's own
|
||||
* device business, and its routes refuse. Settings is offered exactly when
|
||||
* the Settings shell would list at least one section for that record, and
|
||||
* it lands on the first section the shell lists — a managed profile at MANAGE
|
||||
* opens on its profile card, an adult share at MANAGE on the anamnesis page.
|
||||
* Both answers come from `recordSettingsLandingDestination`, which reads the
|
||||
* predicate the shell filters its own list with, so the entry cannot lead to
|
||||
* a page the shell refuses and cannot be missing where the shell has pages.
|
||||
* Every other shared context — READ, WRITE, a refused or pending switch —
|
||||
* gets no utility at all.
|
||||
*/
|
||||
export function visibleUtilityDestinations(
|
||||
opts: { sharedRecord?: boolean } = {},
|
||||
opts: { record?: SettingsRecordContext | null } = {},
|
||||
): NavUtilityDestination[] {
|
||||
// v1.36.0 — the utility tail is account configuration by definition:
|
||||
// Settings holds credentials, integrations, notification channels and grant
|
||||
// management, and Notifications is the delegate's own device business. Every
|
||||
// route behind both refuses under a switch; the tail simply stops being
|
||||
// offered so nobody walks into the refusal.
|
||||
if (opts.sharedRecord) return [];
|
||||
return [...NAV_UTILITY_DESTINATIONS];
|
||||
const record = opts.record ?? null;
|
||||
if (record === null) return [...NAV_UTILITY_DESTINATIONS];
|
||||
const landing = recordSettingsLandingDestination(record);
|
||||
if (landing === null) return [];
|
||||
return NAV_UTILITY_DESTINATIONS.filter(isSettingsUtilityDestination).map(
|
||||
(d) => ({ ...d, href: `/settings/${landing}` }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-m
|
||||
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
|
||||
import {
|
||||
isNavDestinationActive,
|
||||
isSettingsUtilityDestination,
|
||||
visibleNavDestinations,
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
@@ -64,10 +65,12 @@ function getInitials(name: string): string {
|
||||
* The sidebar footer's name-and-avatar block.
|
||||
*
|
||||
* A link into the account settings normally, and a plain block while this
|
||||
* browser is acting on somebody else's record — the settings routes refuse
|
||||
* under a switch, so the link would lead only to an explanation of why it did
|
||||
* not work. It keeps naming the person at the keyboard either way, which is
|
||||
* what a switched session needs it to do.
|
||||
* browser is acting on somebody else's record. The block names the person at
|
||||
* the keyboard, and `/settings/account` inside a record is either refused (an
|
||||
* adult share) or the RECORD's profile card (a managed profile) — a door on
|
||||
* one's own name into somebody else's settings would mislabel both. A record
|
||||
* that has Settings pages reaches them through the footer Settings entry,
|
||||
* which is labelled as Settings and lands where the shell opens.
|
||||
*/
|
||||
function FooterIdentity({
|
||||
sharedRecord,
|
||||
@@ -307,7 +310,16 @@ export function SidebarNav() {
|
||||
// v1.36.0 — while this browser is acting on somebody else's record, the nav
|
||||
// shows only what sharing covers. The server refuses the rest regardless;
|
||||
// dropping the entries spares a delegate a click that ends in a 403.
|
||||
const { inSharedRecord: sharedRecord, sections } = useRecordCapabilities();
|
||||
const {
|
||||
inSharedRecord: sharedRecord,
|
||||
sections,
|
||||
recordKind,
|
||||
level,
|
||||
} = useRecordCapabilities();
|
||||
// The sections the grant may manage, bound from the server-resolved entry.
|
||||
// The Settings entry needs them: a record-content page is offered only when
|
||||
// the grant manages the section its forms write.
|
||||
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
|
||||
const visibleNavItems = useMemo(
|
||||
() =>
|
||||
visibleNavDestinations(user?.modules, mounted, sharedRecord, sections),
|
||||
@@ -347,22 +359,28 @@ export function SidebarNav() {
|
||||
}
|
||||
|
||||
// v1.17.1 (F-1 residue) — the sidebar footer utility links derive from
|
||||
// the SAME shared list the mobile More-hub tail consumes, so the two
|
||||
// surfaces can no longer drift into two hand-curated utility lists.
|
||||
// Notifications is surfaced in the avatar menu (not the footer), so the
|
||||
// footer takes every utility entry except `/notifications`; Admin is the
|
||||
// role-gated, sidebar-only surface and is inserted separately below.
|
||||
// the SAME shared list the mobile user menu consumes, so the two surfaces
|
||||
// can no longer drift into two hand-curated utility lists. Notifications is
|
||||
// surfaced in the avatar menu (not the footer), so the footer takes every
|
||||
// utility entry except `/notifications`; Admin is the role-gated,
|
||||
// sidebar-only surface and is inserted separately below.
|
||||
//
|
||||
// Under a switch the list answers for the record on screen: a Settings
|
||||
// entry exactly when the Settings shell lists a section for it, pointing at
|
||||
// the first one (see `visibleUtilityDestinations`).
|
||||
const footerUtilityItems = useMemo(
|
||||
() =>
|
||||
visibleUtilityDestinations({ sharedRecord }).filter(
|
||||
(d) => d.href !== "/notifications",
|
||||
),
|
||||
[sharedRecord],
|
||||
visibleUtilityDestinations({
|
||||
record: sharedRecord
|
||||
? { recordKind, level, manageableDomains: manageableDomains ?? [] }
|
||||
: null,
|
||||
}).filter((d) => d.href !== "/notifications"),
|
||||
[sharedRecord, recordKind, level, manageableDomains],
|
||||
);
|
||||
|
||||
function isUtilityActive(href: string) {
|
||||
// Settings matches the whole `/settings/*` shell; the rest match exact.
|
||||
return href === "/settings/account"
|
||||
return isSettingsUtilityDestination({ href })
|
||||
? pathname.startsWith("/settings")
|
||||
: pathname === href;
|
||||
}
|
||||
@@ -374,8 +392,10 @@ export function SidebarNav() {
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
const isActive = isUtilityActive(item.href);
|
||||
const tourId =
|
||||
item.href === "/settings/account" ? "nav-settings" : undefined;
|
||||
const isSettings = isSettingsUtilityDestination(item);
|
||||
const tourId = isSettingsUtilityDestination(item)
|
||||
? "nav-settings"
|
||||
: undefined;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
@@ -384,6 +404,7 @@ export function SidebarNav() {
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={tourId}
|
||||
data-slot={isSettings ? "nav-settings-link" : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
isActive
|
||||
@@ -406,6 +427,7 @@ export function SidebarNav() {
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={tourId}
|
||||
data-slot={isSettings ? "nav-settings-link" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
@@ -607,11 +629,11 @@ export function SidebarNav() {
|
||||
role-gated Admin entry inserted before Settings. */}
|
||||
<div className={cn("space-y-1 pb-1", collapsed ? "px-1.5" : "px-3")}>
|
||||
{footerUtilityItems
|
||||
.filter((item) => item.href !== "/settings/account")
|
||||
.filter((item) => !isSettingsUtilityDestination(item))
|
||||
.map((item) => renderUtilityLink(item))}
|
||||
{renderAdminLink()}
|
||||
{footerUtilityItems
|
||||
.filter((item) => item.href === "/settings/account")
|
||||
.filter((item) => isSettingsUtilityDestination(item))
|
||||
.map((item) => renderUtilityLink(item))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Moon,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu";
|
||||
import {
|
||||
isSettingsUtilityDestination,
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
@@ -19,6 +21,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import Link from "next/link";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTheme } from "@/components/providers";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -42,8 +45,24 @@ function getInitials(name: string): string {
|
||||
|
||||
export function TopBar() {
|
||||
const { user, isLoading } = useAuth();
|
||||
// v1.36.0 — acting on somebody else's record; see the menu below.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
// v1.36.0 — acting on somebody else's record; see the menu below. Read from
|
||||
// the capability hook, as the sidebar does, so a refused or pending switch
|
||||
// counts as switched here too and both bars offer the same utilities.
|
||||
const {
|
||||
inSharedRecord: sharedRecord,
|
||||
recordKind,
|
||||
level,
|
||||
} = useRecordCapabilities();
|
||||
const utilityItems = visibleUtilityDestinations({
|
||||
record: sharedRecord
|
||||
? {
|
||||
recordKind,
|
||||
level,
|
||||
manageableDomains:
|
||||
user?.accountAccess?.active?.manageableDomains ?? [],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const logout = useLogout();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslations();
|
||||
@@ -115,34 +134,38 @@ export function TopBar() {
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
{/* v1.36.0 — the account utilities drop out while this browser is
|
||||
acting on somebody else's record. Every route behind them
|
||||
refuses under a switch, and a control that leads only to an
|
||||
explanation of why it does not work is worse than no control.
|
||||
The switcher below is how the person gets back. */}
|
||||
{!sharedRecord && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/account" className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("nav.settings")}
|
||||
{/* The account utilities come from the list the desktop sidebar
|
||||
reads. Inside somebody else's record Notifications and Admin
|
||||
drop out — every route behind them refuses under a switch —
|
||||
and Settings stays only where the record has Settings pages,
|
||||
pointing at the first one the shell opens. The switcher
|
||||
below is how the person gets back. */}
|
||||
{utilityItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<DropdownMenuItem key={item.href} asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="cursor-pointer"
|
||||
data-slot={
|
||||
isSettingsUtilityDestination(item)
|
||||
? "nav-settings-link"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{t(item.tKey)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/notifications" className="cursor-pointer">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
{t("nav.admin")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
{!sharedRecord && isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
{t("nav.admin")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* v1.4.36 W4e — About moved into the Admin Console
|
||||
(`/admin/about`). The dropdown entry was redundant
|
||||
|
||||
@@ -154,4 +154,27 @@ describe("notification channel cards", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("webhook card offers the payload format and sends it with the full save (#947)", async () => {
|
||||
const html = render(<WebhookCard isAuthenticated />);
|
||||
|
||||
expect(html).toContain('id="webhook-format"');
|
||||
expect(html).toMatch(/<option value="generic" selected="">/);
|
||||
expect(html).toContain('<option value="gotify">');
|
||||
|
||||
// The full save is the first mutation of the last render, whose closure
|
||||
// holds the values seeded from the server.
|
||||
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
|
||||
await save.mutationFn(false);
|
||||
|
||||
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
|
||||
string,
|
||||
{ body: string },
|
||||
];
|
||||
expect(JSON.parse(init.body)).toMatchObject({
|
||||
url: "https://saved.example/hook",
|
||||
format: "generic",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,11 @@ const mockModulesRef: { value: Record<string, boolean> | undefined } = {
|
||||
// server has already resolved which record is active, so it applies on the SSR
|
||||
// pass and is assertable from a static render.
|
||||
const mockActiveRecordRef: {
|
||||
value: { recordKind: "managed" | "shared"; level: string } | null;
|
||||
value: {
|
||||
recordKind: "managed" | "shared";
|
||||
level: string;
|
||||
manageableDomains: string[];
|
||||
} | null;
|
||||
} = { value: null };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
@@ -48,6 +52,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
SETTINGS_SECTIONS,
|
||||
@@ -60,7 +65,11 @@ function renderShell(props: {
|
||||
pathname?: string;
|
||||
locale?: "en" | "de";
|
||||
modules?: Record<string, boolean>;
|
||||
activeRecord?: { recordKind: "managed" | "shared"; level: string } | null;
|
||||
activeRecord?: {
|
||||
recordKind: "managed" | "shared";
|
||||
level: string;
|
||||
manageableDomains: string[];
|
||||
} | null;
|
||||
}) {
|
||||
mockPathnameRef.value = props.pathname ?? "/settings/account";
|
||||
mockModulesRef.value = props.modules;
|
||||
@@ -358,7 +367,11 @@ describe("<SettingsShell>", () => {
|
||||
it("offers the Modules destination to a guardian on a managed record", () => {
|
||||
const html = renderShell({
|
||||
active: "account",
|
||||
activeRecord: { recordKind: "managed", level: "manage" },
|
||||
activeRecord: {
|
||||
recordKind: "managed",
|
||||
level: "manage",
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
},
|
||||
});
|
||||
expect(html).toContain('href="/settings/modules"');
|
||||
// Its own configuration is not the record's, and stays away.
|
||||
@@ -372,12 +385,36 @@ describe("<SettingsShell>", () => {
|
||||
// here — both are `managed-guardian`, which an adult record never opens.
|
||||
const html = renderShell({
|
||||
active: "account",
|
||||
activeRecord: { recordKind: "shared", level: "manage" },
|
||||
activeRecord: {
|
||||
recordKind: "shared",
|
||||
level: "manage",
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
},
|
||||
});
|
||||
expect(html).not.toContain('href="/settings/modules"');
|
||||
expect(html).not.toContain('href="/settings/integrations"');
|
||||
expect(html).toContain('href="/settings/anamnesis"');
|
||||
});
|
||||
|
||||
it("lists nothing for a MANAGE grant that does not manage profile", () => {
|
||||
// The anamnesis forms write through `requireRecordAuth("manage",
|
||||
// "profile")`, which refuses a grant whose sections miss `profile`, so
|
||||
// the shell does not list the page. The navigation reads the same
|
||||
// predicate and offers no Settings entry either.
|
||||
const html = renderShell({
|
||||
active: "account",
|
||||
activeRecord: {
|
||||
recordKind: "shared",
|
||||
level: "manage",
|
||||
manageableDomains: ["labs"],
|
||||
},
|
||||
});
|
||||
expect(html).not.toContain('href="/settings/anamnesis"');
|
||||
expect(html).not.toContain('href="/settings/modules"');
|
||||
// Non-zero: the shell rendered, and still names the active section in
|
||||
// its heading, which it resolves from the unfiltered list.
|
||||
expect(html).toContain('id="settings-section-account-title"');
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to `account` when the pathname doesn't match a known slug", () => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* How the test-connection button reads a real failure response (#947).
|
||||
*
|
||||
* The component suite is SSR-only, so the button's probe is exercised as the
|
||||
* function the button calls: `apiFetchRaw` is stubbed with the envelope a
|
||||
* notification test route sends, and the result is rendered through the same
|
||||
* callout the button renders. Removing the meta extraction turns this red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiFetchRawMock = vi.fn();
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: (...args: unknown[]) => apiFetchRawMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
runConnectionTest,
|
||||
TestConnectionFailure,
|
||||
} from "../test-connection-button";
|
||||
|
||||
function envelope(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function renderFailure(
|
||||
result: Awaited<ReturnType<typeof runConnectionTest>>,
|
||||
): string {
|
||||
if (result.kind !== "error") throw new Error("expected a failure");
|
||||
return renderToStaticMarkup(
|
||||
<TestConnectionFailure
|
||||
message={`code:${result.errorCode}`}
|
||||
upstreamStatus={result.upstreamStatus}
|
||||
smtpCode={result.smtpCode}
|
||||
upstreamBody={result.upstreamBody}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchRawMock.mockReset();
|
||||
});
|
||||
|
||||
describe("runConnectionTest", () => {
|
||||
it("reads the code, the HTTP status and what the relay said from a 502", async () => {
|
||||
apiFetchRawMock.mockResolvedValue(
|
||||
envelope(502, {
|
||||
data: null,
|
||||
error: "The webhook answered HTTP 400.",
|
||||
meta: {
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
upstreamBody: "priority must be an integer",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runConnectionTest("/api/settings/webhook/test");
|
||||
|
||||
expect(apiFetchRawMock).toHaveBeenCalledWith("/api/settings/webhook/test", {
|
||||
method: "POST",
|
||||
});
|
||||
const html = renderFailure(result);
|
||||
expect(html).toContain("code:upstream_rejected (HTTP 400)");
|
||||
expect(html).toContain("priority must be an integer");
|
||||
});
|
||||
|
||||
it("reads the SMTP reply code from an email failure", async () => {
|
||||
apiFetchRawMock.mockResolvedValue(
|
||||
envelope(502, {
|
||||
data: null,
|
||||
error: "The mail server rejected the credentials (SMTP 535).",
|
||||
meta: { errorCode: "credentials_rejected", smtpCode: 535 },
|
||||
}),
|
||||
);
|
||||
|
||||
const html = renderFailure(
|
||||
await runConnectionTest("/api/settings/email/test"),
|
||||
);
|
||||
|
||||
expect(html).toContain("code:credentials_rejected (SMTP 535)");
|
||||
expect(html).not.toContain("test-connection-upstream-body");
|
||||
});
|
||||
|
||||
it("falls back to the generic code when a proxy replaced the body", async () => {
|
||||
apiFetchRawMock.mockResolvedValue(
|
||||
new Response("<html>Bad Gateway</html>", { status: 502 }),
|
||||
);
|
||||
|
||||
expect(await runConnectionTest("/api/settings/ntfy/test")).toEqual({
|
||||
kind: "error",
|
||||
errorCode: "generic",
|
||||
upstreamStatus: undefined,
|
||||
smtpCode: undefined,
|
||||
upstreamBody: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports success with the latency", async () => {
|
||||
apiFetchRawMock.mockResolvedValue(
|
||||
envelope(200, { data: { ok: true, latencyMs: 42 }, error: null }),
|
||||
);
|
||||
|
||||
expect(await runConnectionTest("/api/nightscout/test")).toEqual({
|
||||
kind: "ok",
|
||||
latency: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("is what the button calls", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/settings/test-connection-button.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toMatch(/setResult\(await runConnectionTest\(endpoint\)\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The test-connection failure callout (#947).
|
||||
*
|
||||
* SSR-only, like the rest of the component suite: the callout is hook-free so
|
||||
* it renders here without a provider. What is pinned is what a person reads:
|
||||
* the translated sentence, the status the other end answered, and the relay's
|
||||
* own words as text, never as markup.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TestConnectionFailure } from "../test-connection-button";
|
||||
|
||||
describe("TestConnectionFailure", () => {
|
||||
it("shows the sentence with the HTTP status and what the relay said", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TestConnectionFailure
|
||||
message="The other end refused the message"
|
||||
upstreamStatus={400}
|
||||
upstreamBody='{"error":"Bad Request","errorCode":400}'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("The other end refused the message (HTTP 400)");
|
||||
expect(html).toContain(
|
||||
"{"error":"Bad Request","errorCode":400}",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a markup-shaped body as characters", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TestConnectionFailure
|
||||
message="Upstream returned an error"
|
||||
upstreamStatus={502}
|
||||
upstreamBody='<img src=x onerror="alert(1)">'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("<img src=x onerror="alert(1)">");
|
||||
});
|
||||
|
||||
it("shows the SMTP code when there is no HTTP status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TestConnectionFailure message="Credentials rejected" smtpCode={535} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Credentials rejected (SMTP 535)");
|
||||
});
|
||||
|
||||
it("shows the sentence alone when the server sent no detail", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TestConnectionFailure message="Test failed" />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Test failed");
|
||||
expect(html).not.toContain("HTTP");
|
||||
expect(html).not.toContain("test-connection-upstream-body");
|
||||
});
|
||||
});
|
||||
@@ -724,8 +724,9 @@ export function inviteErrorKey(err: unknown): string {
|
||||
return "recordSharing.invite.errorInvalid";
|
||||
case "sharing.invite.invalid_scope":
|
||||
return "recordSharing.invite.errorScope";
|
||||
// The decided consequence of gating Manage on a fresh second factor: the
|
||||
// step-up is cookie-only, so this reaches a native caller and nobody else.
|
||||
// Manage access is offered from a browser session only: the route refuses
|
||||
// a Bearer caller before any second-factor check, so this reaches a native
|
||||
// caller and nobody else.
|
||||
// Named rather than folded into the generic failure, because "use a
|
||||
// browser for this one" is a next step and "the invitation could not be
|
||||
// sent" is not.
|
||||
|
||||
@@ -49,7 +49,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
@@ -448,31 +448,16 @@ export function SettingsShell({
|
||||
// Keep `allVisibleSections` for the active heading so a direct unavailable
|
||||
// link identifies itself before the gate explains why it cannot open.
|
||||
//
|
||||
// * A managed profile shows its guardian configuration AND the record
|
||||
// content a MANAGE holder may write — the guardian holds MANAGE, so the
|
||||
// second set is theirs as well.
|
||||
// * An ordinary shared record at MANAGE shows only the record content. A
|
||||
// delegate manages somebody's health record, not their account: modules,
|
||||
// thresholds and notification routing stay with the owner.
|
||||
// * Every other shared record shows nothing here, because it reaches no
|
||||
// Settings destination at all.
|
||||
// Which destinations a record lists is answered by
|
||||
// `isSettingsDestinationListedForRecord`, the same predicate the app
|
||||
// navigation reads to decide whether to offer a Settings entry at all and
|
||||
// where it lands. Its docblock carries the per-kind rules.
|
||||
const visibleSections =
|
||||
activeRecord === null
|
||||
? allVisibleSections
|
||||
: allVisibleSections.filter((section) => {
|
||||
const kind = classifySettingsDestination(section.slug).kind;
|
||||
// Every destination on either list needs MANAGE, so the level is
|
||||
// checked once for both record kinds rather than only for the adult
|
||||
// one. A guardian grant is always MANAGE today, which is exactly why
|
||||
// the check was easy to leave out — and a managed entry that ever
|
||||
// arrived below it would have listed destinations the section gate
|
||||
// refuses, sending somebody to a page that explains it cannot open.
|
||||
if (activeRecord.level !== "manage") return false;
|
||||
if (activeRecord.recordKind === "managed") {
|
||||
return kind === "managed-guardian" || kind === "manage-writable";
|
||||
}
|
||||
return kind === "manage-writable";
|
||||
});
|
||||
: allVisibleSections.filter((section) =>
|
||||
isSettingsDestinationListedForRecord(section.slug, activeRecord),
|
||||
);
|
||||
const activeSection = allVisibleSections.find(
|
||||
(section) => section.slug === activeSlug,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,10 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
* Shared "Test connection" UX for the settings integrations + notifications
|
||||
* sections (A8-UI). POSTs to the given endpoint, expects a `{ data, error,
|
||||
* meta }` envelope, and surfaces success (latency) or a translated
|
||||
* `meta.errorCode` callout.
|
||||
* `meta.errorCode` callout. When the route reports what the other end
|
||||
* answered (`meta.upstreamStatus` / `meta.smtpCode` / `meta.upstreamBody`,
|
||||
* the notification test routes since #947), the callout shows the status and
|
||||
* the relay's own words beneath it, as plain text.
|
||||
*
|
||||
* Endpoints are user-scoped, so the button intentionally does NOT send an
|
||||
* Idempotency-Key — each click probes the upstream live.
|
||||
@@ -27,7 +30,98 @@ export interface TestConnectionButtonProps {
|
||||
interface TestResponse {
|
||||
data?: { latencyMs?: number; ok?: boolean; sent?: number };
|
||||
error?: string;
|
||||
meta?: { errorCode?: string };
|
||||
meta?: {
|
||||
errorCode?: string;
|
||||
upstreamStatus?: number;
|
||||
smtpCode?: number;
|
||||
upstreamBody?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestConnectionFailureProps {
|
||||
/** The translated error sentence. */
|
||||
message: string;
|
||||
upstreamStatus?: number;
|
||||
smtpCode?: number;
|
||||
/** What the other end answered, already bounded and cleaned by the server. */
|
||||
upstreamBody?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure callout. Hook-free so it renders without a provider. The
|
||||
* relay's body is a React text child, never markup: whatever it contains is
|
||||
* shown as characters.
|
||||
*/
|
||||
export function TestConnectionFailure({
|
||||
message,
|
||||
upstreamStatus,
|
||||
smtpCode,
|
||||
upstreamBody,
|
||||
}: TestConnectionFailureProps) {
|
||||
const code =
|
||||
typeof upstreamStatus === "number"
|
||||
? ` (HTTP ${upstreamStatus})`
|
||||
: typeof smtpCode === "number"
|
||||
? ` (SMTP ${smtpCode})`
|
||||
: "";
|
||||
return (
|
||||
<div role="alert" className="space-y-1">
|
||||
<p className="text-destructive flex items-center gap-1.5 text-sm">
|
||||
<XCircle className="size-3.5 shrink-0" />
|
||||
{`${message}${code}`}
|
||||
</p>
|
||||
{upstreamBody ? (
|
||||
<p
|
||||
data-testid="test-connection-upstream-body"
|
||||
className="text-foreground font-mono text-xs break-all"
|
||||
>
|
||||
{upstreamBody}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ConnectionTestResult =
|
||||
| { kind: "ok"; latency: number }
|
||||
| {
|
||||
kind: "error";
|
||||
errorCode: string;
|
||||
upstreamStatus?: number;
|
||||
smtpCode?: number;
|
||||
upstreamBody?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The button's probe: POST the endpoint and read the envelope into what the
|
||||
* button shows. Exported so the reading of a real failure response can be
|
||||
* tested without a DOM.
|
||||
*/
|
||||
export async function runConnectionTest(
|
||||
endpoint: string,
|
||||
): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const res = await apiFetchRaw(endpoint, { method: "POST" });
|
||||
const json = (await res.json().catch(() => ({}))) as TestResponse;
|
||||
|
||||
if (res.ok && json.data?.ok !== false) {
|
||||
return { kind: "ok", latency: json.data?.latencyMs ?? 0 };
|
||||
}
|
||||
const meta = json.meta;
|
||||
return {
|
||||
kind: "error",
|
||||
errorCode: meta?.errorCode ?? "generic",
|
||||
upstreamStatus:
|
||||
typeof meta?.upstreamStatus === "number"
|
||||
? meta.upstreamStatus
|
||||
: undefined,
|
||||
smtpCode: typeof meta?.smtpCode === "number" ? meta.smtpCode : undefined,
|
||||
upstreamBody:
|
||||
typeof meta?.upstreamBody === "string" ? meta.upstreamBody : undefined,
|
||||
};
|
||||
} catch {
|
||||
return { kind: "error", errorCode: "connection_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export function TestConnectionButton({
|
||||
@@ -37,32 +131,13 @@ export function TestConnectionButton({
|
||||
}: TestConnectionButtonProps) {
|
||||
const { t } = useTranslations();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<
|
||||
| { kind: "ok"; latency: number }
|
||||
| { kind: "error"; errorCode: string }
|
||||
| null
|
||||
>(null);
|
||||
const [result, setResult] = useState<ConnectionTestResult | null>(null);
|
||||
|
||||
async function handleClick() {
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await apiFetchRaw(endpoint, { method: "POST" });
|
||||
const json = (await res.json().catch(() => ({}))) as TestResponse;
|
||||
|
||||
if (res.ok && json.data?.ok !== false) {
|
||||
setResult({
|
||||
kind: "ok",
|
||||
latency: json.data?.latencyMs ?? 0,
|
||||
});
|
||||
} else {
|
||||
setResult({
|
||||
kind: "error",
|
||||
errorCode: json.meta?.errorCode ?? "generic",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setResult({ kind: "error", errorCode: "connection_failed" });
|
||||
setResult(await runConnectionTest(endpoint));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@@ -109,13 +184,12 @@ export function TestConnectionButton({
|
||||
)}
|
||||
|
||||
{result?.kind === "error" && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{describeError(result.errorCode)}
|
||||
</p>
|
||||
<TestConnectionFailure
|
||||
message={describeError(result.errorCode)}
|
||||
upstreamStatus={result.upstreamStatus}
|
||||
smtpCode={result.smtpCode}
|
||||
upstreamBody={result.upstreamBody}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import type { WebhookPayloadFormat } from "@/lib/notifications/types";
|
||||
|
||||
interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
headerName: string;
|
||||
hasHeaderValue: boolean;
|
||||
format?: WebhookPayloadFormat;
|
||||
}
|
||||
|
||||
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
@@ -35,6 +38,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [headerName, setHeaderName] = useState("");
|
||||
const [headerValue, setHeaderValue] = useState("");
|
||||
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
@@ -50,13 +54,14 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings
|
||||
? `${settings.url}|${settings.headerName}`
|
||||
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setUrl(settings!.url);
|
||||
setHeaderName(settings!.headerName);
|
||||
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
@@ -68,6 +73,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
url,
|
||||
headerName: headerName || undefined,
|
||||
headerValue: headerValue || undefined,
|
||||
format,
|
||||
enabled,
|
||||
}),
|
||||
});
|
||||
@@ -180,6 +186,30 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
{t("settings.webhookUrlHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="webhook-format">
|
||||
{t("settings.webhookFormat")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="webhook-format"
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(e.target.value === "gotify" ? "gotify" : "generic")
|
||||
}
|
||||
>
|
||||
<option value="generic">
|
||||
{t("settings.webhookFormatGeneric")}
|
||||
</option>
|
||||
<option value="gotify">
|
||||
{t("settings.webhookFormatGotify")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
{format === "gotify" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.webhookFormatGotifyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-header-name">
|
||||
{t("settings.webhookHeaderName")}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { looksSecretShaped } from "@/lib/secret-shape";
|
||||
|
||||
describe("looksSecretShaped", () => {
|
||||
it.each([
|
||||
["an access token", "token hlk_abc123"],
|
||||
["a refresh token", "hlr_xyz789"],
|
||||
["a share-link token", "see hls_def456"],
|
||||
["an invite token", "hlv_0a1b2c3d"],
|
||||
["an elevation token", "hle_0a1b2c3d"],
|
||||
["an OpenAI key", "echoed sk-1234567890"],
|
||||
["an Anthropic key", "sk-ant-api03-xyz12345"],
|
||||
])("matches %s", (_label, text) => {
|
||||
expect(looksSecretShaped(text)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a word containing sk-", "task-id must not contain spaces"],
|
||||
["another one", "risk-management"],
|
||||
["a JSON error body", '{"error":"Bad Request","errorCode":400}'],
|
||||
])("does not match %s", (_label, text) => {
|
||||
expect(looksSecretShaped(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -172,8 +172,16 @@ describe("the credential prefix lists do not drift apart", () => {
|
||||
// Two parallel denylists guard the same class of value. They were already
|
||||
// one prefix out of step (`hls_` was in one and not the other), which is
|
||||
// how the next one gets missed.
|
||||
// The cache refuses through the shared matcher, so the matcher is what
|
||||
// has to know the prefix, and the cache has to be using it rather than a
|
||||
// private copy of the pattern.
|
||||
const { looksSecretShaped } = await import("@/lib/secret-shape");
|
||||
expect(looksSecretShaped(`elevation=hle_${"a".repeat(64)}`)).toBe(true);
|
||||
const idempotency = readFileSync("src/lib/idempotency.ts", "utf8");
|
||||
expect(idempotency).toContain("hle_");
|
||||
expect(idempotency).toMatch(
|
||||
/import \{ looksSecretShaped \} from "@\/lib\/secret-shape"/,
|
||||
);
|
||||
expect(idempotency).toMatch(/looksSecretShaped\(text\)/);
|
||||
|
||||
const { redactSecrets } = await import("@/lib/logging/redact");
|
||||
const secret = `hle_${"a".repeat(64)}`;
|
||||
|
||||
+4
-13
@@ -28,6 +28,7 @@ import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { createHash } from "node:crypto";
|
||||
import { looksSecretShaped } from "@/lib/secret-shape";
|
||||
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
@@ -641,21 +642,11 @@ export function withIdempotency<
|
||||
// Defence-in-depth: never persist a body that carries a freshly-issued
|
||||
// bearer / refresh token or a third-party AI provider key. Auth and
|
||||
// settings routes shouldn't be wrapped in withIdempotency to begin
|
||||
// with, but if a future caller forgets we refuse to leak.
|
||||
// `hlk_` = our access tokens
|
||||
// `hlr_` = our refresh tokens
|
||||
// `hls_` = clinician share-link tokens (v1.11)
|
||||
// `hlv_` = registration invite tokens (v1.16 — the admin mint
|
||||
// response is the one place the raw token appears)
|
||||
// `sk-…` = OpenAI / Anthropic keys (full token form, not the
|
||||
// raw substring — a 422 body explaining "task-id…"
|
||||
// or any other word containing `sk-` would otherwise
|
||||
// silently break idempotency for benign retries).
|
||||
const SECRET_PATTERN =
|
||||
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
||||
// with, but if a future caller forgets we refuse to leak. The prefixes
|
||||
// are listed where the matcher lives (`src/lib/secret-shape.ts`).
|
||||
const cloned = response.clone();
|
||||
const text = await cloned.text();
|
||||
if (!SECRET_PATTERN.test(text)) {
|
||||
if (!looksSecretShaped(text)) {
|
||||
await persistCached(ctx, response, text);
|
||||
} else {
|
||||
// Secret-shaped body — drop the claim so the key isn't left
|
||||
|
||||
@@ -110,3 +110,33 @@ describe("WideEventBuilder.setHttp", () => {
|
||||
expect(evt.toJSON().http).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WideEventBuilder.capStatusLevel", () => {
|
||||
it("derives error from a 5xx by default", () => {
|
||||
const evt = new WideEventBuilder("http");
|
||||
evt.finish(502);
|
||||
expect(evt.toJSON().level).toBe("error");
|
||||
});
|
||||
|
||||
it("keeps a capped 5xx at warn", () => {
|
||||
const evt = new WideEventBuilder("http");
|
||||
evt.capStatusLevel("warn");
|
||||
evt.finish(502);
|
||||
expect(evt.toJSON().level).toBe("warn");
|
||||
});
|
||||
|
||||
it("does not lower an explicit elevation", () => {
|
||||
const evt = new WideEventBuilder("http");
|
||||
evt.capStatusLevel("warn");
|
||||
evt.elevateLevel("error");
|
||||
evt.finish(502);
|
||||
expect(evt.toJSON().level).toBe("error");
|
||||
});
|
||||
|
||||
it("leaves a 4xx at warn under the cap", () => {
|
||||
const evt = new WideEventBuilder("http");
|
||||
evt.capStatusLevel("warn");
|
||||
evt.finish(422);
|
||||
expect(evt.toJSON().level).toBe("warn");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Loki shipping (#947 follow-up): the endpoint forms an operator types, the
|
||||
* dial path a private Loki needs, the floor no configuration opens, and the
|
||||
* stderr notice that replaces the silent catch.
|
||||
*
|
||||
* Two layers. The mocked-`safeFetch` block pins what the transport asks for
|
||||
* and what it says when that fails. The loopback block runs the real
|
||||
* `safeFetch` against a real HTTP server on 127.0.0.1, which is the case the
|
||||
* public-host pin used to refuse: an address that is not public at all.
|
||||
*/
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetLoggingConfig } from "../config";
|
||||
import type { WideEvent } from "../types";
|
||||
|
||||
const safeFetchMock = vi.hoisted(() => vi.fn());
|
||||
const useRealSafeFetch = vi.hoisted(() => ({ value: false }));
|
||||
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
|
||||
return {
|
||||
...actual,
|
||||
safeFetch: (...args: Parameters<typeof actual.safeFetch>) =>
|
||||
useRealSafeFetch.value
|
||||
? actual.safeFetch(...args)
|
||||
: safeFetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
import {
|
||||
_resetLokiTransportForTests,
|
||||
emitEvent,
|
||||
flushLokiBuffer,
|
||||
LOKI_FAILURE_NOTICE_INTERVAL_MS,
|
||||
resolveLokiPushTarget,
|
||||
} from "../transports";
|
||||
|
||||
const USERNAME = "loki-user";
|
||||
const PASSWORD = "s3cret-loki-password";
|
||||
const BASIC = Buffer.from(`${USERNAME}:${PASSWORD}`).toString("base64");
|
||||
|
||||
function makeEvent(): WideEvent {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
duration_ms: 1,
|
||||
request_id: "req-loki",
|
||||
trace_id: "trace-loki",
|
||||
level: "info",
|
||||
kind: "http",
|
||||
service: "healthlog",
|
||||
environment: "test",
|
||||
};
|
||||
}
|
||||
|
||||
let stderrLines: string[];
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function configure(endpoint: string, withAuth = true): void {
|
||||
vi.stubEnv("LOKI_ENDPOINT", endpoint);
|
||||
vi.stubEnv("LOKI_USERNAME", withAuth ? USERNAME : "");
|
||||
vi.stubEnv("LOKI_PASSWORD", withAuth ? PASSWORD : "");
|
||||
resetLoggingConfig();
|
||||
}
|
||||
|
||||
function buffer(count: number): void {
|
||||
for (let i = 0; i < count; i++) emitEvent(makeEvent());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetLokiTransportForTests();
|
||||
safeFetchMock.mockReset();
|
||||
useRealSafeFetch.value = false;
|
||||
stderrLines = [];
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk: string | Uint8Array) => {
|
||||
stderrLines.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
stdoutSpy.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
resetLoggingConfig();
|
||||
_resetLokiTransportForTests();
|
||||
});
|
||||
|
||||
describe("resolveLokiPushTarget", () => {
|
||||
it.each([
|
||||
["http://loki:3100", "http://loki:3100/loki/api/v1/push"],
|
||||
["http://loki:3100/", "http://loki:3100/loki/api/v1/push"],
|
||||
["http://loki:3100/loki/api/v1/push", "http://loki:3100/loki/api/v1/push"],
|
||||
["http://loki:3100/loki/api/v1/push/", "http://loki:3100/loki/api/v1/push"],
|
||||
[
|
||||
"https://logs.example.com/tenant-a",
|
||||
"https://logs.example.com/tenant-a/loki/api/v1/push",
|
||||
],
|
||||
[
|
||||
"https://logs.example.com/tenant-a/loki/api/v1/push",
|
||||
"https://logs.example.com/tenant-a/loki/api/v1/push",
|
||||
],
|
||||
[" http://10.0.0.5:3100 ", "http://10.0.0.5:3100/loki/api/v1/push"],
|
||||
])("%s pushes to %s", (endpoint, expected) => {
|
||||
const resolved = resolveLokiPushTarget(endpoint);
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (resolved.ok) expect(resolved.target.url).toBe(expected);
|
||||
});
|
||||
|
||||
it("approves exactly the endpoint origin, private addresses included", () => {
|
||||
const resolved = resolveLokiPushTarget("http://192.168.1.20:3100/");
|
||||
expect(resolved).toEqual({
|
||||
ok: true,
|
||||
target: {
|
||||
url: "http://192.168.1.20:3100/loki/api/v1/push",
|
||||
origin: "http://192.168.1.20:3100",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://169.254.169.254",
|
||||
"http://169.254.10.1:3100",
|
||||
"http://0.0.0.0:3100",
|
||||
"http://[fe80::1]:3100",
|
||||
])("refuses the never-grantable host %s", (endpoint) => {
|
||||
const resolved = resolveLokiPushTarget(endpoint);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok) expect(resolved.reason).toBe("never_grantable");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"loki:3100",
|
||||
"ftp://loki:3100",
|
||||
"http://user:pw@loki:3100",
|
||||
"http://loki:3100?token=abc",
|
||||
])("refuses the malformed endpoint %s", (endpoint) => {
|
||||
const resolved = resolveLokiPushTarget(endpoint);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok) {
|
||||
expect(resolved.reason).toBe("invalid_endpoint");
|
||||
expect(resolved.shown).not.toContain("pw");
|
||||
expect(resolved.shown).not.toContain("abc");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushLokiBuffer — dial path", () => {
|
||||
it("dials a private endpoint as an operator-approved origin, not through the public pin", async () => {
|
||||
configure("http://10.0.0.5:3100/loki/api/v1/push");
|
||||
safeFetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
buffer(2);
|
||||
|
||||
await flushLokiBuffer();
|
||||
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init, opts] = safeFetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://10.0.0.5:3100/loki/api/v1/push");
|
||||
expect(init.headers.Authorization).toBe(`Basic ${BASIC}`);
|
||||
expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.5:3100");
|
||||
expect(opts.requirePublicHost).toBeUndefined();
|
||||
expect(JSON.parse(init.body).streams[0].values).toHaveLength(2);
|
||||
expect(stderrLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("never dials a metadata endpoint and says why", async () => {
|
||||
configure("http://169.254.169.254:3100");
|
||||
buffer(3);
|
||||
|
||||
await flushLokiBuffer();
|
||||
|
||||
expect(safeFetchMock).not.toHaveBeenCalled();
|
||||
expect(stderrLines).toHaveLength(1);
|
||||
expect(stderrLines[0]).toContain("never_grantable");
|
||||
expect(stderrLines[0]).toContain("3 events dropped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushLokiBuffer — failures reach stderr, once per window", () => {
|
||||
it("reports a non-2xx answer with origin, status and dropped count", async () => {
|
||||
configure("https://logs.example.com/loki/api/v1/push/loki/api/v1/push");
|
||||
safeFetchMock.mockResolvedValue(
|
||||
new Response("404 page not found", { status: 404 }),
|
||||
);
|
||||
buffer(4);
|
||||
|
||||
await flushLokiBuffer();
|
||||
|
||||
expect(stderrLines).toHaveLength(1);
|
||||
expect(stderrLines[0]).toContain("https://logs.example.com");
|
||||
expect(stderrLines[0]).toContain("HTTP 404");
|
||||
expect(stderrLines[0]).toContain("4 events dropped");
|
||||
});
|
||||
|
||||
it("reports a thrown error by class and socket code, never by message", async () => {
|
||||
configure("http://loki.lan:3100");
|
||||
const cause = Object.assign(new Error("connect ECONNREFUSED"), {
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
safeFetchMock.mockRejectedValue(
|
||||
new SafeFetchError(
|
||||
`safeFetch network error: http://${USERNAME}:${PASSWORD}@loki.lan`,
|
||||
"network",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
buffer(1);
|
||||
|
||||
await flushLokiBuffer();
|
||||
|
||||
expect(stderrLines).toHaveLength(1);
|
||||
expect(stderrLines[0]).toContain("http://loki.lan:3100");
|
||||
expect(stderrLines[0]).toContain("network ECONNREFUSED");
|
||||
expect(stderrLines[0]).toContain("1 event dropped");
|
||||
});
|
||||
|
||||
it("writes one line per reason per window and carries the suppressed count forward", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
configure("http://loki.lan:3100");
|
||||
safeFetchMock.mockImplementation(
|
||||
async () => new Response(null, { status: 503 }),
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
buffer(2);
|
||||
await flushLokiBuffer();
|
||||
vi.advanceTimersByTime(5_000);
|
||||
}
|
||||
expect(stderrLines).toHaveLength(1);
|
||||
expect(stderrLines[0]).toContain("2 events dropped");
|
||||
|
||||
// A different reason is not muted by the first one's window.
|
||||
safeFetchMock.mockImplementationOnce(
|
||||
async () => new Response(null, { status: 401 }),
|
||||
);
|
||||
buffer(1);
|
||||
await flushLokiBuffer();
|
||||
expect(stderrLines).toHaveLength(2);
|
||||
expect(stderrLines[1]).toContain("HTTP 401");
|
||||
|
||||
vi.advanceTimersByTime(LOKI_FAILURE_NOTICE_INTERVAL_MS);
|
||||
buffer(2);
|
||||
await flushLokiBuffer();
|
||||
expect(stderrLines).toHaveLength(3);
|
||||
expect(stderrLines[2]).toContain("HTTP 503");
|
||||
// Four suppressed batches of two plus this batch of two.
|
||||
expect(stderrLines[2]).toContain("10 events dropped");
|
||||
});
|
||||
|
||||
it("never prints the credentials or the basic-auth header", async () => {
|
||||
configure("http://loki.lan:3100");
|
||||
safeFetchMock.mockResolvedValueOnce(new Response(null, { status: 403 }));
|
||||
safeFetchMock.mockRejectedValueOnce(
|
||||
new SafeFetchError(`failed with Basic ${BASIC} ${PASSWORD}`, "timeout"),
|
||||
);
|
||||
buffer(1);
|
||||
await flushLokiBuffer();
|
||||
buffer(1);
|
||||
await flushLokiBuffer();
|
||||
|
||||
expect(stderrLines).toHaveLength(2);
|
||||
const all = stderrLines.join("");
|
||||
expect(all).not.toContain(PASSWORD);
|
||||
expect(all).not.toContain(USERNAME);
|
||||
expect(all).not.toContain(BASIC);
|
||||
});
|
||||
|
||||
it("stays silent on success", async () => {
|
||||
configure("http://loki.lan:3100");
|
||||
safeFetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
buffer(1);
|
||||
await flushLokiBuffer();
|
||||
expect(stderrLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not feed its own notice back into the Loki buffer", async () => {
|
||||
configure("http://loki.lan:3100");
|
||||
safeFetchMock.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
buffer(1);
|
||||
await flushLokiBuffer();
|
||||
expect(stderrLines).toHaveLength(1);
|
||||
|
||||
// Nothing was buffered by the failure, so the next flush has nothing
|
||||
// to send.
|
||||
safeFetchMock.mockClear();
|
||||
await flushLokiBuffer();
|
||||
expect(safeFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushLokiBuffer — real dial to a loopback Loki", () => {
|
||||
let server: Server;
|
||||
let received: Array<{ url: string; auth: string | undefined }>;
|
||||
let status: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
useRealSafeFetch.value = true;
|
||||
received = [];
|
||||
status = 204;
|
||||
server = createServer((req, res) => {
|
||||
received.push({ url: req.url ?? "", auth: req.headers.authorization });
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res.statusCode = status;
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function endpoint(path = ""): string {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${port}${path}`;
|
||||
}
|
||||
|
||||
it("delivers to a non-public address given as the full push URL", async () => {
|
||||
configure(endpoint("/loki/api/v1/push"));
|
||||
buffer(1);
|
||||
|
||||
await flushLokiBuffer();
|
||||
|
||||
expect(received).toEqual([
|
||||
{ url: "/loki/api/v1/push", auth: `Basic ${BASIC}` },
|
||||
]);
|
||||
expect(stderrLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the 404 a wrong path produces instead of swallowing it", async () => {
|
||||
status = 404;
|
||||
configure(endpoint("/wrong"));
|
||||
buffer(2);
|
||||
|
||||
await flushLokiBuffer();
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].url).toBe("/wrong/loki/api/v1/push");
|
||||
expect(stderrLines).toHaveLength(1);
|
||||
expect(stderrLines[0]).toContain(`${endpoint()} failed (HTTP 404)`);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ export class WideEventBuilder {
|
||||
private event: Partial<WideEvent>;
|
||||
private startTime: number;
|
||||
private highestLevel: LogLevel = "info";
|
||||
private statusLevelCeiling: LogLevel = "error";
|
||||
|
||||
constructor(kind: EventKind = "http") {
|
||||
this.startTime = performance.now();
|
||||
@@ -239,6 +240,18 @@ export class WideEventBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling for the level `finish()` derives from the HTTP status. A route
|
||||
* whose 5xx reports someone else's refusal (a user's relay answering a
|
||||
* test with 400) caps it at `warn`, so a user's configuration does not
|
||||
* raise an operator-facing error. `elevateLevel` calls and thrown errors
|
||||
* are unaffected.
|
||||
*/
|
||||
capStatusLevel(level: LogLevel): this {
|
||||
this.statusLevelCeiling = level;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Request/Operation beenden, Duration berechnen */
|
||||
finish(httpStatus?: number): this {
|
||||
this.event.duration_ms = Math.round(performance.now() - this.startTime);
|
||||
@@ -256,8 +269,16 @@ export class WideEventBuilder {
|
||||
this.event.http.status = httpStatus;
|
||||
}
|
||||
if (httpStatus !== undefined) {
|
||||
if (httpStatus >= 500) this.elevateLevel("error");
|
||||
else if (httpStatus >= 400) this.elevateLevel("warn");
|
||||
const derived: LogLevel | null =
|
||||
httpStatus >= 500 ? "error" : httpStatus >= 400 ? "warn" : null;
|
||||
if (derived) {
|
||||
this.elevateLevel(
|
||||
LOG_LEVEL_PRIORITY[derived] >
|
||||
LOG_LEVEL_PRIORITY[this.statusLevelCeiling]
|
||||
? this.statusLevelCeiling
|
||||
: derived,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.event.level = this.highestLevel;
|
||||
return this;
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { WideEvent } from "./types";
|
||||
import { getLoggingConfig } from "./config";
|
||||
import { shouldEmit } from "./sampler";
|
||||
import { appendLogEvent } from "./in-memory-buffer";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
import { canonicalOrigin } from "@/lib/private-origin-policy";
|
||||
|
||||
/** Event auf stdout als einzelne JSON-Zeile schreiben */
|
||||
function emitToStdout(event: WideEvent): void {
|
||||
@@ -31,7 +32,133 @@ function initLokiTransport(): void {
|
||||
if (lokiFlushTimer.unref) lokiFlushTimer.unref();
|
||||
}
|
||||
|
||||
async function flushLokiBuffer(): Promise<void> {
|
||||
const LOKI_PUSH_PATH = "/loki/api/v1/push";
|
||||
const LOKI_PUSH_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface LokiPushTarget {
|
||||
/** The URL the batch is POSTed to. */
|
||||
url: string;
|
||||
/**
|
||||
* The exact origin handed to `safeFetch` as the operator approval. Also the
|
||||
* only part of the endpoint that ever appears in a failure line: it carries
|
||||
* no userinfo, path or query by construction.
|
||||
*/
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export type LokiTargetResolution =
|
||||
| { ok: true; target: LokiPushTarget }
|
||||
| {
|
||||
ok: false;
|
||||
reason: "invalid_endpoint" | "never_grantable";
|
||||
shown: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn `LOKI_ENDPOINT` into the push URL and the origin it may dial.
|
||||
*
|
||||
* Both spellings an operator reaches for work: the base URL
|
||||
* (`http://loki:3100`, the push path is appended) and the full push URL
|
||||
* (`http://loki:3100/loki/api/v1/push`, used as is, trailing slash or not).
|
||||
* Appending unconditionally turned the second form into
|
||||
* `/loki/api/v1/push/loki/api/v1/push` and a 404.
|
||||
*
|
||||
* Why the endpoint dials as an operator-approved origin rather than through
|
||||
* the public-host pin: `LOKI_ENDPOINT` is read from the server environment in
|
||||
* `config.ts` and nowhere else. No request field, settings row or admin form
|
||||
* can set it, so there is no user-supplied host to defend against, and the
|
||||
* public-only pin was refusing the common deployment (a Loki on the same LAN
|
||||
* or Docker network) without a word. The exact origin still goes through the
|
||||
* pinned operator resolver in `safeFetch` with redirects forbidden, and the
|
||||
* never-grantable floor holds: an endpoint whose literal host is the
|
||||
* unspecified address, link-local or the metadata range is refused here
|
||||
* (`canonicalOrigin`), and a name that resolves there is dropped at dial time.
|
||||
*/
|
||||
export function resolveLokiPushTarget(endpoint: string): LokiTargetResolution {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint.trim());
|
||||
} catch {
|
||||
return { ok: false, reason: "invalid_endpoint", shown: "(unparseable)" };
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return { ok: false, reason: "invalid_endpoint", shown: url.protocol };
|
||||
}
|
||||
// `url.origin` never carries userinfo, so it is safe to print even when
|
||||
// the endpoint itself is refused for carrying credentials.
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
return { ok: false, reason: "invalid_endpoint", shown: url.origin };
|
||||
}
|
||||
const origin = canonicalOrigin(url.origin);
|
||||
if (!origin) {
|
||||
return { ok: false, reason: "never_grantable", shown: url.origin };
|
||||
}
|
||||
const basePath = url.pathname.replace(/\/+$/, "");
|
||||
const pushPath = basePath.endsWith(LOKI_PUSH_PATH)
|
||||
? basePath
|
||||
: `${basePath}${LOKI_PUSH_PATH}`;
|
||||
return { ok: true, target: { url: `${origin}${pushPath}`, origin } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure notices for the Loki push, written straight to stderr.
|
||||
*
|
||||
* Never through `emitEvent`: a failing Loki push that reported itself as a
|
||||
* wide event would buffer that event for the same failing push. At most one
|
||||
* line per reason per window, so a Loki that is down for an hour costs twelve
|
||||
* lines, not seven hundred. Events dropped while a reason is quiet are counted
|
||||
* and reported on its next line, so the numbers add up.
|
||||
*/
|
||||
export const LOKI_FAILURE_NOTICE_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const lokiFailureNotices = new Map<
|
||||
string,
|
||||
{ lastAt: number; droppedSinceLast: number }
|
||||
>();
|
||||
|
||||
function reportLokiFailure(
|
||||
shownEndpoint: string,
|
||||
reason: string,
|
||||
droppedEvents: number,
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const entry = lokiFailureNotices.get(reason);
|
||||
if (entry && now - entry.lastAt < LOKI_FAILURE_NOTICE_INTERVAL_MS) {
|
||||
entry.droppedSinceLast += droppedEvents;
|
||||
return;
|
||||
}
|
||||
const dropped = droppedEvents + (entry?.droppedSinceLast ?? 0);
|
||||
lokiFailureNotices.set(reason, { lastAt: now, droppedSinceLast: 0 });
|
||||
process.stderr.write(
|
||||
`[logging] Loki push to ${shownEndpoint} failed (${reason}); ` +
|
||||
`${dropped} event${dropped === 1 ? "" : "s"} dropped. ` +
|
||||
`Repeats of this failure are reported at most every 5 minutes.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure reason as a short class, never the error message: messages
|
||||
* from the fetch stack can embed the target URL, and this line goes to stderr
|
||||
* unredacted. A socket error code (`ECONNREFUSED`, `ENOTFOUND`) is kept
|
||||
* because it is the part that tells an operator what to fix.
|
||||
*/
|
||||
function classifyLokiError(err: unknown): string {
|
||||
const kind = err instanceof SafeFetchError ? err.kind : "error";
|
||||
let cause: unknown = err instanceof SafeFetchError ? err.cause : err;
|
||||
for (
|
||||
let depth = 0;
|
||||
depth < 4 && cause && typeof cause === "object";
|
||||
depth++
|
||||
) {
|
||||
const code = (cause as { code?: unknown }).code;
|
||||
if (typeof code === "string" && /^[A-Z][A-Z0-9_]{1,40}$/.test(code)) {
|
||||
return `${kind} ${code}`;
|
||||
}
|
||||
cause = (cause as { cause?: unknown }).cause;
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
export async function flushLokiBuffer(): Promise<void> {
|
||||
if (lokiBuffer.length === 0) return;
|
||||
const config = getLoggingConfig();
|
||||
if (!config.lokiEndpoint) return;
|
||||
@@ -39,6 +166,13 @@ async function flushLokiBuffer(): Promise<void> {
|
||||
const batch = lokiBuffer;
|
||||
lokiBuffer = [];
|
||||
|
||||
const resolved = resolveLokiPushTarget(config.lokiEndpoint);
|
||||
if (!resolved.ok) {
|
||||
reportLokiFailure(resolved.shown, resolved.reason, batch.length);
|
||||
return;
|
||||
}
|
||||
const { target } = resolved;
|
||||
|
||||
const streams = [
|
||||
{
|
||||
stream: {
|
||||
@@ -64,21 +198,41 @@ async function flushLokiBuffer(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
await safeFetch(
|
||||
`${config.lokiEndpoint}/loki/api/v1/push`,
|
||||
res = await safeFetch(
|
||||
target.url,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ streams }),
|
||||
},
|
||||
// Operator-configured Loki endpoint — pin the connect-time IP
|
||||
// against DNS rebinding.
|
||||
{ timeoutMs: 10_000, requirePublicHost: true },
|
||||
// Operator-configured endpoint: dial exactly its origin through the
|
||||
// pinned operator resolver (see `resolveLokiPushTarget`).
|
||||
{
|
||||
timeoutMs: LOKI_PUSH_TIMEOUT_MS,
|
||||
operatorApprovedPrivateOrigin: target.origin,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Events gehen verloren — akzeptabel fuer Logging
|
||||
} catch (err) {
|
||||
// The batch is already detached from the buffer; say so rather than
|
||||
// losing it without a trace.
|
||||
reportLokiFailure(target.origin, classifyLokiError(err), batch.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Loki answers 204. Anything else, including a 3xx the forbidden redirect
|
||||
// left unfollowed, means the batch did not land.
|
||||
if (!res.ok) {
|
||||
reportLokiFailure(target.origin, `HTTP ${res.status}`, batch.length);
|
||||
}
|
||||
await res.body?.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
/** Test helper: clear the buffer and the failure-notice windows. */
|
||||
export function _resetLokiTransportForTests(): void {
|
||||
lokiBuffer = [];
|
||||
lokiFailureNotices.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
codeForUpstreamStatus,
|
||||
testFailureDetail,
|
||||
testFailureSentence,
|
||||
} from "@/lib/notifications/test-delivery-failure";
|
||||
|
||||
describe("codeForUpstreamStatus", () => {
|
||||
it.each([
|
||||
[400, "upstream_rejected"],
|
||||
[401, "credentials_rejected"],
|
||||
[403, "credentials_rejected"],
|
||||
[404, "endpoint_not_found"],
|
||||
[405, "upstream_rejected"],
|
||||
[409, "upstream_rejected"],
|
||||
[410, "endpoint_not_found"],
|
||||
[413, "upstream_rejected"],
|
||||
[415, "upstream_rejected"],
|
||||
[422, "upstream_rejected"],
|
||||
[429, "rate_limited"],
|
||||
[500, "upstream_error"],
|
||||
[502, "upstream_error"],
|
||||
[301, "redirected"],
|
||||
[308, "redirected"],
|
||||
])("%i → %s", (status, code) => {
|
||||
expect(codeForUpstreamStatus(status)).toBe(code);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testFailureDetail", () => {
|
||||
it("prefers the HTTP status over any transport code", () => {
|
||||
expect(
|
||||
testFailureDetail({
|
||||
ok: false,
|
||||
statusCode: 400,
|
||||
failureCode: "timeout",
|
||||
upstreamBody: "bad",
|
||||
}),
|
||||
).toEqual({
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
upstreamBody: "bad",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the sender's code when there is no status", () => {
|
||||
expect(
|
||||
testFailureDetail({
|
||||
ok: false,
|
||||
failureCode: "upstream_rejected",
|
||||
smtpCode: 550,
|
||||
}),
|
||||
).toEqual({ errorCode: "upstream_rejected", smtpCode: 550 });
|
||||
});
|
||||
|
||||
it("names nothing for an unnamed failure", () => {
|
||||
expect(testFailureDetail({ ok: false, reason: "x" })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("testFailureSentence", () => {
|
||||
it("states the status, and falls back to a plain sentence for an unlisted code", () => {
|
||||
expect(
|
||||
testFailureSentence("The webhook", {
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
}),
|
||||
).toBe("The webhook answered HTTP 400.");
|
||||
expect(
|
||||
testFailureSentence("The webhook", { errorCode: "something_new" }),
|
||||
).toBe("The webhook test failed.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
readUpstreamBody,
|
||||
secretsInHeaderValue,
|
||||
secretsInUrl,
|
||||
UPSTREAM_BODY_MAX_CHARS,
|
||||
} from "@/lib/notifications/upstream-body";
|
||||
|
||||
function res(body: string, status = 400): Response {
|
||||
return new Response(body, { status });
|
||||
}
|
||||
|
||||
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
|
||||
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
|
||||
const BELL = String.fromCodePoint(0x07);
|
||||
|
||||
describe("readUpstreamBody", () => {
|
||||
it("returns a short JSON error body as it arrived", async () => {
|
||||
const body =
|
||||
'{"error":"Bad Request","errorCode":400,"errorDescription":"priority must be an integer"}';
|
||||
await expect(readUpstreamBody(res(body), [])).resolves.toBe(body);
|
||||
});
|
||||
|
||||
it("removes control and bidirectional characters and collapses whitespace", async () => {
|
||||
const body = `line one\r\n\tline two ${RIGHT_TO_LEFT_OVERRIDE}evil${ZERO_WIDTH_SPACE}${BELL}`;
|
||||
await expect(readUpstreamBody(res(body), [])).resolves.toBe(
|
||||
"line one line two evil",
|
||||
);
|
||||
});
|
||||
|
||||
it("cuts a long body to the cap, marked with an ellipsis", async () => {
|
||||
const result = await readUpstreamBody(res("x".repeat(5000)), []);
|
||||
expect(Array.from(result ?? "")).toHaveLength(UPSTREAM_BODY_MAX_CHARS);
|
||||
expect(result?.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("reads at most a bounded prefix of an endless body", async () => {
|
||||
let pulled = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pulled += 1;
|
||||
controller.enqueue(new TextEncoder().encode("y".repeat(1024)));
|
||||
},
|
||||
});
|
||||
const result = await readUpstreamBody(
|
||||
new Response(stream, { status: 502 }),
|
||||
[],
|
||||
);
|
||||
expect(result?.startsWith("yyyy")).toBe(true);
|
||||
// 4 KB in 1 KB chunks: the body is abandoned after a few pulls.
|
||||
expect(pulled).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it("refuses a secret-shaped body", async () => {
|
||||
await expect(
|
||||
readUpstreamBody(res('{"error":"bad key sk-ant-api03-abcdefgh"}'), []),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a body that echoes a configured secret", async () => {
|
||||
await expect(
|
||||
readUpstreamBody(res("token AbCdEf123 is not valid"), [
|
||||
undefined,
|
||||
"AbCdEf123",
|
||||
]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a configured value too short to be a secret", async () => {
|
||||
await expect(
|
||||
readUpstreamBody(res("priority 5 rejected"), ["5"]),
|
||||
).resolves.toBe("priority 5 rejected");
|
||||
});
|
||||
|
||||
it("returns nothing for an empty or unreadable body", async () => {
|
||||
await expect(readUpstreamBody(res(" \n"), [])).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readUpstreamBody({ ok: false, status: 400 } as Response, []),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readUpstreamBody(
|
||||
{
|
||||
text: () => Promise.reject(new Error("aborted")),
|
||||
} as unknown as Response,
|
||||
[],
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret extraction", () => {
|
||||
it("finds query values and userinfo in a URL", () => {
|
||||
expect(
|
||||
secretsInUrl(
|
||||
"https://user:pa%20ss@gotify.example.com/message?token=Abc123",
|
||||
),
|
||||
).toEqual(expect.arrayContaining(["Abc123", "user", "pa%20ss", "pa ss"]));
|
||||
expect(secretsInUrl("not a url")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the token of a scheme-prefixed header value", () => {
|
||||
expect(secretsInHeaderValue("Bearer tok123")).toEqual([
|
||||
"Bearer tok123",
|
||||
"tok123",
|
||||
]);
|
||||
expect(secretsInHeaderValue("AppToken")).toEqual(["AppToken"]);
|
||||
expect(secretsInHeaderValue(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret matching survives encoding and malformed parts (review L4)", () => {
|
||||
it("keeps the query token when the userinfo holds a malformed escape", () => {
|
||||
const found = secretsInUrl(
|
||||
"https://us%ZZer:pw@gotify.example.com/message?token=Abc12345",
|
||||
);
|
||||
expect(found).toContain("Abc12345");
|
||||
expect(found).toContain("us%ZZer");
|
||||
});
|
||||
|
||||
it("keeps the other query values when one holds a malformed escape", () => {
|
||||
const found = secretsInUrl(
|
||||
"https://gotify.example.com/message?a=%ZZ&token=Abc12345",
|
||||
);
|
||||
expect(found).toContain("Abc12345");
|
||||
expect(found).toContain("%ZZ");
|
||||
});
|
||||
|
||||
it("treats long path segments as secrets and short ones not", () => {
|
||||
const found = secretsInUrl(
|
||||
"https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrSt",
|
||||
);
|
||||
expect(found).toContain("123456789012345678");
|
||||
expect(found).toContain("AbCdEfGhIjKlMnOpQrSt");
|
||||
expect(found).not.toContain("webhooks");
|
||||
expect(found).not.toContain("api");
|
||||
});
|
||||
|
||||
it("refuses a body echoing a path token", async () => {
|
||||
await expect(
|
||||
readUpstreamBody(
|
||||
res('{"message":"Unknown Webhook AbCdEfGhIjKlMnOpQrSt"}', 404),
|
||||
secretsInUrl(
|
||||
"https://discord.com/api/webhooks/123/AbCdEfGhIjKlMnOpQrSt",
|
||||
),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a JSON-escaped echo with an escaped slash", async () => {
|
||||
await expect(
|
||||
readUpstreamBody(res('{"auth":"Basic dXNlcj\\/wYXNz"}'), [
|
||||
"dXNlcj/wYXNz",
|
||||
]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a JSON-escaped echo with an escaped quote", async () => {
|
||||
await expect(
|
||||
readUpstreamBody(res('{"got":"ab\\"cd1234"}'), ['ab"cd1234']),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a percent-encoded echo, in either hex case", async () => {
|
||||
await expect(
|
||||
readUpstreamBody(res("bad token p%40ss%20word!"), ["p@ss word!"]),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readUpstreamBody(res("bad token a%2fb%2Bc1234"), ["a/b+c1234"]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,22 @@ export interface SendOutcome {
|
||||
* instead of a bare 500.
|
||||
*/
|
||||
errorCode?: string;
|
||||
/**
|
||||
* Why a delivery failed when there is no HTTP status to say it: a timeout,
|
||||
* a refused connection, an SMTP reply. The value is a code from the test
|
||||
* card's vocabulary (`settings.testConnection.errors`), so a test route can
|
||||
* forward it as `meta.errorCode` without translating. Absent when the
|
||||
* sender cannot tell, which the test routes treat as an internal failure.
|
||||
*/
|
||||
failureCode?: string;
|
||||
/** SMTP reply code of a rejected send (email only). */
|
||||
smtpCode?: number;
|
||||
/**
|
||||
* What the other end answered on a non-2xx, bounded and cleaned by
|
||||
* `readUpstreamBody`, and absent when it might carry a secret. Only the
|
||||
* test routes read it; neither the ledger nor the channel state stores it.
|
||||
*/
|
||||
upstreamBody?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -170,3 +170,110 @@ describe("sendViaEmail", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendViaEmail — the failure names itself for the test button", () => {
|
||||
it.each([
|
||||
[
|
||||
"an authentication refusal",
|
||||
Object.assign(new Error("Invalid login"), {
|
||||
code: "EAUTH",
|
||||
responseCode: 535,
|
||||
}),
|
||||
"credentials_rejected",
|
||||
535,
|
||||
],
|
||||
[
|
||||
"a timeout",
|
||||
Object.assign(new Error("Connection timeout"), { code: "ETIMEDOUT" }),
|
||||
"timeout",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"a refused connection",
|
||||
Object.assign(new Error("connect ECONNREFUSED"), {
|
||||
code: "ECONNECTION",
|
||||
}),
|
||||
"connection_failed",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"a permanent SMTP rejection",
|
||||
Object.assign(new Error("550 mailbox unavailable"), {
|
||||
responseCode: 550,
|
||||
}),
|
||||
"upstream_rejected",
|
||||
550,
|
||||
],
|
||||
[
|
||||
"a transient SMTP rejection",
|
||||
Object.assign(new Error("451 try later"), { responseCode: 451 }),
|
||||
"upstream_error",
|
||||
451,
|
||||
],
|
||||
])("%s", async (_label, err, failureCode, smtpCode) => {
|
||||
loadEmailConfigMock.mockReturnValue(transport);
|
||||
sendMailMock.mockRejectedValue(err);
|
||||
|
||||
const result = await sendViaEmail(
|
||||
{ recipient: "you@example.com" },
|
||||
payload(),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failureCode).toBe(failureCode);
|
||||
expect(result.smtpCode).toBe(smtpCode);
|
||||
});
|
||||
|
||||
it("leaves an unrecognised fault without a code", async () => {
|
||||
loadEmailConfigMock.mockReturnValue(transport);
|
||||
sendMailMock.mockRejectedValue(new Error("something else"));
|
||||
|
||||
const result = await sendViaEmail(
|
||||
{ recipient: "you@example.com" },
|
||||
payload(),
|
||||
);
|
||||
|
||||
expect(result.failureCode).toBeUndefined();
|
||||
expect(result.smtpCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendViaEmail — transport and envelope faults are named too", () => {
|
||||
it.each([
|
||||
["a TLS handshake failure", "ETLS", undefined, "connection_failed"],
|
||||
["a protocol error", "EPROTOCOL", undefined, "connection_failed"],
|
||||
[
|
||||
"an envelope refused without a code",
|
||||
"EENVELOPE",
|
||||
undefined,
|
||||
"upstream_rejected",
|
||||
],
|
||||
[
|
||||
"a message refused without a code",
|
||||
"EMESSAGE",
|
||||
undefined,
|
||||
"upstream_rejected",
|
||||
],
|
||||
[
|
||||
"an envelope refused with a temporary code",
|
||||
"EENVELOPE",
|
||||
451,
|
||||
"upstream_error",
|
||||
],
|
||||
])("%s", async (_label, code, responseCode, failureCode) => {
|
||||
loadEmailConfigMock.mockReturnValue(transport);
|
||||
sendMailMock.mockRejectedValue(
|
||||
Object.assign(new Error(String(code)), {
|
||||
code,
|
||||
...(responseCode !== undefined ? { responseCode } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sendViaEmail(
|
||||
{ recipient: "you@example.com" },
|
||||
payload(),
|
||||
);
|
||||
|
||||
expect(result.failureCode).toBe(failureCode);
|
||||
});
|
||||
});
|
||||
@@ -233,3 +233,38 @@ describe("sendViaNtfy", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendViaNtfy — what the server said (#947)", () => {
|
||||
it("carries a short error body on a non-2xx", async () => {
|
||||
safeFetchMock.mockResolvedValue(
|
||||
new Response('{"code":40009,"http":400,"error":"invalid request"}', {
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sendViaNtfy(config, payload());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
statusCode: 400,
|
||||
upstreamBody: '{"code":40009,"http":400,"error":"invalid request"}',
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a body that echoes the auth token or the topic", async () => {
|
||||
safeFetchMock.mockResolvedValueOnce(
|
||||
new Response("token tk_secret rejected", { status: 403 }),
|
||||
);
|
||||
expect((await sendViaNtfy(config, payload())).upstreamBody).toBeUndefined();
|
||||
|
||||
safeFetchMock.mockResolvedValueOnce(
|
||||
new Response("topic health is reserved", { status: 403 }),
|
||||
);
|
||||
expect((await sendViaNtfy(config, payload())).upstreamBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names a timeout", async () => {
|
||||
safeFetchMock.mockRejectedValue(new SafeFetchError("timed out", "timeout"));
|
||||
expect((await sendViaNtfy(config, payload())).failureCode).toBe("timeout");
|
||||
});
|
||||
});
|
||||
@@ -281,3 +281,178 @@ describe("sendViaWebhook", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendViaWebhook — what the relay said (#947)", () => {
|
||||
it("carries a short error body on a non-2xx", async () => {
|
||||
safeFetchMock.mockResolvedValue(
|
||||
new Response('{"error":"Bad Request","errorCode":400}', { status: 400 }),
|
||||
);
|
||||
|
||||
const result = await sendViaWebhook(config, payload());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
statusCode: 400,
|
||||
reason: "webhook_400",
|
||||
upstreamBody: '{"error":"Bad Request","errorCode":400}',
|
||||
});
|
||||
// The ledger row keeps the reason only, never the body.
|
||||
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ upstreamBody: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a body that echoes the header token or a query token", async () => {
|
||||
safeFetchMock.mockResolvedValueOnce(
|
||||
new Response("invalid token secret", { status: 401 }),
|
||||
);
|
||||
const viaHeader = await sendViaWebhook(config, payload());
|
||||
expect(viaHeader.upstreamBody).toBeUndefined();
|
||||
|
||||
safeFetchMock.mockResolvedValueOnce(
|
||||
new Response("unknown token Q1w2E3r4", { status: 401 }),
|
||||
);
|
||||
const viaQuery = await sendViaWebhook(
|
||||
{ url: "https://relay.example.com/message?token=Q1w2E3r4" },
|
||||
payload(),
|
||||
);
|
||||
expect(viaQuery.upstreamBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names a timeout and a refused connection, and leaves an unknown fault uncoded", async () => {
|
||||
safeFetchMock.mockRejectedValueOnce(
|
||||
new SafeFetchError("timed out", "timeout"),
|
||||
);
|
||||
expect((await sendViaWebhook(config, payload())).failureCode).toBe(
|
||||
"timeout",
|
||||
);
|
||||
|
||||
safeFetchMock.mockRejectedValueOnce(
|
||||
new SafeFetchError("ECONNREFUSED", "network"),
|
||||
);
|
||||
expect((await sendViaWebhook(config, payload())).failureCode).toBe(
|
||||
"connection_failed",
|
||||
);
|
||||
|
||||
safeFetchMock.mockRejectedValueOnce(new TypeError("boom"));
|
||||
expect(
|
||||
(await sendViaWebhook(config, payload())).failureCode,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function sentBody(): string {
|
||||
return (safeFetchMock.mock.calls[0][1] as { body: string }).body;
|
||||
}
|
||||
|
||||
describe("sendViaWebhook — generic body stays byte for byte", () => {
|
||||
// Literal strings, not re-parsed objects: a Home Assistant or n8n rule
|
||||
// that matches on the raw body must keep matching after the format choice
|
||||
// was added.
|
||||
it.each([
|
||||
[
|
||||
"a routine event",
|
||||
{},
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"}',
|
||||
],
|
||||
[
|
||||
"a medication reminder",
|
||||
{ eventType: "MEDICATION_REMINDER" },
|
||||
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"}',
|
||||
],
|
||||
[
|
||||
"an urgent event",
|
||||
{ urgent: true },
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"}',
|
||||
],
|
||||
[
|
||||
"a discreet cycle event",
|
||||
{ eventType: "CYCLE_PERIOD_SOON", discreet: true },
|
||||
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"}',
|
||||
],
|
||||
])("%s", async (_label, over, expected) => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(
|
||||
{ url: "https://relay.example.com/hook" },
|
||||
payload(over),
|
||||
);
|
||||
|
||||
expect(sentBody()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendViaWebhook — Gotify format (#947)", () => {
|
||||
const gotify = {
|
||||
url: "https://gotify.example.com/message",
|
||||
headerName: "X-Gotify-Key",
|
||||
headerValue: "AbCdEfGh123",
|
||||
format: "gotify" as const,
|
||||
};
|
||||
|
||||
it("sends the body Gotify binds, with an integer priority and no eventType field", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(gotify, payload({ message: "<b>Body</b>" }));
|
||||
|
||||
expect(JSON.parse(sentBody())).toEqual({
|
||||
title: "Title",
|
||||
message: "Body",
|
||||
priority: 5,
|
||||
extras: {
|
||||
"client::display": { contentType: "text/plain" },
|
||||
"healthlog::event": { type: "SYSTEM_ALERT" },
|
||||
},
|
||||
});
|
||||
const [url, init] = safeFetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
];
|
||||
expect(url).toBe("https://gotify.example.com/message");
|
||||
expect(init.headers["X-Gotify-Key"]).toBe("AbCdEfGh123");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a routine event", 5, {}],
|
||||
["a medication reminder", 8, { eventType: "MEDICATION_REMINDER" }],
|
||||
["an urgent event", 10, { urgent: true }],
|
||||
[
|
||||
"an urgent medication reminder",
|
||||
10,
|
||||
{ eventType: "MEDICATION_REMINDER", urgent: true },
|
||||
],
|
||||
])("maps %s to priority %i", async (_label, priority, over) => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(gotify, payload(over));
|
||||
|
||||
const body = JSON.parse(sentBody());
|
||||
expect(body.priority).toBe(priority);
|
||||
expect(Number.isInteger(body.priority)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the cycle event name out of a discreet message", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(
|
||||
gotify,
|
||||
payload({ eventType: "CYCLE_PERIOD_SOON", discreet: true }),
|
||||
);
|
||||
|
||||
expect(sentBody()).not.toContain("CYCLE");
|
||||
expect(JSON.parse(sentBody()).extras["healthlog::event"]).toEqual({
|
||||
type: "reminder",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an unknown stored format as generic", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(
|
||||
{ url: "https://relay.example.com/hook", format: "bogus" as never },
|
||||
payload(),
|
||||
);
|
||||
|
||||
expect(JSON.parse(sentBody()).priority).toBe("default");
|
||||
});
|
||||
});
|
||||
@@ -79,19 +79,55 @@ function classifySmtpError(err: unknown): {
|
||||
hardReject: boolean;
|
||||
reason: string;
|
||||
message: string;
|
||||
failureCode?: string;
|
||||
smtpCode?: number;
|
||||
} {
|
||||
const message = err instanceof Error ? err.message : "smtp_send_failed";
|
||||
// nodemailer attaches `responseCode` (the SMTP reply code) on protocol-level
|
||||
// rejections. 5xx = permanent (bad mailbox, relay denied) → hard reject so
|
||||
// the channel auto-disables. 4xx / connection errors = transient → retry.
|
||||
// rejections and a `code` string on transport faults. 5xx = permanent (bad
|
||||
// mailbox, relay denied) → hard reject so the channel auto-disables.
|
||||
// 4xx / connection errors = transient → retry.
|
||||
const responseCode =
|
||||
typeof err === "object" && err !== null && "responseCode" in err
|
||||
? (err as { responseCode?: number }).responseCode
|
||||
: undefined;
|
||||
if (typeof responseCode === "number" && responseCode >= 500) {
|
||||
return { hardReject: true, reason: "email_smtp_5xx", message };
|
||||
const code =
|
||||
typeof err === "object" && err !== null && "code" in err
|
||||
? (err as { code?: unknown }).code
|
||||
: undefined;
|
||||
const smtpCode = typeof responseCode === "number" ? responseCode : undefined;
|
||||
// The test card's vocabulary, so the test route can say which it was.
|
||||
// Anything unrecognised stays without a code.
|
||||
const failureCode =
|
||||
code === "EAUTH"
|
||||
? "credentials_rejected"
|
||||
: code === "ETIMEDOUT"
|
||||
? "timeout"
|
||||
: code === "ECONNECTION" ||
|
||||
code === "ESOCKET" ||
|
||||
code === "EDNS" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
// STARTTLS or TLS handshake failure (typically port 465 set up
|
||||
// as 587 or the reverse) and a server that does not speak SMTP.
|
||||
code === "ETLS" ||
|
||||
code === "EPROTOCOL"
|
||||
? "connection_failed"
|
||||
: smtpCode !== undefined && smtpCode >= 500
|
||||
? "upstream_rejected"
|
||||
: smtpCode !== undefined && smtpCode >= 400
|
||||
? "upstream_error"
|
||||
: // An envelope or message refused without a reply code.
|
||||
code === "EENVELOPE" || code === "EMESSAGE"
|
||||
? "upstream_rejected"
|
||||
: undefined;
|
||||
const extras = {
|
||||
...(failureCode ? { failureCode } : {}),
|
||||
...(smtpCode !== undefined ? { smtpCode } : {}),
|
||||
};
|
||||
if (smtpCode !== undefined && smtpCode >= 500) {
|
||||
return { hardReject: true, reason: "email_smtp_5xx", message, ...extras };
|
||||
}
|
||||
return { hardReject: false, reason: "email_smtp_error", message };
|
||||
return { hardReject: false, reason: "email_smtp_error", message, ...extras };
|
||||
}
|
||||
|
||||
export async function sendViaEmail(
|
||||
@@ -169,6 +205,12 @@ export async function sendViaEmail(
|
||||
hardReject: classified.hardReject,
|
||||
reason: classified.reason,
|
||||
message: classified.message,
|
||||
...(classified.failureCode
|
||||
? { failureCode: classified.failureCode }
|
||||
: {}),
|
||||
...(classified.smtpCode !== undefined
|
||||
? { smtpCode: classified.smtpCode }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
} from "@/lib/notifications/egress-policy";
|
||||
import type { OriginReason } from "@/lib/private-origin-policy";
|
||||
import {
|
||||
readUpstreamBody,
|
||||
secretsInUrl,
|
||||
} from "@/lib/notifications/upstream-body";
|
||||
|
||||
/**
|
||||
* Send notification via ntfy (simple HTTP POST).
|
||||
@@ -129,11 +133,19 @@ export async function sendViaNtfy(
|
||||
result: "error",
|
||||
reason: classified.reason,
|
||||
});
|
||||
// What the server said, for the test button. The topic is treated as a
|
||||
// secret too: on a public ntfy server it is the only access control.
|
||||
const upstreamBody = await readUpstreamBody(res, [
|
||||
config.authToken,
|
||||
config.topic,
|
||||
...secretsInUrl(config.serverUrl),
|
||||
]);
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: res.status,
|
||||
hardReject: classified.hardReject,
|
||||
reason: classified.reason,
|
||||
...(upstreamBody ? { upstreamBody } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "request_failed";
|
||||
@@ -144,6 +156,12 @@ export async function sendViaNtfy(
|
||||
const reason = policyRefused
|
||||
? "ntfy_private_origin_refused"
|
||||
: "ntfy_network_error";
|
||||
const failureCode =
|
||||
err instanceof SafeFetchError && !policyRefused
|
||||
? err.kind === "timeout"
|
||||
? "timeout"
|
||||
: "connection_failed"
|
||||
: undefined;
|
||||
getEvent()?.addExternalCall({
|
||||
service: "ntfy",
|
||||
method: "sendNotification",
|
||||
@@ -165,6 +183,7 @@ export async function sendViaNtfy(
|
||||
...(policyRefused
|
||||
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
|
||||
: {}),
|
||||
...(failureCode ? { failureCode } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,94 @@ import {
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
} from "@/lib/notifications/egress-policy";
|
||||
import type { OriginReason } from "@/lib/private-origin-policy";
|
||||
import {
|
||||
readUpstreamBody,
|
||||
secretsInHeaderValue,
|
||||
secretsInUrl,
|
||||
} from "@/lib/notifications/upstream-body";
|
||||
|
||||
type PriorityBand = "urgent" | "high" | "default";
|
||||
|
||||
/**
|
||||
* v1.18.4: an explicitly urgent event is `urgent` so a relay rule can
|
||||
* escalate; MEDICATION_REMINDER keeps `high`; the rest `default`.
|
||||
*/
|
||||
function priorityBand(payload: NotificationPayload): PriorityBand {
|
||||
if (payload.urgent === true) return "urgent";
|
||||
if (payload.eventType === "MEDICATION_REMINDER") return "high";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gotify's priority is an integer, and its Android client files a message
|
||||
* into a notification channel by it (`convertPriorityToChannel` in
|
||||
* gotify/android): below 1 minimum importance, 1-3 low (no sound), 4-7
|
||||
* default (sound), 8 and above high (sound, vibration, and a heads-up banner).
|
||||
*
|
||||
* The bands mirror what the ntfy sender asks for:
|
||||
* - `default` → 5: audible, like ntfy's default. Not 1-3, which would
|
||||
* make every routine reminder silent.
|
||||
* - `high` (medication reminders) → 8: the lowest value that reaches the
|
||||
* heads-up channel, as ntfy's `high` pops over.
|
||||
* - `urgent` → 10: the same Android channel as 8, and the top of the scale
|
||||
* for any client or rule that orders or filters by priority, as ntfy's 5.
|
||||
*/
|
||||
const GOTIFY_PRIORITY: Record<PriorityBand, number> = {
|
||||
default: 5,
|
||||
high: 8,
|
||||
urgent: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* The request body for the configured format.
|
||||
*
|
||||
* Discreet mode (cycle privacy) is honoured the same way in both: the title
|
||||
* and message arrive already masked upstream, and the event name a relay
|
||||
* could route on is replaced by the generic `reminder`.
|
||||
*/
|
||||
export function buildWebhookBody(
|
||||
config: Pick<WebhookChannelConfig, "format">,
|
||||
payload: NotificationPayload,
|
||||
): string {
|
||||
const title = plainPushText(payload.title, payload.eventType);
|
||||
const message = plainPushText(stripHtml(payload.message), payload.eventType);
|
||||
const eventType = payload.discreet ? "reminder" : payload.eventType;
|
||||
const band = priorityBand(payload);
|
||||
|
||||
if (config.format === "gotify") {
|
||||
return JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
priority: GOTIFY_PRIORITY[band],
|
||||
extras: {
|
||||
// Plain text, stated rather than left to the client default: the
|
||||
// body is never markdown (hard rule).
|
||||
"client::display": { contentType: "text/plain" },
|
||||
"healthlog::event": { type: eventType },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Generic envelope. Discord and Slack ignore unknown keys, so one shape
|
||||
// covers the common JSON relays. Unchanged since v1.18.4: an existing
|
||||
// Home Assistant or n8n rule parses these exact fields.
|
||||
return JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
eventType,
|
||||
priority: band,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification via a generic outbound webhook (v1.17.1).
|
||||
*
|
||||
* The user supplies a public URL (and optionally one custom header — e.g.
|
||||
* `Authorization: Bearer <token>` for Gotify). We POST a small JSON envelope
|
||||
* that Gotify / Discord / Slack / Matrix-bridge / Home Assistant / any homelab
|
||||
* relay can consume. The body is plain text (no markdown — hard rule); the
|
||||
* `title`/`message` fields are stripped of HTML + decorative emoji on routine
|
||||
* reminders exactly like the ntfy sender.
|
||||
* The user supplies a URL and optionally one custom header, and chooses the
|
||||
* body shape: HealthLog's generic JSON envelope, or the body Gotify's
|
||||
* `POST /message` binds (see `buildWebhookBody`). Gotify takes its app token
|
||||
* as an `X-Gotify-Key` header or a `token` query parameter. The body is plain
|
||||
* text (no markdown — hard rule); `title`/`message` are stripped of HTML and
|
||||
* decorative emoji on routine reminders exactly like the ntfy sender.
|
||||
*
|
||||
* Outbound goes through `safeFetch` with the connect-time DNS-rebinding pin
|
||||
* because the host is user-supplied; the optional shared-secret header would
|
||||
@@ -58,25 +136,7 @@ export async function sendViaWebhook(
|
||||
headers[config.headerName] = config.headerValue;
|
||||
}
|
||||
|
||||
// Plain-text envelope. The `title` field doubles as Gotify's `title` and
|
||||
// Discord/Slack ignore unknown keys, so a single shape covers the common
|
||||
// relays. Discreet mode (cycle privacy) is honoured by the title/body
|
||||
// already being masked upstream; we also send a generic `eventType` tag so
|
||||
// a relay rule can route without leaking the cycle event name.
|
||||
const body = JSON.stringify({
|
||||
title: plainPushText(payload.title, payload.eventType),
|
||||
message: plainPushText(stripHtml(payload.message), payload.eventType),
|
||||
eventType: payload.discreet ? "reminder" : payload.eventType,
|
||||
// v1.18.4 — an explicitly urgent event maps to `urgent` so a relay
|
||||
// rule (Gotify priority, Discord mention, Home Assistant automation)
|
||||
// can escalate; MEDICATION_REMINDER keeps `high`; the rest `default`.
|
||||
priority:
|
||||
payload.urgent === true
|
||||
? "urgent"
|
||||
: payload.eventType === "MEDICATION_REMINDER"
|
||||
? "high"
|
||||
: "default",
|
||||
});
|
||||
const body = buildWebhookBody(config, payload);
|
||||
|
||||
const policy = evaluateNotificationTarget(config.url);
|
||||
if (!policy.allowed || !policy.canonicalOrigin) {
|
||||
@@ -130,11 +190,18 @@ export async function sendViaWebhook(
|
||||
result: "error",
|
||||
reason: classified.reason,
|
||||
});
|
||||
// What the relay said, for the test button (Gotify names the field it
|
||||
// could not bind). Refused when it echoes the header or a URL token.
|
||||
const upstreamBody = await readUpstreamBody(res, [
|
||||
...secretsInHeaderValue(config.headerValue),
|
||||
...secretsInUrl(config.url),
|
||||
]);
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: res.status,
|
||||
hardReject: classified.hardReject,
|
||||
reason: classified.reason,
|
||||
...(upstreamBody ? { upstreamBody } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "request_failed";
|
||||
@@ -147,6 +214,12 @@ export async function sendViaWebhook(
|
||||
const reason = policyRefused
|
||||
? "webhook_private_origin_refused"
|
||||
: "webhook_network_error";
|
||||
const failureCode =
|
||||
err instanceof SafeFetchError && !policyRefused
|
||||
? err.kind === "timeout"
|
||||
? "timeout"
|
||||
: "connection_failed"
|
||||
: undefined;
|
||||
getEvent()?.addExternalCall({
|
||||
service: "webhook",
|
||||
method: "sendNotification",
|
||||
@@ -168,6 +241,7 @@ export async function sendViaWebhook(
|
||||
...(policyRefused
|
||||
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
|
||||
: {}),
|
||||
...(failureCode ? { failureCode } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
|
||||
/**
|
||||
* The code for an HTTP status a relay answered, in the test card's
|
||||
* vocabulary (`settings.testConnection.errors`). The same split the
|
||||
* Nightscout and wearable test routes use, plus two the relays need: a 3xx
|
||||
* (safeFetch never follows a redirect) and a 4xx that is not about the
|
||||
* credentials or the path, where the relay accepted the connection and
|
||||
* refused the payload.
|
||||
*/
|
||||
export function codeForUpstreamStatus(status: number): string {
|
||||
if (status === 401 || status === 403) return "credentials_rejected";
|
||||
if (status === 404 || status === 410) return "endpoint_not_found";
|
||||
if (status === 429) return "rate_limited";
|
||||
if (status >= 500) return "upstream_error";
|
||||
if (status >= 300 && status < 400) return "redirected";
|
||||
return "upstream_rejected";
|
||||
}
|
||||
|
||||
export interface TestFailureDetail {
|
||||
/** Absent only for a failure the sender could not name: an internal fault. */
|
||||
errorCode?: string;
|
||||
upstreamStatus?: number;
|
||||
smtpCode?: number;
|
||||
upstreamBody?: string;
|
||||
}
|
||||
|
||||
/** What a failed send outcome says, without the private-origin refusal. */
|
||||
export function testFailureDetail(result: SendOutcome): TestFailureDetail {
|
||||
if (result.statusCode !== undefined) {
|
||||
return {
|
||||
errorCode: codeForUpstreamStatus(result.statusCode),
|
||||
upstreamStatus: result.statusCode,
|
||||
...(result.upstreamBody ? { upstreamBody: result.upstreamBody } : {}),
|
||||
};
|
||||
}
|
||||
if (result.failureCode) {
|
||||
return {
|
||||
errorCode: result.failureCode,
|
||||
...(result.smtpCode !== undefined ? { smtpCode: result.smtpCode } : {}),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** One English sentence for the envelope's `error`, the card translates the code. */
|
||||
export function testFailureSentence(
|
||||
label: string,
|
||||
detail: TestFailureDetail,
|
||||
): string {
|
||||
if (detail.upstreamStatus !== undefined) {
|
||||
return `${label} answered HTTP ${detail.upstreamStatus}.`;
|
||||
}
|
||||
const smtp =
|
||||
detail.smtpCode !== undefined ? ` (SMTP ${detail.smtpCode})` : "";
|
||||
switch (detail.errorCode) {
|
||||
case "timeout":
|
||||
return `${label} did not answer in time.`;
|
||||
case "connection_failed":
|
||||
return `Could not connect to ${label}.`;
|
||||
case "credentials_rejected":
|
||||
return `${label} rejected the credentials${smtp}.`;
|
||||
case "upstream_rejected":
|
||||
return `${label} refused the message${smtp}.`;
|
||||
case "upstream_error":
|
||||
return `${label} reported a temporary error${smtp}.`;
|
||||
default:
|
||||
return `${label} test failed.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The test routes' answer to a failed send that was not a private-origin
|
||||
* refusal (those keep their own 422 arm).
|
||||
*
|
||||
* The other end, or the way to it, failed: 502 with `meta.errorCode`, the
|
||||
* upstream status or SMTP code, and what the relay said when it is short
|
||||
* and holds no secret. A failure the sender could not name is an internal
|
||||
* fault and stays a 500 with `fallbackMessage`. The 502 is recorded at
|
||||
* `warn`, the 500 at `error`.
|
||||
*/
|
||||
export function answerTestDeliveryFailure(
|
||||
result: SendOutcome,
|
||||
opts: {
|
||||
channel: "webhook" | "ntfy" | "email";
|
||||
label: string;
|
||||
fallbackMessage: string;
|
||||
},
|
||||
): Response {
|
||||
const detail = testFailureDetail(result);
|
||||
annotate({
|
||||
action: { name: `settings.${opts.channel}.test` },
|
||||
meta: {
|
||||
success: false,
|
||||
[`${opts.channel}_test_code`]: detail.errorCode ?? "internal",
|
||||
...(detail.upstreamStatus !== undefined
|
||||
? { [`${opts.channel}_test_status`]: detail.upstreamStatus }
|
||||
: {}),
|
||||
...(detail.smtpCode !== undefined
|
||||
? { [`${opts.channel}_test_smtp_code`]: detail.smtpCode }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (!detail.errorCode) {
|
||||
return apiError(opts.fallbackMessage, 500);
|
||||
}
|
||||
// 502 is the honest status: the other end failed, not this request. But
|
||||
// the cause is the user's relay or mail setup, so the wide event stays at
|
||||
// warn rather than raising an operator-facing error on every test press.
|
||||
getEvent()?.capStatusLevel("warn");
|
||||
return apiError(testFailureSentence(opts.label, detail), 502, { ...detail });
|
||||
}
|
||||
@@ -238,12 +238,26 @@ export interface NtfyChannelConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic-webhook channel config (v1.17.1). The user supplies a public URL and
|
||||
* an OPTIONAL custom header (e.g. `Authorization: Bearer <token>` for Gotify,
|
||||
* or a Discord/Slack incoming-webhook URL needs no header). The dispatcher
|
||||
* POSTs a JSON body — see `sendViaWebhook` — through `safeFetch` with
|
||||
* `requirePublicHost: true` so the SSRF floor + DNS-rebinding pin apply to the
|
||||
* user-supplied host exactly as they do for ntfy.
|
||||
* The body shapes the generic webhook can send.
|
||||
*
|
||||
* - `generic`: HealthLog's own JSON envelope (`title`, `message`,
|
||||
* `eventType`, `priority` as a word), for Home Assistant, n8n, a Discord
|
||||
* or Slack relay, or anything that reads arbitrary JSON.
|
||||
* - `gotify`: the body Gotify's `POST /message` binds, with `priority` as
|
||||
* the integer Gotify types it as. The generic body is refused there with
|
||||
* a 400.
|
||||
*/
|
||||
export const WEBHOOK_PAYLOAD_FORMATS = ["generic", "gotify"] as const;
|
||||
export type WebhookPayloadFormat = (typeof WEBHOOK_PAYLOAD_FORMATS)[number];
|
||||
|
||||
/**
|
||||
* Generic-webhook channel config (v1.17.1). The user supplies a URL and an
|
||||
* OPTIONAL custom header (Gotify accepts its app token as `X-Gotify-Key`, or
|
||||
* as a `token` query parameter on the URL; a Discord or Slack incoming-webhook
|
||||
* URL needs no header). The dispatcher POSTs a JSON body — see
|
||||
* `sendViaWebhook` — through `safeFetch` with the connect-time pin, so the
|
||||
* SSRF floor and the DNS-rebinding defence apply to the user-supplied host
|
||||
* exactly as they do for ntfy.
|
||||
*/
|
||||
export interface WebhookChannelConfig {
|
||||
url: string;
|
||||
@@ -251,6 +265,11 @@ export interface WebhookChannelConfig {
|
||||
headerName?: string;
|
||||
/** Optional header value (e.g. "Bearer <token>"). Encrypted at rest. */
|
||||
headerValue?: string;
|
||||
/**
|
||||
* Body shape. Absent means `generic`, which is every config saved before
|
||||
* the choice existed; only `gotify` is written to the stored config.
|
||||
*/
|
||||
format?: WebhookPayloadFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,7 +333,7 @@ export interface NotificationPayload {
|
||||
* does not).
|
||||
* - ntfy → `Priority: 5` (max) + an `urgent` tag.
|
||||
* - WebPush → `Urgency: high` header + `requireInteraction`.
|
||||
* - Webhook → `priority: "urgent"` in the JSON body.
|
||||
* - Webhook → `priority: "urgent"` in the generic body, `10` in Gotify's.
|
||||
* - Telegram → normal delivery (no urgency tier exists; never silent).
|
||||
* Normal events leave `urgent` unset and behave exactly as before.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { looksSecretShaped } from "@/lib/secret-shape";
|
||||
|
||||
/** What the test card shows at most. */
|
||||
export const UPSTREAM_BODY_MAX_CHARS = 200;
|
||||
/** What is read off the wire at most, before anything is cleaned or cut. */
|
||||
const UPSTREAM_BODY_MAX_READ_BYTES = 4096;
|
||||
/** A configured value shorter than this is not treated as a secret to hide. */
|
||||
const MIN_KNOWN_SECRET_LENGTH = 4;
|
||||
|
||||
/**
|
||||
* C0 and C1 control characters, plus the zero-width and bidirectional
|
||||
* formatting marks that could make the rendered text read differently from
|
||||
* what the relay sent.
|
||||
*/
|
||||
function isControlOrFormatting(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint <= 0x1f ||
|
||||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
||||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2066 && codePoint <= 0x2069)
|
||||
);
|
||||
}
|
||||
|
||||
async function readBounded(res: Response): Promise<string | undefined> {
|
||||
const reader = res.body?.getReader?.();
|
||||
if (reader) {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (total < UPSTREAM_BODY_MAX_READ_BYTES) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || !value) break;
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
} finally {
|
||||
// An oversized error page is abandoned, not drained.
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
const bytes = new Uint8Array(Math.min(total, UPSTREAM_BODY_MAX_READ_BYTES));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
const room = bytes.length - offset;
|
||||
if (room <= 0) break;
|
||||
bytes.set(chunk.subarray(0, room), offset);
|
||||
offset += Math.min(chunk.byteLength, room);
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
}
|
||||
if (typeof res.text === "function") return res.text();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways a relay can echo a secret back: literally, JSON-escaped (with and
|
||||
* without the `\/` form some serialisers write for a slash, common inside a
|
||||
* base64 value), and percent-encoded.
|
||||
*/
|
||||
function echoForms(secret: string): string[] {
|
||||
const json = JSON.stringify(secret).slice(1, -1);
|
||||
return [
|
||||
...new Set([
|
||||
secret,
|
||||
json,
|
||||
json.replaceAll("/", "\\/"),
|
||||
encodeURIComponent(secret),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/** A value and its decoded form; a malformed escape keeps the raw value. */
|
||||
function withDecoded(part: string): string[] {
|
||||
try {
|
||||
const decoded = decodeURIComponent(part.replaceAll("+", " "));
|
||||
return decoded === part ? [part] : [part, decoded];
|
||||
} catch {
|
||||
return [part];
|
||||
}
|
||||
}
|
||||
|
||||
/** Path segments this long are credentials in Discord, Slack and n8n URLs. */
|
||||
const MIN_SECRET_PATH_SEGMENT_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Read what a relay answered on a non-2xx, in a form safe to hand back to
|
||||
* the person who pressed the test button.
|
||||
*
|
||||
* Bounded twice: at most 4 KB is read off the wire, and at most 200
|
||||
* characters survive. Control and formatting characters are removed and
|
||||
* whitespace collapsed. The text is refused outright (undefined) when it is
|
||||
* empty, unreadable, secret-shaped, or contains one of `knownSecrets` (the
|
||||
* header value, the auth token, a query-string token): a relay that echoes
|
||||
* the credential it rejected must not have that credential shown on screen.
|
||||
*
|
||||
* The result is for the test routes. Nothing stores it: the ledger and the
|
||||
* channel state keep only the status and the reason code.
|
||||
*/
|
||||
export async function readUpstreamBody(
|
||||
res: Response,
|
||||
knownSecrets: ReadonlyArray<string | undefined>,
|
||||
): Promise<string | undefined> {
|
||||
let raw: string | undefined;
|
||||
try {
|
||||
raw = await readBounded(res);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!raw) return undefined;
|
||||
|
||||
const cleaned = Array.from(raw, (ch) =>
|
||||
isControlOrFormatting(ch.codePointAt(0) ?? 0) ? " " : ch,
|
||||
)
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!cleaned) return undefined;
|
||||
|
||||
if (looksSecretShaped(raw) || looksSecretShaped(cleaned)) return undefined;
|
||||
const rawLower = raw.toLowerCase();
|
||||
for (const secret of knownSecrets) {
|
||||
if (!secret || secret.length < MIN_KNOWN_SECRET_LENGTH) continue;
|
||||
for (const form of echoForms(secret)) {
|
||||
if (raw.includes(form) || cleaned.includes(form)) return undefined;
|
||||
}
|
||||
// Percent-encoding is case-insensitive in its hex digits.
|
||||
if (rawLower.includes(encodeURIComponent(secret).toLowerCase())) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const characters = Array.from(cleaned);
|
||||
if (characters.length <= UPSTREAM_BODY_MAX_CHARS) return cleaned;
|
||||
return `${characters.slice(0, UPSTREAM_BODY_MAX_CHARS - 1).join("")}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The secrets a URL can carry on its own: userinfo, every query value
|
||||
* (Gotify's `?token=` form) and every path segment of 16 or more characters
|
||||
* (a Discord or Slack webhook token, an n8n webhook id). Each part is decoded
|
||||
* on its own, so one malformed escape cannot drop the others.
|
||||
*/
|
||||
export function secretsInUrl(url: string): string[] {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const found: string[] = [];
|
||||
if (parsed.username) found.push(...withDecoded(parsed.username));
|
||||
if (parsed.password) found.push(...withDecoded(parsed.password));
|
||||
for (const pair of parsed.search.replace(/^\?/, "").split("&")) {
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const value = pair.slice(eq + 1);
|
||||
if (value) found.push(...withDecoded(value));
|
||||
}
|
||||
for (const segment of parsed.pathname.split("/")) {
|
||||
if (segment.length >= MIN_SECRET_PATH_SEGMENT_LENGTH) {
|
||||
found.push(...withDecoded(segment));
|
||||
}
|
||||
}
|
||||
return [...new Set(found)];
|
||||
}
|
||||
|
||||
/** A header value and, for the `<scheme> <token>` form, the token alone. */
|
||||
export function secretsInHeaderValue(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const parts = value.trim().split(/\s+/);
|
||||
return parts.length > 1 ? [value, parts[parts.length - 1]] : [value];
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types";
|
||||
|
||||
// v1.16.11 — the one threshold read every dose-status consumer makes
|
||||
// (cards, table, take-all-due derivation). `lateMinutes` /
|
||||
@@ -255,7 +256,7 @@ const telegramSettingsRequest = telegramSettingsSchema.meta({
|
||||
const webhookSettingsRequest = webhookSettingsSchema.meta({
|
||||
id: "WebhookSettingsRequest",
|
||||
description:
|
||||
"Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time; a private origin passes both only when the operator listed it in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint.",
|
||||
"Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time; a private origin passes both only when the operator listed it in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint. An omitted `format` keeps the stored body shape.",
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -343,6 +344,11 @@ const webhookSettingsResponse = z
|
||||
.describe(
|
||||
"Whether a header value (the shared secret) is stored. The value itself is never returned.",
|
||||
),
|
||||
format: z
|
||||
.enum(WEBHOOK_PAYLOAD_FORMATS)
|
||||
.describe(
|
||||
"Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "WebhookSettingsResponse",
|
||||
@@ -685,7 +691,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Send a test email to the saved recipient",
|
||||
description:
|
||||
"Fires one message through the operator's SMTP transport to the address saved on this account. Takes no body. Rate-limited 5 per 5 minutes per user.\n\n" +
|
||||
"Refuses with 400 when the instance has no SMTP transport, when the account has no email channel, or when the channel carries no recipient — three distinguishable prose messages on one status. A transport failure is a 500.",
|
||||
"Refuses with 400 when the instance has no SMTP transport, when the account has no email channel, or when the channel carries no recipient — three distinguishable prose messages on one status. A failure at the mail server or on the way to it is a 502 that names its cause; only a failure the server cannot name is a 500.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
@@ -697,6 +703,16 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"502": {
|
||||
description:
|
||||
"The mail server, or the way to it, failed. `meta.errorCode` names the cause: `credentials_rejected` (the server refused the SMTP login), `upstream_rejected` (a permanent 5xx rejection, e.g. an unknown mailbox or relay denied, or an envelope or message refused without a reply code), `upstream_error` (a temporary 4xx rejection), `timeout`, or `connection_failed` (including a TLS handshake or protocol failure). `meta.smtpCode` carries the SMTP reply code when the mail server sent one.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"500": {
|
||||
description:
|
||||
"The send failed in a way the server could not name. An internal fault, not a statement about the mail server.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -705,7 +721,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Notifications"],
|
||||
summary: "Send a test message to the saved ntfy topic",
|
||||
description:
|
||||
"Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.",
|
||||
"Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic. A failure at the ntfy server or on the way to it is a 502 that carries the upstream status and, when safe, what the server answered; only a failure the server cannot name is a 500. A server on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
@@ -717,6 +733,16 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"502": {
|
||||
description:
|
||||
"The ntfy server, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found` (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved auth token, the topic, or a credential in the server URL (userinfo, a query value, a path segment of 16 or more characters), literal, JSON-escaped or percent-encoded.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"500": {
|
||||
description:
|
||||
"The send failed in a way the server could not name. An internal fault, not a statement about the ntfy server.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
|
||||
@@ -751,7 +777,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Notifications"],
|
||||
summary: "Send a test payload to the saved webhook",
|
||||
description:
|
||||
"Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.",
|
||||
"Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL. A failure at the relay or on the way to it is a 502 that carries the upstream status and, when safe, what the relay answered; only a failure the server cannot name is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
@@ -763,6 +789,16 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"502": {
|
||||
description:
|
||||
"The relay, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found` (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved header value (and its token part), the URL's userinfo, a query value, or a path segment of 16 or more characters (the token in a Discord or Slack webhook URL), literal, JSON-escaped or percent-encoded.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"500": {
|
||||
description:
|
||||
"The send failed in a way the server could not name. An internal fault, not a statement about the relay.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/components/settings/section-slugs";
|
||||
import type {
|
||||
AccountAccessLevel,
|
||||
AccountRecordKind,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
export type SettingsDestinationKind =
|
||||
| "personal"
|
||||
@@ -138,3 +146,110 @@ export function isManageDelegateSettingsDestination(
|
||||
): boolean {
|
||||
return classifySettingsDestination(destination).kind === "manage-writable";
|
||||
}
|
||||
|
||||
/**
|
||||
* The section each record-content destination writes to.
|
||||
*
|
||||
* A `manage-writable` destination is a page of forms whose routes resolve
|
||||
* `requireRecordAuth("manage", <section>)`, and that call refuses a grant
|
||||
* whose sections do not reach the section (`grantCoversDomain`). Level alone
|
||||
* therefore does not say the page works: the grant must manage that section.
|
||||
* The anamnesis forms post to `/api/allergies` and `/api/family-history`, both
|
||||
* declared on `profile`.
|
||||
*
|
||||
* A `manage-writable` destination missing here is never listed, so a new one
|
||||
* cannot appear in a shared record until somebody names what it writes.
|
||||
*/
|
||||
export const RECORD_CONTENT_WRITE_DOMAINS = {
|
||||
anamnesis: "profile",
|
||||
} as const satisfies Partial<Record<SettingsSectionSlug, ShareDomain>>;
|
||||
|
||||
/**
|
||||
* The record a Settings listing is drawn for: the server-resolved kind of the
|
||||
* record on screen, the level of the grant that opened it, and the sections
|
||||
* that grant may manage. `level` is null in one's own record and in a context
|
||||
* the client could not prove. `manageableDomains` is bound from
|
||||
* `accountAccess.active.manageableDomains`, never derived here.
|
||||
*/
|
||||
export interface SettingsRecordContext {
|
||||
recordKind: AccountRecordKind;
|
||||
level: AccountAccessLevel | null;
|
||||
manageableDomains: readonly ShareDomain[];
|
||||
}
|
||||
|
||||
function managesRecordContentSection(
|
||||
destination: string,
|
||||
record: SettingsRecordContext,
|
||||
): boolean {
|
||||
const section = (
|
||||
RECORD_CONTENT_WRITE_DOMAINS as Partial<Record<string, ShareDomain>>
|
||||
)[destination];
|
||||
return section !== undefined && record.manageableDomains.includes(section);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the Settings shell list this destination inside a shared record.
|
||||
*
|
||||
* The one answer both the shell's section list and the app navigation read,
|
||||
* so the navigation cannot offer a Settings entry the shell has nothing
|
||||
* behind, nor withhold one the shell would list.
|
||||
*
|
||||
* * A managed profile at MANAGE lists its guardian configuration and the
|
||||
* record content a MANAGE holder may write. The guardian holds MANAGE, so
|
||||
* the second set is theirs as well.
|
||||
* * An ordinary shared record at MANAGE lists only the record content. A
|
||||
* delegate manages somebody's health record, not their account: modules,
|
||||
* thresholds and notification routing stay with the owner.
|
||||
* * Every other context lists nothing.
|
||||
*
|
||||
* A record-content destination is listed only when the grant manages the
|
||||
* section it writes ({@link RECORD_CONTENT_WRITE_DOMAINS}). Today a MANAGE
|
||||
* grant is whole-record at every layer (`inviteGrant` refuses a scope on a
|
||||
* MANAGE invitation, no endpoint raises a live grant, and the account-access
|
||||
* schema rejects a manage entry with sections), so this changes nothing for a
|
||||
* managed profile or an adult MANAGE share as they exist. It keeps the list
|
||||
* true if a scoped MANAGE grant ever reaches the client, rather than offering a
|
||||
* page whose writes answer 403.
|
||||
*
|
||||
* Every destination on either list needs MANAGE, so the level is checked once
|
||||
* for both record kinds. A guardian grant is always MANAGE today, which is
|
||||
* exactly why the check was easy to leave out, and a managed entry that ever
|
||||
* arrived below it would have listed destinations the section gate refuses.
|
||||
*
|
||||
* Paint only. The section gate still refuses any direct URL on its own.
|
||||
*/
|
||||
export function isSettingsDestinationListedForRecord(
|
||||
destination: string,
|
||||
record: SettingsRecordContext,
|
||||
): boolean {
|
||||
if (record.level !== "manage") return false;
|
||||
const kind = classifySettingsDestination(destination).kind;
|
||||
if (kind === "manage-writable") {
|
||||
return (
|
||||
(record.recordKind === "managed" || record.recordKind === "shared") &&
|
||||
managesRecordContentSection(destination, record)
|
||||
);
|
||||
}
|
||||
return record.recordKind === "managed" && kind === "managed-guardian";
|
||||
}
|
||||
|
||||
/**
|
||||
* The Settings page a navigation entry opens inside a shared record, or null
|
||||
* when the shell would list nothing there and no entry should be offered.
|
||||
*
|
||||
* `order` is the order the shell lists its sections in. The navigation passes
|
||||
* the slug registry, whose order differs from the shell's in general; the
|
||||
* parity test in `nav-model-shared-record.test.ts` holds the first answer
|
||||
* equal to the first section the shell actually lists, for every record kind,
|
||||
* so a reorder on either side fails there rather than landing somebody on a
|
||||
* page the shell does not open with.
|
||||
*/
|
||||
export function recordSettingsLandingDestination(
|
||||
record: SettingsRecordContext,
|
||||
order: readonly SettingsSectionSlug[] = SETTINGS_SECTION_SLUGS,
|
||||
): SettingsSectionSlug | null {
|
||||
return (
|
||||
order.find((slug) => isSettingsDestinationListedForRecord(slug, record)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* One definition of "this text carries a credential HealthLog or a common
|
||||
* AI provider mints", shared by every surface that refuses to store or echo
|
||||
* such text.
|
||||
*
|
||||
* `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link
|
||||
* tokens, `hlv_` registration invite tokens, `hle_` elevation tokens;
|
||||
* `sk-…` / `sk-ant-…` OpenAI and Anthropic keys, in the full token form
|
||||
* only: a bare `sk-` substring ("task-id", "risk-management") is not a key,
|
||||
* and matching it would refuse benign text.
|
||||
*
|
||||
* It is a shape test, not a secret detector. A credential without one of
|
||||
* these prefixes (a relay's own app token, a password) passes, so a caller
|
||||
* that knows the secrets in play must also refuse text containing them.
|
||||
*/
|
||||
const SECRET_SHAPED =
|
||||
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
||||
|
||||
export function looksSecretShaped(text: string): boolean {
|
||||
return SECRET_SHAPED.test(text);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isIP } from "node:net";
|
||||
import { EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import {
|
||||
EVENT_TYPES,
|
||||
WEBHOOK_PAYLOAD_FORMATS,
|
||||
} from "@/lib/notifications/types";
|
||||
|
||||
export const notificationPreferenceSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
@@ -391,6 +394,12 @@ export function webhookSettingsSchemaWith(
|
||||
.or(z.literal("")),
|
||||
headerName: z.string().max(100).optional().or(z.literal("")),
|
||||
headerValue: z.string().max(500).optional().or(z.literal("")),
|
||||
format: z
|
||||
.enum(WEBHOOK_PAYLOAD_FORMATS)
|
||||
.optional()
|
||||
.describe(
|
||||
"Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved before the choice existed.",
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,6 +244,26 @@ const LEGACY_ADMIN_ANCHORS: Record<string, string> = {
|
||||
"/admin/section-danger-zone": "/admin/danger-zone",
|
||||
};
|
||||
|
||||
/**
|
||||
* Presence marker on every HealthLog page response, read by `public/sw.js`.
|
||||
*
|
||||
* The service worker is registered with scope `/` and serves `/_next/static/*`
|
||||
* cache-first. When HealthLog is stopped and a different app starts on the
|
||||
* same origin (`localhost:3000` on a developer machine is the usual case), the
|
||||
* worker went on serving HealthLog's cached chunks to that app (#847). It
|
||||
* needs a way to tell a HealthLog page from someone else's, and nothing
|
||||
* HealthLog already sends is specific enough: `x-request-id`, the nonce CSP
|
||||
* and the COOP/COEP set are headers any Next.js app may emit. So the proxy
|
||||
* adds one of its own.
|
||||
*
|
||||
* Presence only, value `1`: no version and no build id, because a version on
|
||||
* every page is a free fingerprint for anyone scanning for an outdated
|
||||
* instance and the worker does not need it. Pages only, not `/api/*`: the
|
||||
* worker checks navigations and nothing else. Keep the name in lockstep with
|
||||
* `HEALTHLOG_PAGE_MARKER_HEADER` in `public/sw.js`.
|
||||
*/
|
||||
export const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page";
|
||||
|
||||
function generateNonce(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
let binary = "";
|
||||
@@ -484,6 +504,10 @@ export function proxy(request: NextRequest) {
|
||||
// Request ID for log correlation
|
||||
response.headers.set("x-request-id", requestId);
|
||||
|
||||
if (!isApiRoute) {
|
||||
response.headers.set(HEALTHLOG_PAGE_MARKER_HEADER, "1");
|
||||
}
|
||||
|
||||
// The document vault's decrypt-and-serve route is framed SAME-ORIGIN by
|
||||
// the document detail preview (an <iframe> for inline-class PDFs). The
|
||||
// blanket `X-Frame-Options: DENY` + `frame-ancestors 'none'` below would
|
||||
|
||||
Reference in new issue
Block a user