From 15f0e499f96ae0fcc5c6c680b4623cd867d378e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 13:56:58 +0200 Subject: [PATCH 1/7] refactor: share the secret-shape matcher from one leaf The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged. --- src/lib/__tests__/secret-shape.test.ts | 25 +++++++++++++++++++++++++ src/lib/idempotency.ts | 17 ++++------------- src/lib/secret-shape.ts | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 src/lib/__tests__/secret-shape.test.ts create mode 100644 src/lib/secret-shape.ts diff --git a/src/lib/__tests__/secret-shape.test.ts b/src/lib/__tests__/secret-shape.test.ts new file mode 100644 index 000000000..2cdbf3d26 --- /dev/null +++ b/src/lib/__tests__/secret-shape.test.ts @@ -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); + }); +}); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index c9e2dbeb8..70e2ddcbc 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -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 diff --git a/src/lib/secret-shape.ts b/src/lib/secret-shape.ts new file mode 100644 index 000000000..7786f7b59 --- /dev/null +++ b/src/lib/secret-shape.ts @@ -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); +} From 8c345f600e18971d6bbc31ecdb0dfa65854dd1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 14:04:40 +0200 Subject: [PATCH 2/7] feat(notifications): keep what the relay answered on a failed send The webhook and ntfy senders now carry up to 200 characters of a non-2xx response body on the send outcome, with control and formatting characters removed. The body is dropped when it looks like a credential or echoes the header value, auth token, topic or a URL token. Nothing stores it; the ledger row and channel state keep the reason code as before. Transport faults without a status now say whether they were a timeout or a refused connection, and the email sender maps SMTP failures into the same vocabulary with the reply code. Both exist so the test routes can answer with the cause instead of a bare 500. Refs #947 --- .../__tests__/upstream-body.test.ts | 110 ++++++++++++++++ src/lib/notifications/retry-policy.ts | 16 +++ .../senders/__tests__/email.test.ts | 67 ++++++++++ .../senders/__tests__/ntfy.test.ts | 35 +++++ .../senders/__tests__/webhook.test.ts | 59 +++++++++ src/lib/notifications/senders/email.ts | 45 ++++++- src/lib/notifications/senders/ntfy.ts | 19 +++ src/lib/notifications/senders/webhook.ts | 19 +++ src/lib/notifications/upstream-body.ts | 121 ++++++++++++++++++ 9 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 src/lib/notifications/__tests__/upstream-body.test.ts create mode 100644 src/lib/notifications/upstream-body.ts diff --git a/src/lib/notifications/__tests__/upstream-body.test.ts b/src/lib/notifications/__tests__/upstream-body.test.ts new file mode 100644 index 000000000..021c6c1a0 --- /dev/null +++ b/src/lib/notifications/__tests__/upstream-body.test.ts @@ -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({ + 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([]); + }); +}); diff --git a/src/lib/notifications/retry-policy.ts b/src/lib/notifications/retry-policy.ts index 6dbdfe8d8..958024563 100644 --- a/src/lib/notifications/retry-policy.ts +++ b/src/lib/notifications/retry-policy.ts @@ -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; } /** diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts index 998ec15f6..83f12cec3 100644 --- a/src/lib/notifications/senders/__tests__/email.test.ts +++ b/src/lib/notifications/senders/__tests__/email.test.ts @@ -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(); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index 9bb4c17be..e38efa619 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -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"); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index 255f1705e..6bec91585 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -281,3 +281,62 @@ 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(); + }); +}); diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts index 86afb748e..2b518638d 100644 --- a/src/lib/notifications/senders/email.ts +++ b/src/lib/notifications/senders/email.ts @@ -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 } + : {}), }; } } diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 1692112b8..a2fe63401 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -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 } : {}), }; } } diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts index c9d480a7e..baad20ccc 100644 --- a/src/lib/notifications/senders/webhook.ts +++ b/src/lib/notifications/senders/webhook.ts @@ -15,6 +15,11 @@ 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"; /** * Send a notification via a generic outbound webhook (v1.17.1). @@ -130,11 +135,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 +159,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 +186,7 @@ export async function sendViaWebhook( ...(policyRefused ? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE } : {}), + ...(failureCode ? { failureCode } : {}), }; } } diff --git a/src/lib/notifications/upstream-body.ts b/src/lib/notifications/upstream-body.ts new file mode 100644 index 000000000..dbf57bad2 --- /dev/null +++ b/src/lib/notifications/upstream-body.ts @@ -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 { + 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, +): Promise { + 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 ` ` 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]; +} From f6a890df8a31d4da34437314b7c48930cd2da7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 14:10:35 +0200 Subject: [PATCH 3/7] fix(notifications): answer a failed channel test with its cause The webhook, ntfy and email test routes turned every delivery failure into a bare 500, so the settings card could only say "Test failed" while the server knew the relay had answered 400. A failure at the other end, or on the way to it, is now a 502 with meta.errorCode, the upstream HTTP status or SMTP reply code, and for webhook and ntfy what the relay answered when that is short and holds no credential. The codes reuse the test card's vocabulary; upstream_rejected is new, for a relay that accepted the connection and refused the payload. The private-origin 422 is unchanged, and only a failure the server cannot name stays a 500. The guard that pinned the bare 500 now asserts the opposite, with the reason in its name. The admin fan-out test reports the same code and status per channel, and tests the webhook and email channels, which it used to list as an unknown channel type. Refs #947 --- docs/api/openapi.yaml | 72 +++++++-- .../test/__tests__/route.test.ts | 138 +++++++++++++++++- src/app/api/admin/notifications/test/route.ts | 88 +++++++++-- .../__tests__/email-test-route.test.ts | 131 +++++++++++++++++ .../__tests__/private-origin-routes.test.ts | 107 ++++++++++++-- src/app/api/settings/email/test/route.ts | 9 +- src/app/api/settings/ntfy/test/route.ts | 9 +- src/app/api/settings/webhook/test/route.ts | 10 +- .../__tests__/test-delivery-failure.test.ts | 74 ++++++++++ .../notifications/test-delivery-failure.ts | 109 ++++++++++++++ src/lib/openapi/routes/settings.ts | 36 ++++- 11 files changed, 744 insertions(+), 39 deletions(-) create mode 100644 src/app/api/settings/__tests__/email-test-route.test.ts create mode 100644 src/lib/notifications/__tests__/test-delivery-failure.test.ts create mode 100644 src/lib/notifications/test-delivery-failure.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fb07ebd4e..90730c227 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: 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/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/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/lib/notifications/__tests__/test-delivery-failure.test.ts b/src/lib/notifications/__tests__/test-delivery-failure.test.ts new file mode 100644 index 000000000..69be323fb --- /dev/null +++ b/src/lib/notifications/__tests__/test-delivery-failure.test.ts @@ -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."); + }); +}); diff --git a/src/lib/notifications/test-delivery-failure.ts b/src/lib/notifications/test-delivery-failure.ts new file mode 100644 index 000000000..1728f1678 --- /dev/null +++ b/src/lib/notifications/test-delivery-failure.ts @@ -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 }); +} diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 7b948794f..03264d50a 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -685,7 +685,7 @@ export const settingsPaths: NonNullable = { 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 +697,16 @@ export const settingsPaths: NonNullable = { }, }, ...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 +715,7 @@ export const settingsPaths: NonNullable = { 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 +727,16 @@ export const settingsPaths: NonNullable = { }, }, ...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 +771,7 @@ export const settingsPaths: NonNullable = { 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 +783,16 @@ export const settingsPaths: NonNullable = { }, }, ...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.", From 92606f64099f18b7527e149b2609a17a6f320db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 14:13:55 +0200 Subject: [PATCH 4/7] feat(settings): show the status and reply of a failed channel test The test-connection callout now appends the HTTP status or SMTP reply code the route reports, and shows what the relay answered on a line beneath it. The reply is rendered as a React text child, so markup in it is shown as characters. Adds the upstream_rejected message in all seven locales. Refs #947 --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../test-connection-failure.test.tsx | 61 +++++++++++++ .../settings/test-connection-button.tsx | 90 ++++++++++++++++--- 9 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 src/components/settings/__tests__/test-connection-failure.test.tsx diff --git a/messages/de.json b/messages/de.json index 3a48fb6f1..b5ac510ae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6136,6 +6136,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 b2a37305f..e54cffb55 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6136,6 +6136,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 e3f4ca56c..1ca650b2a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6136,6 +6136,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 48fc47a50..aba616f1d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6136,6 +6136,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 0e97ed7be..834d5da3d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6136,6 +6136,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 d09db31aa..48722e917 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6136,6 +6136,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 3c84cbc85..4689c536b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6136,6 +6136,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/components/settings/__tests__/test-connection-failure.test.tsx b/src/components/settings/__tests__/test-connection-failure.test.tsx new file mode 100644 index 000000000..2002a70ac --- /dev/null +++ b/src/components/settings/__tests__/test-connection-failure.test.tsx @@ -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( + , + ); + + 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( + , + ); + + expect(html).not.toContain(" { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Credentials rejected (SMTP 535)"); + }); + + it("shows the sentence alone when the server sent no detail", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Test failed"); + expect(html).not.toContain("HTTP"); + expect(html).not.toContain("test-connection-upstream-body"); + }); +}); diff --git a/src/components/settings/test-connection-button.tsx b/src/components/settings/test-connection-button.tsx index 503b53079..e741a6233 100644 --- a/src/components/settings/test-connection-button.tsx +++ b/src/components/settings/test-connection-button.tsx @@ -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 ( +
+

+ + {`${message}${code}`} +

+ {upstreamBody ? ( +

+ {upstreamBody} +

+ ) : null} +
+ ); } 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" && ( -

- - {describeError(result.errorCode)} -

+ )} ); From fafb5cdc085fb7c19c351ee2a808feedc6b023d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 14:19:48 +0200 Subject: [PATCH 5/7] feat(notifications): add a Gotify payload format to the webhook Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras. Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does. The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter. Refs #947 --- docs/api/openapi.yaml | 17 +- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/ko.json | 4 + messages/pl.json | 4 + .../__tests__/webhook-format-route.test.ts | 194 ++++++++++++++++++ src/app/api/settings/webhook/route.ts | 39 ++-- .../notification-channel-cards.test.tsx | 23 +++ src/components/settings/webhook-card.tsx | 32 ++- .../senders/__tests__/webhook.test.ts | 116 +++++++++++ src/lib/notifications/senders/webhook.ts | 105 +++++++--- src/lib/notifications/types.ts | 33 ++- src/lib/openapi/routes/settings.ts | 8 +- src/lib/validations/notifications.ts | 11 +- 17 files changed, 556 insertions(+), 50 deletions(-) create mode 100644 src/app/api/settings/__tests__/webhook-format-route.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 90730c227..71260c858 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25917,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: @@ -25926,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: @@ -57501,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/messages/de.json b/messages/de.json index b5ac510ae..02e6afce7 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", diff --git a/messages/en.json b/messages/en.json index e54cffb55..1e7ea2328 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", diff --git a/messages/es.json b/messages/es.json index 1ca650b2a..29f3f81b0 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", diff --git a/messages/fr.json b/messages/fr.json index aba616f1d..48c1a548e 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", diff --git a/messages/it.json b/messages/it.json index 834d5da3d..9876034aa 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", diff --git a/messages/ko.json b/messages/ko.json index 48722e917..f99ab9a28 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": "이메일 켜기", diff --git a/messages/pl.json b/messages/pl.json index 4689c536b..d401d3f51 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", 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/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/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(/