mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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" });
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user