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
This commit is contained in:
Marc-André Bombeck committed 2026-09-13 14:10:35 +02:00
1 parent 8c345f600e
commit f6a890df8a
11 files changed
+744 -39

No files matched your search

@@ -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 },
]);
});
});
+78 -10
View File
@@ -7,9 +7,19 @@ import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaApns } from "@/lib/notifications/senders/apns";
import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
import { sendViaEmail } from "@/lib/notifications/senders/email";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import {
testFailureDetail,
testFailureSentence,
type TestFailureDetail,
} from "@/lib/notifications/test-delivery-failure";
import type {
TelegramChannelConfig,
NtfyChannelConfig,
WebhookChannelConfig,
EmailChannelConfig,
ChannelType,
NotificationPayload,
} from "@/lib/notifications/types";
@@ -62,6 +72,39 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
return true;
}
type TestResult = {
channel: string;
success: boolean;
error?: string;
} & TestFailureDetail;
/**
* A failed HTTP or SMTP send, with the code, status and SMTP reply code the
* user-facing test routes answer with, instead of a sentence that drops
* them. A private-origin refusal forwards its own code.
*/
function failedSend(
channel: string,
label: string,
result: SendOutcome,
context?: string,
): TestResult {
const detail: TestFailureDetail = result.errorCode
? { errorCode: result.errorCode }
: testFailureDetail(result);
const cause = result.errorCode
? `refused by the private-origin policy (${result.errorCode})`
: detail.errorCode
? testFailureSentence(label, detail)
: (result.reason ?? "send failed");
return {
channel,
success: false,
error: context ? `${cause} (${context})` : cause,
...detail,
};
}
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
@@ -121,11 +164,7 @@ export const POST = apiHandler(async () => {
message: t("notifications.admin.testNotificationBody"),
};
const results: Array<{
channel: string;
success: boolean;
error?: string;
}> = [];
const results: TestResult[] = [];
for (const channel of channels) {
const pref = channel.preferences[0];
@@ -166,11 +205,40 @@ export const POST = apiHandler(async () => {
const ntfyResult = await sendViaNtfy(config, payload);
success = ntfyResult.ok;
if (!success) {
results.push({
channel: "NTFY",
success: false,
error: `Send failed (topic: ${config.topic})`,
});
results.push(
failedSend(
"NTFY",
"The ntfy server",
ntfyResult,
`topic: ${config.topic}`,
),
);
continue;
}
break;
}
// The generic webhook and email are first-class channels in the
// dispatcher; without these arms they reported "Unknown channel type".
case "WEBHOOK": {
const config = JSON.parse(
decrypt(channel.config),
) as WebhookChannelConfig;
const webhookResult = await sendViaWebhook(config, payload);
success = webhookResult.ok;
if (!success) {
results.push(failedSend("WEBHOOK", "The webhook", webhookResult));
continue;
}
break;
}
case "EMAIL": {
const config = JSON.parse(
decrypt(channel.config),
) as EmailChannelConfig;
const emailResult = await sendViaEmail(config, payload);
success = emailResult.ok;
if (!success) {
results.push(failedSend("EMAIL", "The mail server", emailResult));
continue;
}
break;