fix(notifications): treat channel config decrypt/parse failure as a hard reject

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
(`<type>_config_decrypt_failed` / `<type>_config_parse_failed`). Genuine
transient failures (network, timeout, 5xx) keep the existing backoff path.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 09:26:31 +02:00
1 parent a20e140a2f
commit e1e1b0e985
2 files changed
+106 -5

No files matched your search

@@ -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<typeof vi.fn>
).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<typeof vi.fn>)
.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<typeof vi.fn>
).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" });
+16 -5
View File
@@ -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",
};
}