diff --git a/.gitignore b/.gitignore index 055e3d31ef..d4c561fcc5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,11 @@ -.idea -.vscode -.VSCodeCounter -*.sh -mongo -timescaledb -node_modules/ -docs/architecture -docs/reviews -docs/todo -docs/frontend +.idea +.vscode +.VSCodeCounter +*.sh +mongo +node_modules/ +docs/architecture +docs/reviews +docs/todo +docs/frontend docs/timescale \ No newline at end of file diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0b42b6d40d..d009958fba 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -68,6 +68,17 @@ function buildDefaults(data: Notification | null): NotificationFormData { accessToken: data.accessToken || "", }; } + if (data?.type === "ntfy") { + return { + type: "ntfy", + notificationName: data.notificationName || "", + address: data.address || "", + authType: data.authType || "none", + username: data.username || "", + password: data.password || "", + accessToken: data.accessToken || "", + }; + } // Default: email (covers both data === null and data.type === "email") return { type: "email", diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 3da9e6f757..bf13992e9c 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -13,9 +13,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useNotificationForm } from "@/Hooks/useNotificationForm"; import type { NotificationFormData } from "@/Validation/notifications"; -import type { Notification } from "@/Types/Notification"; +import { type Notification, NotificationChannels, AuthTypes } from "@/Types/Notification"; import { useTranslation } from "react-i18next"; -import { NotificationChannels } from "@/Types/Notification"; +import { dropStaleAuth } from "@/Utils/NotificationUtils"; const NotificationsCreatePage = () => { const { t } = useTranslation(); @@ -46,6 +46,7 @@ const NotificationsCreatePage = () => { }, [defaults, reset]); const watchedType = watch("type"); + const watchedAuthType = watch("authType"); useEffect(() => { clearErrors(); @@ -77,9 +78,10 @@ const NotificationsCreatePage = () => { }, [watchedType, t]); const onSubmit = async (data: NotificationFormData) => { + const payload = dropStaleAuth(data); const result = isEditMode - ? await patch(`/notifications/${notificationId}`, data) - : await post("/notifications", data); + ? await patch(`/notifications/${notificationId}`, payload) + : await post("/notifications", payload); if (result) { navigate("/notifications"); } @@ -149,7 +151,8 @@ const NotificationsCreatePage = () => { /> {watchedType !== "matrix" && watchedType !== "telegram" && - watchedType !== "pushover" && ( + watchedType !== "pushover" && + watchedType !== "ntfy" && ( { { } /> )} + {watchedType === "ntfy" && ( + + ( + + )} + /> + + } + /> + )} + {watchedType === "ntfy" && ( + + ( + + )} + /> + {watchedAuthType === "basic" && ( + <> + ( + + )} + shouldUnregister={true} + /> + ( + + )} + shouldUnregister={true} + /> + + )} + {watchedAuthType === "bearer" && ( + ( + + )} + shouldUnregister={true} + /> + )} + + } + /> + )} + { + // Providers That Support Basic/Bearer Auth: Drop Stale Data + if (data.type !== "ntfy") return data; + const authType = data.authType ?? "none"; + const base = { ...data, authType }; + switch (authType) { + case "none": + return { ...base, username: "", password: "", accessToken: "" }; + case "basic": + return { ...base, accessToken: "" }; + case "bearer": + return { ...base, username: "", password: "" }; + default: + return base; + } +}; diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index a4c132d396..73ea231ac4 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -1,3 +1,4 @@ +import { AuthTypes } from "@/Types/Notification"; import { z } from "zod"; const baseSchema = z.object({ @@ -62,7 +63,16 @@ const pushoverSchema = baseSchema.extend({ accessToken: z.string().min(1, "App token is required"), }); -export const notificationSchema = z.discriminatedUnion("type", [ +const ntfySchema = baseSchema.extend({ + type: z.literal("ntfy"), + address: z.string().min(1, "URL is required").url("Please enter a valid URL"), + authType: z.enum(AuthTypes).optional(), + username: z.string().optional(), + password: z.string().optional(), + accessToken: z.string().optional(), +}); + +export const baseNotificationSchema = z.discriminatedUnion("type", [ emailSchema, slackSchema, discordSchema, @@ -72,6 +82,37 @@ export const notificationSchema = z.discriminatedUnion("type", [ teamsSchema, telegramSchema, pushoverSchema, + ntfySchema, ]); +export const notificationSchema = baseNotificationSchema.superRefine((data, ctx) => { + if (data.type === "ntfy") { + if (data.authType === "basic") { + if (!data.username) { + ctx.addIssue({ + code: "custom", + message: "Username is required", + path: ["username"], + }); + } + if (!data.password) { + ctx.addIssue({ + code: "custom", + message: "Password is required", + path: ["password"], + }); + } + } + if (data.authType === "bearer") { + if (!data.accessToken) { + ctx.addIssue({ + code: "custom", + message: "Token is required", + path: ["accessToken"], + }); + } + } + } +}); + export type NotificationFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 496c960865..6e937d505f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -913,9 +913,16 @@ "title": "Notification channles are used to:" }, "form": { - "accessToken": { - "optionAccessToken": "Access token", - "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + "auth": { + "title": "Authentication", + "description": "Configure authentication for your notification channel.", + "optionAuthType": "Authentication Type", + "optionUsername": "Username", + "placeholderUsername": "Enter Username", + "optionPassword": "Password", + "placeholderPassword": "Enter Password", + "optionAccessToken": "Access Token", + "placeholderAccessToken": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" }, "address": { "description": "The address where notifications will be sent.", @@ -968,6 +975,12 @@ "placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi", "optionUserKey": "User key", "placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG" + }, + "ntfy": { + "title": "Ntfy configuration", + "description": "Configure your Ntfy URL for notifications.", + "optionNtfyAddress": "Ntfy URL", + "placeholderNtfyAddress": "https://ntfy.sh/your-topic" } }, "table": { diff --git a/server/src/config/services.ts b/server/src/config/services.ts index ed9f02a9f5..00a4f34950 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -30,6 +30,7 @@ import { TeamsProvider, TelegramProvider, PushoverProvider, + NtfyProvider, // Interfaces INetworkService, IEmailService, @@ -300,6 +301,7 @@ export const initializeServices = async ({ const teamsProvider = new TeamsProvider(logger); const telegramProvider = new TelegramProvider(logger); const pushoverProvider = new PushoverProvider(logger); + const ntfyProvider = new NtfyProvider(logger); const notificationsService = new NotificationsService( notificationsRepository, @@ -313,6 +315,7 @@ export const initializeServices = async ({ teamsProvider, telegramProvider, pushoverProvider, + ntfyProvider, settingsService, logger, notificationMessageBuilder diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 0a49cbdb12..63f7dbe165 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -12,6 +12,7 @@ import { AppError } from "@/utils/AppError.js"; import { INotificationsService } from "@/service/index.js"; import { requireTeamId, requireUserId } from "./controllerUtils.js"; import { IMonitorsRepository } from "@/repositories/index.js"; +import type { Notification } from "@/types/notification.js"; const SERVICE_NAME = "NotificationController"; @@ -32,6 +33,14 @@ class NotificationController implements INotificationController { this.monitorsRepository = monitorsRepository; } + private sanitizeNotification = (notification: Notification): Omit => { + if (!notification) { + return notification; + } + const { username, password, accessToken, ...sanitized } = notification; + return sanitized; + }; + testNotification = async (req: Request, res: Response, next: NextFunction) => { try { const notification = testNotificationBodyValidation.parse(req.body); @@ -62,7 +71,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification created successfully", - data: notification, + data: this.sanitizeNotification(notification), }); } catch (error) { next(error); @@ -77,7 +86,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notifications fetched successfully", - data: notifications, + data: notifications.map(this.sanitizeNotification), }); } catch (error) { next(error); @@ -109,7 +118,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification fetched successfully", - data: notification, + data: this.sanitizeNotification(notification), }); } catch (error) { next(error); @@ -128,7 +137,7 @@ class NotificationController implements INotificationController { return res.status(200).json({ success: true, msg: "Notification updated successfully", - data: editedNotification, + data: this.sanitizeNotification(editedNotification), }); } catch (error) { next(error); diff --git a/server/src/db/migration/timescaledb/0022_create_auth_fields.ts b/server/src/db/migration/timescaledb/0022_create_auth_fields.ts new file mode 100644 index 0000000000..343f384a30 --- /dev/null +++ b/server/src/db/migration/timescaledb/0022_create_auth_fields.ts @@ -0,0 +1,30 @@ +import type { Pool } from "pg"; + +export const createAuthFields = async (pool: Pool) => { + await pool.query(` + DO $$ BEGIN + CREATE TYPE auth_type AS ENUM ('none', 'basic', 'bearer'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await pool.query(` + ALTER TABLE notifications ADD COLUMN IF NOT EXISTS auth_type auth_type; + ALTER TABLE notifications ADD COLUMN IF NOT EXISTS username TEXT; + ALTER TABLE notifications ADD COLUMN IF NOT EXISTS password TEXT; + `); + + await pool.query(` + ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'ntfy'; + `); +}; + +export const dropAuthFields = async (pool: Pool) => { + await pool.query(` + ALTER TABLE notifications DROP COLUMN IF EXISTS auth_type; + ALTER TABLE notifications DROP COLUMN IF EXISTS username; + ALTER TABLE notifications DROP COLUMN IF EXISTS password; + `); + + await pool.query(`DROP TYPE IF EXISTS auth_type;`); +}; diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts index 14084ebf3d..1d6f011595 100644 --- a/server/src/db/migration/timescaledb/index.ts +++ b/server/src/db/migration/timescaledb/index.ts @@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; +import { createAuthFields, dropAuthFields } from "./0022_create_auth_fields.js"; const SERVICE_NAME = "TimescaleDB Migrations"; @@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [ { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, + { name: "0022_create_auth_fields", up: createAuthFields, down: dropAuthFields }, ]; const ensureMigrationsTable = async (pool: Pool) => { diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 3b424bddeb..daebadc67f 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { Notification, NotificationChannel } from "@/types/notification.js"; +import { Notification, NotificationChannel, AuthTypes } from "@/types/notification.js"; interface NotificationDocument extends Omit { _id: Types.ObjectId; @@ -25,7 +25,7 @@ const NotificationSchema = new Schema( }, type: { type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as NotificationChannel[], + enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "ntfy"] as NotificationChannel[], required: true, }, notificationName: { @@ -36,6 +36,9 @@ const NotificationSchema = new Schema( phone: { type: String }, homeserverUrl: { type: String }, roomId: { type: String }, + authType: { type: String, enum: AuthTypes }, + username: { type: String }, + password: { type: String }, accessToken: { type: String }, }, { diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 3c4225b50e..7583e0376b 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -31,6 +31,9 @@ class MongoNotificationsRepository implements INotificationsRepository { phone: doc.phone ?? undefined, homeserverUrl: doc.homeserverUrl ?? undefined, roomId: doc.roomId ?? undefined, + authType: doc.authType ?? undefined, + username: doc.username ?? undefined, + password: doc.password ?? undefined, accessToken: doc.accessToken ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), diff --git a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts index b2c369c863..7748fb3c2f 100644 --- a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts +++ b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts @@ -1,6 +1,6 @@ import type { Pool } from "pg"; import { INotificationsRepository } from "@/repositories/notifications/INotificationsRepository.js"; -import type { Notification, NotificationChannel } from "@/types/notification.js"; +import type { AuthType, Notification, NotificationChannel } from "@/types/notification.js"; import { AppError } from "@/utils/AppError.js"; interface NotificationRow { @@ -13,21 +13,24 @@ interface NotificationRow { phone: string | null; homeserver_url: string | null; room_id: string | null; + auth_type: AuthType; + username: string | null; + password: string | null; access_token: string | null; created_at: Date; updated_at: Date; } const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone, - homeserver_url, room_id, access_token, created_at, updated_at`; + homeserver_url, room_id, auth_type, username, password, access_token, created_at, updated_at`; export class TimescaleNotificationsRepository implements INotificationsRepository { constructor(private pool: Pool) {} create = async (data: Partial): Promise => { const result = await this.pool.query( - `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, auth_type, username, password, access_token) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING ${COLUMNS}`, [ data.userId, @@ -38,6 +41,9 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor data.phone ?? null, data.homeserverUrl ?? null, data.roomId ?? null, + data.authType ?? "none", + data.username ?? null, + data.password ?? null, data.accessToken ?? null, ] ); @@ -82,6 +88,9 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor ["phone", "phone"], ["homeserverUrl", "homeserver_url"], ["roomId", "room_id"], + ["authType", "auth_type"], + ["username", "username"], + ["password", "password"], ["accessToken", "access_token"], ]; @@ -133,6 +142,9 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor phone: row.phone ?? undefined, homeserverUrl: row.homeserver_url ?? undefined, roomId: row.room_id ?? undefined, + authType: row.auth_type ?? "none", + username: row.username ?? undefined, + password: row.password ?? undefined, accessToken: row.access_token ?? undefined, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), diff --git a/server/src/service/index.ts b/server/src/service/index.ts index da529ed2bc..3a3bc3ee8b 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -31,6 +31,7 @@ export * from "@/service/infrastructure/notificationProviders/teams.js"; 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/ntfy.js"; // System services export * from "@/service/system/settingsService.js"; diff --git a/server/src/service/infrastructure/notificationProviders/ntfy.ts b/server/src/service/infrastructure/notificationProviders/ntfy.ts new file mode 100644 index 0000000000..edd92cf2a8 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/ntfy.ts @@ -0,0 +1,118 @@ +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 { + sendTestAlert = async (notification: Partial) => { + if (!notification.address) { + return false; + } + + const auth = this.determineAuthMethod(notification); + + try { + await got.post(notification.address, { + body: getTestMessage(), + headers: { + "Content-Type": "text/plain", + ...(auth ? { Authorization: auth } : {}), + }, + ...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 { + if (!notification.address) { + this.logger.warn({ + message: "Ntfy notification missing URL", + service: SERVICE_NAME, + method: "sendMessage", + }); + return false; + } + + const auth = this.determineAuthMethod(notification); + const text = this.buildNtfyText(message); + + try { + await got.post(notification.address, { + body: text, + headers: { + "Content-Type": "text/plain", + Title: message.content.title, + ...(auth ? { Authorization: auth } : {}), + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Ntfy notification failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private determineAuthMethod(notification: Partial): string { + if (notification.authType === "basic" && notification.username && notification.password) { + return `Basic ${Buffer.from(`${notification.username}:${notification.password}`).toString("base64")}`; + } else if (notification.authType === "bearer" && notification.accessToken) { + return `Bearer ${notification.accessToken}`; + } + return ""; + } + + private buildNtfyText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.summary); + lines.push(""); + + lines.push("Monitor Details:"); + lines.push(`• Name: ${message.monitor.name}`); + lines.push(`• URL: ${message.monitor.url}`); + lines.push(`• Type: ${message.monitor.type}`); + lines.push(`• Status: ${message.monitor.status}`); + lines.push(`• Alert: ${message.type} (${message.severity})`); + + if (message.content.details && message.content.details.length > 0) { + lines.push(""); + lines.push("Additional Information:"); + message.content.details.forEach((detail) => lines.push(`• ${detail}`)); + } + + if (message.content.thresholds && message.content.thresholds.length > 0) { + lines.push(""); + 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(""); + const incidentUrl = message.content.incident.url || `${message.clientHost}/incidents/${message.content.incident.id}`; + lines.push(`Incident: ${incidentUrl}`); + } + + return lines.join("\n"); + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 0344ab30cb..adeab3e9a3 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -35,6 +35,7 @@ export class NotificationsService implements INotificationsService { private teamsProvider: INotificationProvider; private telegramProvider: INotificationProvider; private pushoverProvider: INotificationProvider; + private ntfyProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; private notificationMessageBuilder: INotificationMessageBuilder; @@ -51,6 +52,7 @@ export class NotificationsService implements INotificationsService { teamsProvider: INotificationProvider, telegramProvider: INotificationProvider, pushoverProvider: INotificationProvider, + ntfyProvider: INotificationProvider, settingsService: ISettingsService, logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder @@ -66,6 +68,7 @@ export class NotificationsService implements INotificationsService { this.teamsProvider = teamsProvider; this.telegramProvider = telegramProvider; this.pushoverProvider = pushoverProvider; + this.ntfyProvider = ntfyProvider; this.settingsService = settingsService; this.logger = logger; this.notificationMessageBuilder = notificationMessageBuilder; @@ -107,6 +110,8 @@ export class NotificationsService implements INotificationsService { return await this.telegramProvider.sendMessage!(notification, notificationMessage); case "pushover": return await this.pushoverProvider.sendMessage!(notification, notificationMessage); + case "ntfy": + return await this.ntfyProvider.sendMessage!(notification, notificationMessage); default: this.logger.warn({ message: `Unknown notification type: ${notification.type}`, @@ -171,6 +176,8 @@ export class NotificationsService implements INotificationsService { return await this.telegramProvider.sendTestAlert(notification); case "pushover": return await this.pushoverProvider.sendTestAlert(notification); + case "ntfy": + return await this.ntfyProvider.sendTestAlert(notification); default: return false; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index f095be7702..16f9d37696 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -1,6 +1,20 @@ -export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as const; +export const NotificationChannels = [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", + "teams", + "telegram", + "pushover", + "ntfy", +] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; +export const AuthTypes = ["none", "basic", "bearer"] as const; +export type AuthType = (typeof AuthTypes)[number]; + export interface Notification { id: string; userId: string; @@ -11,6 +25,9 @@ export interface Notification { phone?: string; homeserverUrl?: string; roomId?: string; + authType?: AuthType; + username?: string; + password?: string; accessToken?: string; createdAt: string; updatedAt: string; diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 5cdb44e95b..1d0fef2f83 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -1,3 +1,4 @@ +import { AuthTypes } from "@/types/notification.js"; import { z } from "zod"; //**************************************** @@ -79,6 +80,32 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ address: z.string().min(1, "User key is required"), accessToken: z.string().min(1, "App token is required"), }), + // Ntfy notification + z + .object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("ntfy"), + address: z.string().min(1, "URL is required").url("Please enter a valid URL"), + authType: z.enum(AuthTypes).optional(), + username: z.union([z.string(), z.literal("")]).optional(), + password: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }) + .superRefine((data, ctx) => { + if (data.authType === "basic") { + if (!data.username) { + ctx.addIssue({ code: "custom", message: "Username is required for Basic Auth", path: ["username"] }); + } + if (!data.password) { + ctx.addIssue({ code: "custom", message: "Password is required for Basic Auth", path: ["password"] }); + } + } + if (data.authType === "bearer") { + if (!data.accessToken) { + ctx.addIssue({ code: "custom", message: "Token is required for Bearer Auth", path: ["accessToken"] }); + } + } + }), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; diff --git a/server/test/unit/providers/notifications/ntfyProvider.test.ts b/server/test/unit/providers/notifications/ntfyProvider.test.ts new file mode 100644 index 0000000000..804a8fa280 --- /dev/null +++ b/server/test/unit/providers/notifications/ntfyProvider.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeMessage, makeMessageWithIncident, makeMessageWithThresholds, makeNotification } 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(); + const provider = new NtfyProvider(logger as any); + return { provider, logger }; +}; + +testNotificationProviderContract("NtfyProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("NtfyProvider", () => { + beforeEach(() => { + mockGotPost.mockReset().mockResolvedValue({}); + }); + + describe("sendTestAlert", () => { + it("sends test message and returns true", async () => { + const { provider } = createProvider(); + const result = await provider.sendTestAlert(makeNotification({ accessToken: "" })); + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith("https://hooks.example.com/webhook", expect.objectContaining({ body: expect.any(String) })); + }); + + it("returns false when address is missing", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("network")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert" })); + }); + + it("handles undefined thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(undefined); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + }); + + describe("sendMessage", () => { + it("sends message and returns true", async () => { + const { provider } = createProvider(); + const result = await provider.sendMessage(makeNotification({ accessToken: "" }), makeMessage()); + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith("https://hooks.example.com/webhook", expect.objectContaining({ body: expect.any(String) })); + }); + + it("returns false when address is missing", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("network")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert" })); + }); + + it("handles undefined thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(undefined); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("includes threshold section when thresholds present", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + const body = mockGotPost.mock.calls[0][1].body; + expect(body).toContain("Threshold Breaches"); + }); + + it("includes incident button when incident present", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithIncident()); + const body = mockGotPost.mock.calls[0][1].body; + expect(body).toContain("Incident"); + }); + + it("includes details section when details are present", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage()); + const body = mockGotPost.mock.calls[0][1].body; + expect(body).toContain("Additional Information"); + }); + + it("omits threshold, details, and incident sections when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + msg.content.incident = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const body = mockGotPost.mock.calls[0][1].body; + expect(body).not.toContain("Threshold"); + expect(body).not.toContain("Additional Information"); + expect(body).not.toContain("View Incident"); + }); + }); +});