mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge channel tests that report the other end's answer, and the Gotify format
This commit is contained in:
commit
4306bf1aec
40 files changed
+2059
-119
No files matched your search
+79
-10
@@ -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,21 @@ 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), `upstream_error` (a temporary 4xx rejection), `timeout`, or `connection_failed`.
|
||||
`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 +12388,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 +12409,24 @@ 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 header
|
||||
value, auth token, topic or URL token)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/settings/telegram/test:
|
||||
post:
|
||||
tags:
|
||||
@@ -12421,11 +12456,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 +12479,24 @@ 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, auth token, topic or URL token)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/settings/data:
|
||||
delete:
|
||||
tags:
|
||||
@@ -25863,6 +25917,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 +25934,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 +57509,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:
|
||||
|
||||
@@ -207,6 +207,73 @@ 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.
|
||||
|
||||
## Notification targets on a private network
|
||||
|
||||
Every webhook and ntfy target is resolved at send time and refused when
|
||||
|
||||
@@ -5590,6 +5590,10 @@
|
||||
"webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.",
|
||||
"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",
|
||||
|
||||
@@ -5590,6 +5590,10 @@
|
||||
"webhookUrlHint": "The full URL to POST to. Must be publicly reachable.",
|
||||
"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",
|
||||
|
||||
@@ -5590,6 +5590,10 @@
|
||||
"webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.",
|
||||
"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",
|
||||
|
||||
@@ -5590,6 +5590,10 @@
|
||||
"webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.",
|
||||
"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",
|
||||
|
||||
@@ -5590,6 +5590,10 @@
|
||||
"webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.",
|
||||
"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",
|
||||
|
||||
@@ -5590,6 +5590,10 @@
|
||||
"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이 공개 엔드포인트가 아니에요",
|
||||
|
||||
@@ -5590,6 +5590,10 @@
|
||||
"webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.",
|
||||
"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",
|
||||
|
||||
@@ -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,127 @@ 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");
|
||||
});
|
||||
|
||||
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,
|
||||
upstreamBody: "priority must be an integer",
|
||||
});
|
||||
});
|
||||
|
||||
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,39 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
type TestResult = {
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
} & TestFailureDetail;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
context?: string,
|
||||
): TestResult {
|
||||
const detail: TestFailureDetail = result.errorCode
|
||||
? { errorCode: result.errorCode }
|
||||
: testFailureDetail(result);
|
||||
const cause = result.errorCode
|
||||
? `refused by the private-origin policy (${result.errorCode})`
|
||||
: detail.errorCode
|
||||
? testFailureSentence(label, detail)
|
||||
: (result.reason ?? "send failed");
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: context ? `${cause} (${context})` : cause,
|
||||
...detail,
|
||||
};
|
||||
}
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
return (
|
||||
value === "de" ||
|
||||
@@ -121,11 +164,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 +205,40 @@ 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})`,
|
||||
});
|
||||
results.push(
|
||||
failedSend(
|
||||
"NTFY",
|
||||
"The ntfy server",
|
||||
ntfyResult,
|
||||
`topic: ${config.topic}`,
|
||||
),
|
||||
);
|
||||
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
|
||||
@@ -351,20 +353,99 @@ 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");
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
},
|
||||
);
|
||||
|
||||
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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,56 @@ 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 function TestConnectionButton({
|
||||
@@ -39,7 +91,13 @@ export function TestConnectionButton({
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<
|
||||
| { kind: "ok"; latency: number }
|
||||
| { kind: "error"; errorCode: string }
|
||||
| {
|
||||
kind: "error";
|
||||
errorCode: string;
|
||||
upstreamStatus?: number;
|
||||
smtpCode?: number;
|
||||
upstreamBody?: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
@@ -56,9 +114,20 @@ export function TestConnectionButton({
|
||||
latency: json.data?.latencyMs ?? 0,
|
||||
});
|
||||
} else {
|
||||
const meta = json.meta;
|
||||
setResult({
|
||||
kind: "error",
|
||||
errorCode: json.meta?.errorCode ?? "generic",
|
||||
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 {
|
||||
@@ -109,13 +178,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
|
||||
|
||||
@@ -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,110 @@
|
||||
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(["Abc123", "user", "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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,70 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -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,48 @@ 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"
|
||||
? "connection_failed"
|
||||
: smtpCode !== undefined && smtpCode >= 500
|
||||
? "upstream_rejected"
|
||||
: smtpCode !== undefined && smtpCode >= 400
|
||||
? "upstream_error"
|
||||
: 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 +198,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,109 @@
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } 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`.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
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,121 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
for (const secret of knownSecrets) {
|
||||
if (!secret || secret.length < MIN_KNOWN_SECRET_LENGTH) continue;
|
||||
if (raw.includes(secret) || cleaned.includes(secret)) 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 and every query value
|
||||
* (Gotify's `?token=` form).
|
||||
*/
|
||||
export function secretsInUrl(url: string): string[] {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const found = [...parsed.searchParams.values()];
|
||||
if (parsed.username) found.push(decodeURIComponent(parsed.username));
|
||||
if (parsed.password) found.push(decodeURIComponent(parsed.password));
|
||||
return found;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 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), `upstream_error` (a temporary 4xx rejection), `timeout`, or `connection_failed`. `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 header value, auth token, topic or URL token).",
|
||||
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, auth token, topic or URL token).",
|
||||
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.",
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user