feat: add ntfy notification support
This commit is contained in:
1 parent
4a939ad2c4
commit
8a83bc99e9
17 files changed
+492
-27
No files matched your search
@@ -78,6 +78,14 @@ function buildDefaults(data: Notification | null): NotificationFormData {
|
||||
accessToken: data.accessToken || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "ntfy") {
|
||||
return {
|
||||
type: "ntfy",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
topic: data.topic || "",
|
||||
};
|
||||
}
|
||||
// Default: email (covers both data === null and data.type === "email")
|
||||
return {
|
||||
type: "email",
|
||||
|
||||
@@ -150,7 +150,8 @@ const NotificationsCreatePage = () => {
|
||||
{watchedType !== "matrix" &&
|
||||
watchedType !== "telegram" &&
|
||||
watchedType !== "pushover" &&
|
||||
watchedType !== "twilio" && (
|
||||
watchedType !== "twilio" &&
|
||||
watchedType !== "ntfy" && (
|
||||
<ConfigBox
|
||||
title={addressConfig.title}
|
||||
subtitle={addressConfig.description}
|
||||
@@ -174,6 +175,48 @@ const NotificationsCreatePage = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "ntfy" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.ntfy.title")}
|
||||
subtitle={t("pages.notifications.form.ntfy.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
|
||||
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="topic"
|
||||
control={control}
|
||||
defaultValue={"topic" in defaults ? defaults.topic : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
|
||||
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "telegram" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.telegram.title")}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NotificationChannels = [
|
||||
"telegram",
|
||||
"pushover",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
@@ -25,6 +26,7 @@ export interface Notification {
|
||||
accessToken?: string;
|
||||
accountSid?: string;
|
||||
twilioPhoneNumber?: string;
|
||||
topic?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -70,6 +70,12 @@ const twilioSchema = baseSchema.extend({
|
||||
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
|
||||
});
|
||||
|
||||
const ntfySchema = baseSchema.extend({
|
||||
type: z.literal("ntfy"),
|
||||
address: z.string().min(1, "Server URL is required").url("Please enter a valid URL"),
|
||||
topic: z.string().min(1, "Topic is required"),
|
||||
});
|
||||
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
emailSchema,
|
||||
slackSchema,
|
||||
@@ -81,6 +87,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
telegramSchema,
|
||||
pushoverSchema,
|
||||
twilioSchema,
|
||||
ntfySchema,
|
||||
]);
|
||||
|
||||
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
||||
@@ -1155,6 +1155,14 @@
|
||||
"placeholderFromNumber": "+15551234567",
|
||||
"optionToNumber": "To number (recipient)",
|
||||
"placeholderToNumber": "+15559876543"
|
||||
},
|
||||
"ntfy": {
|
||||
"title": "ntfy configuration",
|
||||
"description": "Configure an ntfy server and topic for push notifications.",
|
||||
"optionServerUrl": "Server URL",
|
||||
"placeholderServerUrl": "https://ntfy.sh",
|
||||
"optionTopic": "Topic",
|
||||
"placeholderTopic": "checkmate-alerts"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
||||
+107
-2
@@ -392,6 +392,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -469,6 +475,7 @@
|
||||
"ignoreTlsErrors",
|
||||
"useAdvancedMatching",
|
||||
"notifications",
|
||||
"tags",
|
||||
"cpuAlertThreshold",
|
||||
"memoryAlertThreshold",
|
||||
"diskAlertThreshold",
|
||||
@@ -545,6 +552,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/TwilioNotification"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NtfyNotification"
|
||||
}
|
||||
],
|
||||
"discriminator": {
|
||||
@@ -559,7 +569,8 @@
|
||||
"teams": "#/components/schemas/TeamsNotification",
|
||||
"telegram": "#/components/schemas/TelegramNotification",
|
||||
"pushover": "#/components/schemas/PushoverNotification",
|
||||
"twilio": "#/components/schemas/TwilioNotification"
|
||||
"twilio": "#/components/schemas/TwilioNotification",
|
||||
"ntfy": "#/components/schemas/NtfyNotification"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1003,6 +1014,34 @@
|
||||
"phone": "+15551234567",
|
||||
"twilioPhoneNumber": "+15557654321"
|
||||
}
|
||||
},
|
||||
"NtfyNotification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notificationName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["ntfy"]
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["notificationName", "type", "address", "topic"],
|
||||
"example": {
|
||||
"notificationName": "ntfy topic",
|
||||
"type": "ntfy",
|
||||
"address": "https://ntfy.sh",
|
||||
"topic": "checkmate-alerts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {}
|
||||
@@ -2362,6 +2401,16 @@
|
||||
"profilePicture": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
|
||||
},
|
||||
"newPassword": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
|
||||
},
|
||||
"profileImage": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
@@ -5207,7 +5256,8 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"duration": {
|
||||
"type": "number"
|
||||
@@ -5478,6 +5528,24 @@
|
||||
"required": false,
|
||||
"name": "filter",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"name": "tags",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -5672,6 +5740,24 @@
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"name": "tags",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
@@ -6924,6 +7010,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7586,6 +7678,13 @@
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -8104,6 +8203,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -68,6 +68,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
|
||||
twilioPhoneNumber: "+15557654321",
|
||||
},
|
||||
},
|
||||
ntfy: {
|
||||
component: "NtfyNotification",
|
||||
example: { notificationName: "ntfy topic", type: "ntfy", address: "https://ntfy.sh", topic: "checkmate-alerts" },
|
||||
},
|
||||
};
|
||||
|
||||
const decoratedVariants = createNotificationBodyValidation.options.map((variant) => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
TelegramProvider,
|
||||
PushoverProvider,
|
||||
TwilioProvider,
|
||||
NtfyProvider,
|
||||
// Interfaces
|
||||
INetworkService,
|
||||
IEmailService,
|
||||
@@ -304,6 +305,7 @@ export const initializeServices = async ({
|
||||
const telegramProvider = new TelegramProvider(logger);
|
||||
const pushoverProvider = new PushoverProvider(logger);
|
||||
const twilioProvider = new TwilioProvider(logger);
|
||||
const ntfyProvider = new NtfyProvider(logger);
|
||||
|
||||
const notificationsService = new NotificationsService(
|
||||
notificationsRepository,
|
||||
@@ -318,6 +320,7 @@ export const initializeServices = async ({
|
||||
telegramProvider,
|
||||
pushoverProvider,
|
||||
twilioProvider,
|
||||
ntfyProvider,
|
||||
settingsService,
|
||||
logger,
|
||||
notificationMessageBuilder
|
||||
|
||||
@@ -25,7 +25,19 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
|
||||
enum: [
|
||||
"email",
|
||||
"slack",
|
||||
"discord",
|
||||
"webhook",
|
||||
"pager_duty",
|
||||
"matrix",
|
||||
"teams",
|
||||
"telegram",
|
||||
"pushover",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as NotificationChannel[],
|
||||
required: true,
|
||||
},
|
||||
notificationName: {
|
||||
@@ -39,6 +51,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
accessToken: { type: String },
|
||||
accountSid: { type: String },
|
||||
twilioPhoneNumber: { type: String },
|
||||
topic: { type: String },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -34,6 +34,7 @@ class MongoNotificationsRepository implements INotificationsRepository {
|
||||
accessToken: doc.accessToken ?? undefined,
|
||||
accountSid: doc.accountSid ?? undefined,
|
||||
twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined,
|
||||
topic: doc.topic ?? undefined,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/telegram.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/pushover.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/twilio.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/ntfy.js";
|
||||
|
||||
// System services
|
||||
export * from "@/service/system/settingsService.js";
|
||||
@@ -0,0 +1,128 @@
|
||||
const SERVICE_NAME = "NtfyProvider";
|
||||
import type { Notification } from "@/types/index.js";
|
||||
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
|
||||
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
|
||||
import got from "got";
|
||||
|
||||
export class NtfyProvider extends NotificationProvider {
|
||||
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
|
||||
if (!notification.address || !notification.topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await got.post(this.buildTopicUrl(notification.address, notification.topic), {
|
||||
body: getTestMessage(),
|
||||
headers: {
|
||||
Title: "Checkmate test notification",
|
||||
Priority: "default",
|
||||
},
|
||||
...this.gotRequestOptions(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn({
|
||||
message: "ntfy test alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendTestAlert",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
|
||||
if (!notification.address || !notification.topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await got.post(this.buildTopicUrl(notification.address, notification.topic), {
|
||||
body: this.buildNtfyText(message),
|
||||
headers: {
|
||||
Title: message.content.title,
|
||||
Priority: this.mapPriority(message.severity),
|
||||
Tags: this.mapTags(message.severity),
|
||||
},
|
||||
...this.gotRequestOptions(),
|
||||
});
|
||||
this.logger.info({
|
||||
message: "ntfy notification sent",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn({
|
||||
message: "ntfy alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private buildTopicUrl(address: string, topic: string): string {
|
||||
return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
private buildNtfyText(message: NotificationMessage): string {
|
||||
const lines = [
|
||||
message.content.summary,
|
||||
"",
|
||||
"Monitor Details:",
|
||||
`- Name: ${message.monitor.name}`,
|
||||
`- URL: ${message.monitor.url}`,
|
||||
`- Type: ${message.monitor.type}`,
|
||||
`- Status: ${message.monitor.status}`,
|
||||
];
|
||||
|
||||
if (message.content.details && message.content.details.length > 0) {
|
||||
lines.push("", "Additional Information:");
|
||||
message.content.details.forEach((detail) => lines.push(`- ${detail}`));
|
||||
}
|
||||
|
||||
if (message.content.thresholds && message.content.thresholds.length > 0) {
|
||||
lines.push("", "Threshold Breaches:");
|
||||
message.content.thresholds.forEach((breach) => {
|
||||
lines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (message.content.incident) {
|
||||
lines.push("", `${message.clientHost}/infrastructure/${message.monitor.id}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private mapPriority(severity: NotificationMessage["severity"]): string {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "high";
|
||||
case "warning":
|
||||
return "default";
|
||||
case "success":
|
||||
return "low";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
private mapTags(severity: NotificationMessage["severity"]): string {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "rotating_light";
|
||||
case "warning":
|
||||
return "warning";
|
||||
case "success":
|
||||
return "white_check_mark";
|
||||
default:
|
||||
return "information_source";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export class NotificationsService implements INotificationsService {
|
||||
private telegramProvider: INotificationProvider;
|
||||
private pushoverProvider: INotificationProvider;
|
||||
private twilioProvider: INotificationProvider;
|
||||
private ntfyProvider: INotificationProvider;
|
||||
private logger: ILogger;
|
||||
private settingsService: ISettingsService;
|
||||
private notificationMessageBuilder: INotificationMessageBuilder;
|
||||
@@ -53,6 +54,7 @@ export class NotificationsService implements INotificationsService {
|
||||
telegramProvider: INotificationProvider,
|
||||
pushoverProvider: INotificationProvider,
|
||||
twilioProvider: INotificationProvider,
|
||||
ntfyProvider: INotificationProvider,
|
||||
settingsService: ISettingsService,
|
||||
logger: ILogger,
|
||||
notificationMessageBuilder: INotificationMessageBuilder
|
||||
@@ -69,6 +71,7 @@ export class NotificationsService implements INotificationsService {
|
||||
this.telegramProvider = telegramProvider;
|
||||
this.pushoverProvider = pushoverProvider;
|
||||
this.twilioProvider = twilioProvider;
|
||||
this.ntfyProvider = ntfyProvider;
|
||||
this.settingsService = settingsService;
|
||||
this.logger = logger;
|
||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||
@@ -112,6 +115,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
|
||||
case "twilio":
|
||||
return await this.twilioProvider.sendMessage!(notification, notificationMessage);
|
||||
case "ntfy":
|
||||
return await this.ntfyProvider.sendMessage!(notification, notificationMessage);
|
||||
default:
|
||||
this.logger.warn({
|
||||
message: `Unknown notification type: ${notification.type}`,
|
||||
@@ -178,6 +183,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.pushoverProvider.sendTestAlert(notification);
|
||||
case "twilio":
|
||||
return await this.twilioProvider.sendTestAlert(notification);
|
||||
case "ntfy":
|
||||
return await this.ntfyProvider.sendTestAlert(notification);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NotificationChannels = [
|
||||
"telegram",
|
||||
"pushover",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
@@ -25,6 +26,7 @@ export interface Notification {
|
||||
accessToken?: string;
|
||||
accountSid?: string;
|
||||
twilioPhoneNumber?: string;
|
||||
topic?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -88,6 +88,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
phone: z.string().min(1, "Recipient phone number is required"),
|
||||
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
|
||||
}),
|
||||
// ntfy notification
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("ntfy"),
|
||||
address: z.url({ message: "Please enter a valid ntfy server URL" }),
|
||||
topic: z.string().min(1, "Topic is required"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const testNotificationBodyValidation = createNotificationBodyValidation;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { NtfyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/ntfy.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new NtfyProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
const makeNtfyNotification = (overrides = {}) =>
|
||||
makeNotification({
|
||||
address: "https://ntfy.sh/",
|
||||
topic: "checkmate-alerts",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
testNotificationProviderContract("NtfyProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNtfyNotification(),
|
||||
});
|
||||
|
||||
describe("NtfyProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("posts the test message to the configured topic", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/checkmate-alerts",
|
||||
expect.objectContaining({
|
||||
body: expect.any(String),
|
||||
headers: expect.objectContaining({ Title: "Checkmate test notification" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when topic is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ topic: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNtfyNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("posts plain text with severity headers to the configured topic", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/checkmate-alerts",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("Monitor Details"),
|
||||
headers: expect.objectContaining({
|
||||
Title: "Monitor Down: Test Monitor",
|
||||
Priority: "high",
|
||||
Tags: "rotating_light",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("url-encodes topics", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNtfyNotification({ topic: "ops alerts" }) as any, makeMessage());
|
||||
expect(mockGotPost.mock.calls[0][0]).toBe("https://ntfy.sh/ops%20alerts");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNtfyNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when topic is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNtfyNotification({ topic: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes threshold breaches in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithThresholds());
|
||||
expect(mockGotPost.mock.calls[0][1].body).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes incident links in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithIncident());
|
||||
expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
const telegramProvider = createProvider();
|
||||
const pushoverProvider = createProvider();
|
||||
const twilioProvider = createProvider();
|
||||
const ntfyProvider = createProvider();
|
||||
const settingsService = createSettingsService();
|
||||
const notificationMessageBuilder = createMessageBuilder();
|
||||
|
||||
@@ -64,6 +65,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
telegramProvider,
|
||||
pushoverProvider,
|
||||
twilioProvider,
|
||||
ntfyProvider,
|
||||
settingsService,
|
||||
notificationMessageBuilder,
|
||||
...overrides,
|
||||
@@ -82,6 +84,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
defaults.telegramProvider as any,
|
||||
defaults.pushoverProvider as any,
|
||||
defaults.twilioProvider as any,
|
||||
defaults.ntfyProvider as any,
|
||||
defaults.settingsService as any,
|
||||
defaults.logger as any,
|
||||
defaults.notificationMessageBuilder as any
|
||||
@@ -147,7 +150,7 @@ describe("NotificationsService", () => {
|
||||
});
|
||||
|
||||
it("routes to correct provider for each notification type", async () => {
|
||||
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const;
|
||||
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio", "ntfy"] as const;
|
||||
for (const type of types) {
|
||||
const deps = createService();
|
||||
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
|
||||
@@ -165,6 +168,7 @@ describe("NotificationsService", () => {
|
||||
telegram: deps.telegramProvider,
|
||||
pushover: deps.pushoverProvider,
|
||||
twilio: deps.twilioProvider,
|
||||
ntfy: deps.ntfyProvider,
|
||||
};
|
||||
expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
@@ -238,30 +242,40 @@ describe("NotificationsService", () => {
|
||||
// ── sendTestNotification ─────────────────────────────────────────────────
|
||||
|
||||
describe("sendTestNotification", () => {
|
||||
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)(
|
||||
"routes %s to the correct provider",
|
||||
async (type) => {
|
||||
const deps = createService();
|
||||
const notification = makeNotification({ type: type as any });
|
||||
it.each([
|
||||
["email"],
|
||||
["slack"],
|
||||
["discord"],
|
||||
["pager_duty"],
|
||||
["matrix"],
|
||||
["webhook"],
|
||||
["teams"],
|
||||
["telegram"],
|
||||
["pushover"],
|
||||
["twilio"],
|
||||
["ntfy"],
|
||||
] as const)("routes %s to the correct provider", async (type) => {
|
||||
const deps = createService();
|
||||
const notification = makeNotification({ type: type as any });
|
||||
|
||||
const result = await deps.service.sendTestNotification(notification);
|
||||
const result = await deps.service.sendTestNotification(notification);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
|
||||
webhook: deps.webhookProvider,
|
||||
slack: deps.slackProvider,
|
||||
matrix: deps.matrixProvider,
|
||||
pager_duty: deps.pagerDutyProvider,
|
||||
discord: deps.discordProvider,
|
||||
email: deps.emailProvider,
|
||||
teams: deps.teamsProvider,
|
||||
telegram: deps.telegramProvider,
|
||||
pushover: deps.pushoverProvider,
|
||||
twilio: deps.twilioProvider,
|
||||
};
|
||||
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
|
||||
}
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
|
||||
webhook: deps.webhookProvider,
|
||||
slack: deps.slackProvider,
|
||||
matrix: deps.matrixProvider,
|
||||
pager_duty: deps.pagerDutyProvider,
|
||||
discord: deps.discordProvider,
|
||||
email: deps.emailProvider,
|
||||
teams: deps.teamsProvider,
|
||||
telegram: deps.telegramProvider,
|
||||
pushover: deps.pushoverProvider,
|
||||
twilio: deps.twilioProvider,
|
||||
ntfy: deps.ntfyProvider,
|
||||
};
|
||||
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it("returns false for unknown notification type", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
Reference in new issue
Block a user