From e1e1b0e9853d7f66b3a338bc966428e460d42358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:26:31 +0200 Subject: [PATCH] fix(notifications): treat channel config decrypt/parse failure as a hard reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel whose stored config cannot be decrypted or JSON-parsed is a structurally-permanent failure, not a transient blip: the ciphertext/key pairing and the malformed body decode the same way on every retry. Routing it through the transient path burned the 5-failure backoff budget and ended on the misleading `give_up_after_5_failures` reason — during a key-rotation gap every channel hit this and auto-disabled for the wrong reason. Classify decrypt and parse failures as hard rejects so the channel auto-disables on first occurrence with the precise reason (`_config_decrypt_failed` / `_config_parse_failed`). Genuine transient failures (network, timeout, 5xx) keep the existing backoff path. --- .../__tests__/dispatcher.test.ts | 90 +++++++++++++++++++ src/lib/notifications/dispatcher.ts | 21 +++-- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index 1795de825..f350ec7b5 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -502,6 +502,96 @@ describe("dispatchNotification — Telegram hard reject", () => { }); }); +describe("dispatchNotification — config parse/decrypt failure is a hard reject", () => { + it("malformed channel config auto-disables on first occurrence (not after 5 transient ticks)", async () => { + const channel = makeChannel({ + type: "NTFY", + // Not valid JSON → JSON.parse throws in sendToChannel. + config: "{ this is not json", + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + // The sender is never reached on a parse failure. + expect(sendViaNtfyMock).not.toHaveBeenCalled(); + + // Channel auto-disabled immediately with the precise parse reason — + // NOT the misleading `give_up_after_5_failures`. + const updateCalls = ( + prisma.notificationChannel.update as unknown as ReturnType + ).mock.calls; + const disablePayload = updateCalls.find( + (c) => (c[0] as { data: { enabled?: boolean } }).data?.enabled === false, + ); + expect(disablePayload).toBeTruthy(); + expect( + (disablePayload?.[0] as { data: { disabledReason?: string } }).data + .disabledReason, + ).toBe("ntfy_config_parse_failed"); + + // It is logged as a hard reject, not a transient give-up. + expect(auditLog).toHaveBeenCalledWith( + "notification.channel.auto_disabled", + expect.objectContaining({ + details: expect.objectContaining({ + reason: "ntfy_config_parse_failed", + kind: "hard_reject", + }), + }), + ); + }); + + it("a genuine transient failure still takes the transient path (stays enabled, backoff set)", async () => { + const channel = makeChannel({ + type: "NTFY", + config: JSON.stringify({ serverUrl: "https://ntfy.example", topic: "t" }), + consecutiveFailures: 0, + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + sendViaNtfyMock.mockResolvedValueOnce({ + ok: false, + hardReject: false, + statusCode: 503, + reason: "ntfy_503", + }); + (prisma.notificationChannel.update as unknown as ReturnType) + .mockResolvedValueOnce({ consecutiveFailures: 1 }) + .mockResolvedValue({}); + + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + const updateCalls = ( + prisma.notificationChannel.update as unknown as ReturnType + ).mock.calls; + // No enabled=false update → channel stays enabled, backoff scheduled. + const disablePayload = updateCalls.find( + (c) => (c[0] as { data: { enabled?: boolean } }).data?.enabled === false, + ); + expect(disablePayload).toBeUndefined(); + const cooldownPayload = updateCalls.find( + (c) => + (c[0] as { data: { nextRetryAt?: Date } }).data?.nextRetryAt instanceof + Date, + ); + expect(cooldownPayload).toBeTruthy(); + }); +}); + describe("dispatchNotification — reminders reach Web Push without APNs", () => { it("a PWA-only self-hoster (WEB_PUSH channel, no APNS) still gets MEDICATION_REMINDER", async () => { const channel = makeChannel({ type: "WEB_PUSH" }); diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 9bbeadc27..cab12442e 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -263,9 +263,16 @@ async function sendToChannel( decrypted = decrypt(encryptedConfig); } catch { getEvent()?.addWarning(`Failed to decrypt ${type} channel config`); + // A config that can't be decrypted is a structurally-permanent failure, + // not a transient blip: the ciphertext / key pairing won't fix itself on + // a retry. Hard-reject so the channel auto-disables on first occurrence + // with an accurate reason, rather than burning the 5-failure transient + // budget and ending on the misleading `give_up_after_5_failures`. During + // a key-rotation gap every channel would otherwise auto-disable for the + // wrong reason. return { ok: false, - hardReject: false, + hardReject: true, reason: `${type.toLowerCase()}_config_decrypt_failed`, }; } @@ -277,9 +284,10 @@ async function sendToChannel( config = JSON.parse(decrypted) as TelegramChannelConfig; } catch { getEvent()?.addWarning("Failed to parse Telegram channel config"); + // Malformed config decodes the same on every retry — permanent. return { ok: false, - hardReject: false, + hardReject: true, reason: "telegram_config_parse_failed", }; } @@ -291,9 +299,10 @@ async function sendToChannel( config = JSON.parse(decrypted) as NtfyChannelConfig; } catch { getEvent()?.addWarning("Failed to parse ntfy channel config"); + // Malformed config decodes the same on every retry — permanent. return { ok: false, - hardReject: false, + hardReject: true, reason: "ntfy_config_parse_failed", }; } @@ -305,9 +314,10 @@ async function sendToChannel( config = JSON.parse(decrypted) as WebhookChannelConfig; } catch { getEvent()?.addWarning("Failed to parse webhook channel config"); + // Malformed config decodes the same on every retry — permanent. return { ok: false, - hardReject: false, + hardReject: true, reason: "webhook_config_parse_failed", }; } @@ -319,9 +329,10 @@ async function sendToChannel( config = JSON.parse(decrypted) as EmailChannelConfig; } catch { getEvent()?.addWarning("Failed to parse email channel config"); + // Malformed config decodes the same on every retry — permanent. return { ok: false, - hardReject: false, + hardReject: true, reason: "email_config_parse_failed", }; }