mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,19 @@ import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
|
||||
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
|
||||
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
|
||||
import { sendViaApns } from "@/lib/notifications/senders/apns";
|
||||
import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
|
||||
import { sendViaEmail } from "@/lib/notifications/senders/email";
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import {
|
||||
testFailureDetail,
|
||||
testFailureSentence,
|
||||
type TestFailureDetail,
|
||||
} from "@/lib/notifications/test-delivery-failure";
|
||||
import type {
|
||||
TelegramChannelConfig,
|
||||
NtfyChannelConfig,
|
||||
WebhookChannelConfig,
|
||||
EmailChannelConfig,
|
||||
ChannelType,
|
||||
NotificationPayload,
|
||||
} from "@/lib/notifications/types";
|
||||
@@ -62,6 +72,39 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
type TestResult = {
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
} & TestFailureDetail;
|
||||
|
||||
/**
|
||||
* A failed HTTP or SMTP send, with the code, status and SMTP reply code the
|
||||
* user-facing test routes answer with, instead of a sentence that drops
|
||||
* them. A private-origin refusal forwards its own code.
|
||||
*/
|
||||
function failedSend(
|
||||
channel: string,
|
||||
label: string,
|
||||
result: SendOutcome,
|
||||
context?: string,
|
||||
): TestResult {
|
||||
const detail: TestFailureDetail = result.errorCode
|
||||
? { errorCode: result.errorCode }
|
||||
: testFailureDetail(result);
|
||||
const cause = result.errorCode
|
||||
? `refused by the private-origin policy (${result.errorCode})`
|
||||
: detail.errorCode
|
||||
? testFailureSentence(label, detail)
|
||||
: (result.reason ?? "send failed");
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: context ? `${cause} (${context})` : cause,
|
||||
...detail,
|
||||
};
|
||||
}
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
return (
|
||||
value === "de" ||
|
||||
@@ -121,11 +164,7 @@ export const POST = apiHandler(async () => {
|
||||
message: t("notifications.admin.testNotificationBody"),
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> = [];
|
||||
const results: TestResult[] = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
const pref = channel.preferences[0];
|
||||
@@ -166,11 +205,40 @@ export const POST = apiHandler(async () => {
|
||||
const ntfyResult = await sendViaNtfy(config, payload);
|
||||
success = ntfyResult.ok;
|
||||
if (!success) {
|
||||
results.push({
|
||||
channel: "NTFY",
|
||||
success: false,
|
||||
error: `Send failed (topic: ${config.topic})`,
|
||||
});
|
||||
results.push(
|
||||
failedSend(
|
||||
"NTFY",
|
||||
"The ntfy server",
|
||||
ntfyResult,
|
||||
`topic: ${config.topic}`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// The generic webhook and email are first-class channels in the
|
||||
// dispatcher; without these arms they reported "Unknown channel type".
|
||||
case "WEBHOOK": {
|
||||
const config = JSON.parse(
|
||||
decrypt(channel.config),
|
||||
) as WebhookChannelConfig;
|
||||
const webhookResult = await sendViaWebhook(config, payload);
|
||||
success = webhookResult.ok;
|
||||
if (!success) {
|
||||
results.push(failedSend("WEBHOOK", "The webhook", webhookResult));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "EMAIL": {
|
||||
const config = JSON.parse(
|
||||
decrypt(channel.config),
|
||||
) as EmailChannelConfig;
|
||||
const emailResult = await sendViaEmail(config, payload);
|
||||
success = emailResult.ok;
|
||||
if (!success) {
|
||||
results.push(failedSend("EMAIL", "The mail server", emailResult));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* POST /api/settings/email/test — a failed send names its cause.
|
||||
*
|
||||
* Before this the route answered every failure with a bare 500, so the card
|
||||
* read "Test failed" whether the mail server refused the login, timed out,
|
||||
* or rejected the recipient.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { notificationChannel: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
||||
}));
|
||||
const sendViaEmailMock = vi.fn();
|
||||
vi.mock("@/lib/notifications/senders/email", () => ({
|
||||
sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/email-config", () => ({
|
||||
isEmailConfigured: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../email/test/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function post(): Promise<Response> {
|
||||
return (POST as (request: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/settings/email/test", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config: 'encrypted:{"recipient":"you@example.com"}',
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/settings/email/test", () => {
|
||||
it.each([
|
||||
["credentials_rejected", 535, "rejected the credentials (SMTP 535)"],
|
||||
["upstream_rejected", 550, "refused the message (SMTP 550)"],
|
||||
])(
|
||||
"answers an SMTP rejection with 502, %s and the reply code",
|
||||
async (failureCode, smtpCode, sentence) => {
|
||||
sendViaEmailMock.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: smtpCode >= 500,
|
||||
reason: "email_smtp_5xx",
|
||||
failureCode,
|
||||
smtpCode,
|
||||
});
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: failureCode, smtpCode });
|
||||
expect(json.error).toContain(sentence);
|
||||
},
|
||||
);
|
||||
|
||||
it("answers a timeout with 502 and the timeout code", async () => {
|
||||
sendViaEmailMock.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "email_smtp_error",
|
||||
failureCode: "timeout",
|
||||
});
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
meta: { errorCode: "timeout" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a 500 for a failure the sender could not name", async () => {
|
||||
sendViaEmailMock.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "email_smtp_error",
|
||||
});
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("answers a delivered test with 200", async () => {
|
||||
sendViaEmailMock.mockResolvedValue({ ok: true });
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@
|
||||
* of a generic "Invalid data", and a listed literal address saves although
|
||||
* the plain public floor would refuse it. The test routes forward the
|
||||
* sender's own refusal as a 422 with the same code instead of the bare 500
|
||||
* that made the reporter read the wide-event log to learn it was policy.
|
||||
* that made the reporter read the wide-event log to learn it was policy, and
|
||||
* answer a relay's error status with a 502 that carries the status and what
|
||||
* the relay said.
|
||||
*
|
||||
* The last block is the structural half: the test button and the dispatcher
|
||||
* must reach the SAME sender function, so the decision cannot differ between
|
||||
@@ -351,20 +353,99 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
["ntfy", sendViaNtfyMock],
|
||||
])("%s: a plain delivery failure is still a 500", async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_503`,
|
||||
statusCode: 503,
|
||||
});
|
||||
])(
|
||||
"%s: a relay that answered 400 is a 502 naming the status and what it said, not a bare 500 (#947: the card could only say Test failed)",
|
||||
async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_400`,
|
||||
statusCode: 400,
|
||||
upstreamBody: '{"error":"Bad Request","errorCode":400}',
|
||||
});
|
||||
|
||||
const response = await post(path);
|
||||
const response = await post(path);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toBeUndefined();
|
||||
});
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
upstreamBody: '{"error":"Bad Request","errorCode":400}',
|
||||
});
|
||||
expect(json.error).toContain("HTTP 400");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[401, "credentials_rejected"],
|
||||
[403, "credentials_rejected"],
|
||||
[404, "endpoint_not_found"],
|
||||
[410, "endpoint_not_found"],
|
||||
[415, "upstream_rejected"],
|
||||
[422, "upstream_rejected"],
|
||||
[429, "rate_limited"],
|
||||
[503, "upstream_error"],
|
||||
[307, "redirected"],
|
||||
])(
|
||||
"webhook: an upstream %i maps to %s, without a body when the sender dropped it",
|
||||
async (status, code) => {
|
||||
sendViaWebhookMock.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `webhook_${status}`,
|
||||
statusCode: status,
|
||||
});
|
||||
|
||||
const response = await post("webhook");
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: code, upstreamStatus: status });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
["ntfy", sendViaNtfyMock],
|
||||
])(
|
||||
"%s: a timeout without a status is a 502 with the timeout code",
|
||||
async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_network_error`,
|
||||
failureCode: "timeout",
|
||||
});
|
||||
|
||||
const response = await post(path);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
meta: { errorCode: "timeout" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
["ntfy", sendViaNtfyMock],
|
||||
])(
|
||||
"%s: only a failure the sender could not name stays a 500",
|
||||
async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_network_error`,
|
||||
});
|
||||
|
||||
const response = await post(path);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isEmailConfigured } from "@/lib/notifications/senders/email-config";
|
||||
import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
|
||||
|
||||
/**
|
||||
* POST: send a test email to the configured recipient.
|
||||
@@ -46,7 +47,13 @@ export const POST = apiHandler(async () => {
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return apiError("Failed to send test email", 500);
|
||||
// The mail server, or the way to it, failed: 502 with the code and the
|
||||
// SMTP reply code. Only an unnamed internal fault is a 500.
|
||||
return answerTestDeliveryFailure(result, {
|
||||
channel: "email",
|
||||
label: "The mail server",
|
||||
fallbackMessage: "Failed to send test email",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NtfyChannelConfig } from "@/lib/notifications/types";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
|
||||
|
||||
/**
|
||||
* POST: Send a test notification via ntfy.
|
||||
@@ -58,7 +59,13 @@ export const POST = apiHandler(async () => {
|
||||
{ errorCode: result.errorCode },
|
||||
);
|
||||
}
|
||||
return apiError("Failed to send test message", 500);
|
||||
// The server, or the way to it, failed: 502 with the upstream status
|
||||
// and what it said (#947). Only an unnamed internal fault is a 500.
|
||||
return answerTestDeliveryFailure(result, {
|
||||
channel: "ntfy",
|
||||
label: "The ntfy server",
|
||||
fallbackMessage: "Failed to send test message",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({ action: { name: "settings.ntfy.test" }, meta: { success: true } });
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user