diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fb07ebd4e..71260c858 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index a3dd06de1..e0416e86c 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -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 diff --git a/messages/de.json b/messages/de.json index 3c70d965c..ae2269901 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index e558f8892..80cee6b17 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index a42c28ad0..6f14b4c6c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index cc4fed784..7c960e462 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 3dc9470e5..a4a87713e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index a8a6a3f8f..bcdfa0806 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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이 공개 엔드포인트가 아니에요", diff --git a/messages/pl.json b/messages/pl.json index a87b93e0b..62a72e546 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/admin/notifications/test/__tests__/route.test.ts b/src/app/api/admin/notifications/test/__tests__/route.test.ts index 295e44f25..259a35461 100644 --- a/src/app/api/admin/notifications/test/__tests__/route.test.ts +++ b/src/app/api/admin/notifications/test/__tests__/route.test.ts @@ -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 }, + ]); + }); +}); diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts index 33492125f..0bf282198 100644 --- a/src/app/api/admin/notifications/test/route.ts +++ b/src/app/api/admin/notifications/test/route.ts @@ -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 { 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; diff --git a/src/app/api/settings/__tests__/email-test-route.test.ts b/src/app/api/settings/__tests__/email-test-route.test.ts new file mode 100644 index 000000000..2bf5e0684 --- /dev/null +++ b/src/app/api/settings/__tests__/email-test-route.test.ts @@ -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 { + return (POST as (request: Request) => Promise)( + 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); + }); +}); diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts index 4bc8d5b77..7825c97b3 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -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], diff --git a/src/app/api/settings/__tests__/webhook-format-route.test.ts b/src/app/api/settings/__tests__/webhook-format-route.test.ts new file mode 100644 index 000000000..d20ba38b8 --- /dev/null +++ b/src/app/api/settings/__tests__/webhook-format-route.test.ts @@ -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; + +function put(body: unknown): Promise { + 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 { + 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) { + 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", + }); + }); +}); diff --git a/src/app/api/settings/email/test/route.ts b/src/app/api/settings/email/test/route.ts index 4e40c8b9a..e6c1ecaa0 100644 --- a/src/app/api/settings/email/test/route.ts +++ b/src/app/api/settings/email/test/route.ts @@ -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({ diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index b260f1643..feeaff9ea 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -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 } }); diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 1f1b072b3..49cccdec3 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -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; 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; + 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 }); }); diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts index cc6bfa6dc..7021e2bfe 100644 --- a/src/app/api/settings/webhook/test/route.ts +++ b/src/app/api/settings/webhook/test/route.ts @@ -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({ diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx index a7cda1b36..81499de41 100644 --- a/src/components/settings/__tests__/notification-channel-cards.test.tsx +++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx @@ -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(); + + expect(html).toContain('id="webhook-format"'); + expect(html).toMatch(/