diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index d08f175481..d838fd9759 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -1,10 +1,11 @@ import { useMemo } from "react"; -import { notificationSchema } from "@/Validation/notifications"; +import { notificationSchema, editNotificationSchema } from "@/Validation/notifications"; import type { NotificationFormData } from "@/Validation/notifications"; import type { Notification } from "@/Types/Notification"; interface UseNotificationFormOptions { data?: Notification | null; + isEditMode?: boolean; } function buildDefaults(data: Notification | null): NotificationFormData { @@ -44,6 +45,10 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: "webhook", notificationName: data.notificationName || "", address: data.address || "", + authType: data.authType || "none", + authUsername: data.authUsername || "", + authPassword: data.authPassword || "", + authToken: data.authToken || "", }; } if (data?.type === "pager_duty") { @@ -86,9 +91,15 @@ function buildDefaults(data: Notification | null): NotificationFormData { }; } -export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => { +export const useNotificationForm = ({ + data = null, + isEditMode = false, +}: UseNotificationFormOptions = {}) => { return useMemo(() => { const defaults = buildDefaults(data); - return { schema: notificationSchema, defaults }; - }, [data]); + return { + schema: isEditMode ? editNotificationSchema : notificationSchema, + defaults, + }; + }, [data, isEditMode]); }; diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 0f6d9b6525..0b8e16af54 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -11,16 +11,20 @@ import { useNavigate } from "react-router-dom"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import { useToast } from "@/Hooks/UseToast"; import { useNotificationForm } from "@/Hooks/useNotificationForm"; import type { NotificationFormData } from "@/Validation/notifications"; +import { notificationSchema } from "@/Validation/notifications"; import type { Notification } from "@/Types/Notification"; +import type { ZodIssue } from "zod"; import { useTranslation } from "react-i18next"; -import { NotificationChannels } from "@/Types/Notification"; +import { NotificationChannels, WebhookAuthTypes } from "@/Types/Notification"; const NotificationsCreatePage = () => { const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); + const { toastError } = useToast(); const { notificationId } = useParams<{ notificationId: string }>(); const isEditMode = Boolean(notificationId); @@ -32,20 +36,28 @@ const NotificationsCreatePage = () => { const { patch, loading: isPatching } = usePatch(); const { post: testPost, loading: isTesting } = usePost(); - const { schema, defaults } = useNotificationForm({ data: existingNotification }); + const { schema, defaults } = useNotificationForm({ + data: existingNotification, + isEditMode, + }); const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); - const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; + const { control, watch, reset, handleSubmit, clearErrors, getValues } = form; useEffect(() => { reset(defaults); }, [defaults, reset]); const watchedType = watch("type"); + const watchedAuthType = watch("authType"); + + const keepExistingHint = isEditMode + ? t("pages.notifications.form.secrets.placeholderKeepExisting") + : undefined; useEffect(() => { clearErrors(); @@ -86,9 +98,24 @@ const NotificationsCreatePage = () => { }; const handleTest = async () => { - const isValid = await trigger(); - if (!isValid) return; const data = getValues(); + const result = notificationSchema.safeParse(data); + if (!result.success) { + toastError("Please provide all required credentials to test this notification."); + result.error.issues.forEach((err: ZodIssue, index: number) => { + if (err.path.length > 0) { + form.setError( + err.path[0] as keyof NotificationFormData, + { + type: "manual", + message: err.message, + }, + { shouldFocus: index === 0 } + ); + } + }); + return; + } await testPost("/notifications/test", data); }; @@ -174,6 +201,107 @@ const NotificationsCreatePage = () => { } /> )} + {watchedType === "webhook" && ( + + ( + + )} + /> + {watchedAuthType === "basic" && ( + <> + ( + + )} + /> + ( + + )} + /> + + )} + {watchedAuthType === "bearer" && ( + ( + + )} + /> + )} + + } + /> + )} {watchedType === "telegram" && ( { {...field} type="text" fieldLabel={t("pages.notifications.form.telegram.optionBotToken")} - placeholder={t( - "pages.notifications.form.telegram.placeholderBotToken" - )} + placeholder={ + keepExistingHint ?? + t("pages.notifications.form.telegram.placeholderBotToken") + } fullWidth error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} @@ -232,9 +361,10 @@ const NotificationsCreatePage = () => { {...field} type="text" fieldLabel={t("pages.notifications.form.pushover.optionAppToken")} - placeholder={t( - "pages.notifications.form.pushover.placeholderAppToken" - )} + placeholder={ + keepExistingHint ?? + t("pages.notifications.form.pushover.placeholderAppToken") + } fullWidth error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} @@ -296,9 +426,10 @@ const NotificationsCreatePage = () => { {...field} type="text" fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")} - placeholder={t( - "pages.notifications.form.twilio.placeholderAuthToken" - )} + placeholder={ + keepExistingHint ?? + t("pages.notifications.form.twilio.placeholderAuthToken") + } fullWidth error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} @@ -394,7 +525,10 @@ const NotificationsCreatePage = () => { fieldLabel={t( "pages.notifications.form.accessToken.optionAccessToken" )} - placeholder={t("pages.notifications.form.accessToken.placeholder")} + placeholder={ + keepExistingHint ?? + t("pages.notifications.form.accessToken.placeholder") + } fullWidth error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 9d5b914c02..c8ba836560 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -12,6 +12,9 @@ export const NotificationChannels = [ ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; +export const WebhookAuthTypes = ["none", "basic", "bearer"] as const; +export type WebhookAuthType = (typeof WebhookAuthTypes)[number]; + export interface Notification { id: string; userId: string; @@ -25,6 +28,10 @@ export interface Notification { accessToken?: string; accountSid?: string; twilioPhoneNumber?: string; + authType?: WebhookAuthType; + authUsername?: string; + authPassword?: string; + authToken?: string; createdAt: string; updatedAt: string; } diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index 9fa2b011ac..b007486d47 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { WebhookAuthTypes } from "@/Types/Notification"; const baseSchema = z.object({ notificationName: z @@ -28,6 +29,10 @@ const discordSchema = baseSchema.extend({ const webhookSchema = baseSchema.extend({ type: z.literal("webhook"), address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), + authType: z.enum(WebhookAuthTypes), + authUsername: z.string().max(256).optional(), + authPassword: z.string().max(1024).optional(), + authToken: z.string().max(4096).optional(), }); const pagerDutySchema = baseSchema.extend({ @@ -70,7 +75,7 @@ const twilioSchema = baseSchema.extend({ twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }); -export const notificationSchema = z.discriminatedUnion("type", [ +export const baseNotificationSchema = z.discriminatedUnion("type", [ emailSchema, slackSchema, discordSchema, @@ -83,4 +88,81 @@ export const notificationSchema = z.discriminatedUnion("type", [ twilioSchema, ]); -export type NotificationFormData = z.infer; +export const notificationSchema = baseNotificationSchema.superRefine((data, ctx) => { + if (data.type === "webhook") { + if (data.authType === "basic") { + if (!data.authUsername) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Username is required", + path: ["authUsername"], + }); + } + if (!data.authPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Password is required", + path: ["authPassword"], + }); + } + } + if (data.authType === "bearer") { + if (!data.authToken) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Token is required", + path: ["authToken"], + }); + } + } + } +}); + +const editMatrixSchema = baseSchema.extend({ + type: z.literal("matrix"), + homeserverUrl: z + .string() + .min(1, "Homeserver URL is required") + .url("Please enter a valid URL"), + roomId: z.string().min(1, "Room ID is required"), + accessToken: z.string().optional(), +}); + +const editTelegramSchema = baseSchema.extend({ + type: z.literal("telegram"), + address: z.string().min(1, "Chat ID is required"), + accessToken: z.string().optional(), +}); + +const editPushoverSchema = baseSchema.extend({ + type: z.literal("pushover"), + address: z.string().min(1, "User key is required"), + accessToken: z.string().optional(), +}); + +const editTwilioSchema = baseSchema.extend({ + type: z.literal("twilio"), + accountSid: z.string().min(1, "Account SID is required"), + accessToken: z.string().optional(), + phone: z.string().min(1, "Recipient phone number is required"), + twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), +}); + +const baseEditNotificationSchema = z.discriminatedUnion("type", [ + emailSchema, + slackSchema, + discordSchema, + webhookSchema, + pagerDutySchema, + editMatrixSchema, + teamsSchema, + editTelegramSchema, + editPushoverSchema, + editTwilioSchema, +]); + +export const editNotificationSchema = baseEditNotificationSchema; + +export type NotificationFormData = + | z.infer + | z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 14b19851be..5c24858336 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1131,6 +1131,23 @@ "placeholderFromNumber": "+15551234567", "optionToNumber": "To number (recipient)", "placeholderToNumber": "+15559876543" + }, + "webhookAuth": { + "title": "Authentication", + "description": "Optionally secure your webhook requests with authentication.", + "optionAuthType": "Authentication type", + "authNone": "None", + "authBasic": "Basic Auth", + "authBearer": "Bearer Token", + "optionUsername": "Username", + "placeholderUsername": "Enter username", + "optionPassword": "Password", + "placeholderPassword": "Enter password", + "optionToken": "Token", + "placeholderToken": "Enter bearer token" + }, + "secrets": { + "placeholderKeepExisting": "Leave blank to keep existing" } }, "table": { diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 301c15446b..6cbbe4a3a4 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { createNotificationBodyValidation, + editNotificationBodyValidation, deleteNotificationParamValidation, getNotificationByIdParamValidation, testNotificationBodyValidation, @@ -12,6 +13,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"; @@ -47,6 +49,12 @@ class NotificationController implements INotificationController { } }; + private sanitizeNotification = (notification: Notification | null | undefined): Notification | null | undefined => { + if (!notification) return notification; + const { authPassword, authToken, ...sanitized } = notification; + return sanitized as Notification; + }; + createNotification = async (req: Request, res: Response, next: NextFunction) => { try { const validatedBody = createNotificationBodyValidation.parse(req.body); @@ -58,7 +66,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); @@ -73,7 +81,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); @@ -105,7 +113,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); @@ -114,7 +122,7 @@ class NotificationController implements INotificationController { editNotification = async (req: Request, res: Response, next: NextFunction) => { try { - const validatedBody = createNotificationBodyValidation.parse(req.body); + const validatedBody = editNotificationBodyValidation.parse(req.body); const validatedParams = editNotificationParamValidation.parse(req.params); const teamId = requireTeamId(req.user?.teamId); @@ -124,7 +132,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/models/Notification.ts b/server/src/db/models/Notification.ts index 2f7effa17d..54ad791283 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 type { Notification, NotificationChannel, WebhookAuthType } from "@/types/notification.js"; interface NotificationDocument extends Omit { _id: Types.ObjectId; @@ -39,6 +39,14 @@ const NotificationSchema = new Schema( accessToken: { type: String }, accountSid: { type: String }, twilioPhoneNumber: { type: String }, + authType: { + type: String, + enum: ["none", "basic", "bearer"], + default: "none", + }, + authUsername: { type: String }, + authPassword: { type: String }, + authToken: { type: String }, }, { timestamps: true, diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 5303aa6ab2..f10518e80b 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -34,6 +34,10 @@ class MongoNotificationsRepository implements INotificationsRepository { accessToken: doc.accessToken ?? undefined, accountSid: doc.accountSid ?? undefined, twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined, + authType: doc.authType ?? undefined, + authUsername: doc.authUsername ?? undefined, + authPassword: doc.authPassword ?? undefined, + authToken: doc.authToken ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -70,12 +74,27 @@ class MongoNotificationsRepository implements INotificationsRepository { }; updateById = async (id: string, teamId: string, patch: Partial): Promise => { + const $set: Record = {}; + const $unset: Record = {}; + + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + $unset[key] = 1; + } else { + $set[key] = value; + } + } + + const update: Record = {}; + if (Object.keys($set).length > 0) update.$set = $set; + if (Object.keys($unset).length > 0) update.$unset = $unset; + const notification = await NotificationModel.findOneAndUpdate( { _id: new mongoose.Types.ObjectId(id), teamId: new mongoose.Types.ObjectId(teamId), }, - { $set: patch }, + update, { new: true, runValidators: true } ); if (!notification) { diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index 292650f915..054892ac7a 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -6,6 +6,19 @@ import { getTestMessage } from "@/service/infrastructure/notificationProviders/u import got from "got"; export class WebhookProvider extends NotificationProvider { + private buildAuthHeaders(notification: Partial): Record { + const headers: Record = { "Content-Type": "application/json" }; + + if (notification.authType === "basic" && notification.authUsername && notification.authPassword) { + const encoded = Buffer.from(`${notification.authUsername}:${notification.authPassword}`).toString("base64"); + headers["Authorization"] = `Basic ${encoded}`; + } else if (notification.authType === "bearer" && notification.authToken) { + headers["Authorization"] = `Bearer ${notification.authToken}`; + } + + return headers; + } + sendMessage = async (notification: Notification, message: NotificationMessage): Promise => { if (!notification.address) { return false; @@ -17,9 +30,7 @@ export class WebhookProvider extends NotificationProvider { try { await got.post(notification.address, { json: payload, - headers: { - "Content-Type": "application/json", - }, + headers: this.buildAuthHeaders(notification), ...this.gotRequestOptions(), }); this.logger.info({ @@ -99,9 +110,7 @@ export class WebhookProvider extends NotificationProvider { try { await got.post(notification.address, { json: { text: getTestMessage() }, - headers: { - "Content-Type": "application/json", - }, + headers: this.buildAuthHeaders(notification), ...this.gotRequestOptions(), }); return true; diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 90e9d637ab..b42bcdd62d 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -5,6 +5,7 @@ import { INotificationProvider } from "./notificationProviders/INotificationProv import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { ISettingsService } from "@/service/system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; +import { AppError } from "@/utils/AppError.js"; import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; export interface INotificationsService { @@ -209,8 +210,64 @@ export class NotificationsService implements INotificationsService { return await this.notificationsRepository.findByTeamId(teamId); }; + private static readonly SENSITIVE_FIELDS: ReadonlyArray = ["accessToken", "authPassword", "authToken"]; + private static readonly CHANNEL_SPECIFIC_FIELDS: ReadonlyArray = [ + "address", + "phone", + "homeserverUrl", + "roomId", + "accessToken", + "accountSid", + "twilioPhoneNumber", + "authType", + "authUsername", + "authPassword", + "authToken", + ]; + updateById = async (id: string, teamId: string, updateData: Partial): Promise => { - return await this.notificationsRepository.updateById(id, teamId, updateData); + const patch: Record = { ...updateData }; + const existing = await this.notificationsRepository.findById(id, teamId); + + if (patch.type !== undefined && existing.type !== patch.type) { + for (const field of NotificationsService.CHANNEL_SPECIFIC_FIELDS) { + if (NotificationsService.SENSITIVE_FIELDS.includes(field)) { + if (!updateData[field]) { + patch[field] = undefined; + } + } else { + if (!(field in updateData)) { + patch[field] = undefined; + } + } + } + } else { + for (const field of NotificationsService.SENSITIVE_FIELDS) { + if (field in patch && !patch[field]) { + delete patch[field]; + } + } + if (patch.authType !== undefined && existing.authType !== patch.authType) { + if (patch.authType === "none") { + patch.authUsername = undefined; + patch.authPassword = undefined; + patch.authToken = undefined; + } else if (patch.authType === "basic") { + if (!updateData.authUsername || !updateData.authPassword) { + throw new AppError({ message: "Username and password are required when switching to Basic Auth", status: 400 }); + } + patch.authToken = undefined; + } else if (patch.authType === "bearer") { + if (!updateData.authToken) { + throw new AppError({ message: "Token is required when switching to Bearer Auth", status: 400 }); + } + patch.authUsername = undefined; + patch.authPassword = undefined; + } + } + } + + return await this.notificationsRepository.updateById(id, teamId, patch as Partial); }; deleteById = async (id: string, teamId: string): Promise => { diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index 9d5b914c02..c8ba836560 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -12,6 +12,9 @@ export const NotificationChannels = [ ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; +export const WebhookAuthTypes = ["none", "basic", "bearer"] as const; +export type WebhookAuthType = (typeof WebhookAuthTypes)[number]; + export interface Notification { id: string; userId: string; @@ -25,6 +28,10 @@ export interface Notification { accessToken?: string; accountSid?: string; twilioPhoneNumber?: string; + authType?: WebhookAuthType; + authUsername?: string; + authPassword?: string; + authToken?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 523cb2a6e1..6c39a63bf7 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -1,10 +1,119 @@ import { z } from "zod"; +import { WebhookAuthTypes } from "@/types/notification.js"; //**************************************** // Notification Validations //**************************************** export const createNotificationBodyValidation = z.discriminatedUnion("type", [ + // Email notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("email"), + address: z.email("Please enter a valid e-mail address"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // Webhook notification + z + .object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("webhook"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + authType: z.enum(WebhookAuthTypes).optional().default("none"), + authUsername: z.string().max(256).optional(), + authPassword: z.string().max(1024).optional(), + authToken: z.string().max(4096).optional(), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }) + .superRefine((data, ctx) => { + if (data.authType === "basic") { + if (!data.authUsername) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Username is required for Basic Auth", path: ["authUsername"] }); + } + if (!data.authPassword) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Password is required for Basic Auth", path: ["authPassword"] }); + } + } + if (data.authType === "bearer") { + if (!data.authToken) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Token is required for Bearer Auth", path: ["authToken"] }); + } + } + }), + // Slack notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("slack"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // Discord notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("discord"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // PagerDuty notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("pager_duty"), + address: z.string().min(1, "PagerDuty integration key is required"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }), + // Matrix notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("matrix"), + address: z.union([z.string(), z.literal("")]).optional(), + homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), + roomId: z.string().min(1, "Room ID is required"), + accessToken: z.string().min(1, "Access Token is required"), + }), + // Teams notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("teams"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + }), + // Telegram notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("telegram"), + address: z.string().min(1, "Chat ID is required"), + accessToken: z.string().min(1, "Bot token is required"), + }), + // Pushover notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("pushover"), + address: z.string().min(1, "User key is required"), + accessToken: z.string().min(1, "App token is required"), + }), + // Twilio SMS notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("twilio"), + accountSid: z.string().min(1, "Account SID is required"), + accessToken: z.string().min(1, "Auth token is required"), + phone: z.string().min(1, "Recipient phone number is required"), + twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), + }), +]); + +export const testNotificationBodyValidation = createNotificationBodyValidation; + +export const editNotificationBodyValidation = z.discriminatedUnion("type", [ // Email notification z.object({ notificationName: z.string().min(1, "Notification name is required"), @@ -19,6 +128,10 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("webhook"), address: z.url({ message: "Please enter a valid Webhook URL" }), + authType: z.enum(WebhookAuthTypes).optional().default("none"), + authUsername: z.string().max(256).optional(), + authPassword: z.string().max(1024).optional(), + authToken: z.string().max(4096).optional(), homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), @@ -57,7 +170,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ address: z.union([z.string(), z.literal("")]).optional(), homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), roomId: z.string().min(1, "Room ID is required"), - accessToken: z.string().min(1, "Access Token is required"), + accessToken: z.union([z.string(), z.literal("")]).optional(), }), // Teams notification z.object({ @@ -70,28 +183,26 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("telegram"), address: z.string().min(1, "Chat ID is required"), - accessToken: z.string().min(1, "Bot token is required"), + accessToken: z.union([z.string(), z.literal("")]).optional(), }), // Pushover notification z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("pushover"), address: z.string().min(1, "User key is required"), - accessToken: z.string().min(1, "App token is required"), + accessToken: z.union([z.string(), z.literal("")]).optional(), }), // Twilio SMS notification z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("twilio"), accountSid: z.string().min(1, "Account SID is required"), - accessToken: z.string().min(1, "Auth token is required"), + accessToken: z.union([z.string(), z.literal("")]).optional(), phone: z.string().min(1, "Recipient phone number is required"), twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), ]); -export const testNotificationBodyValidation = createNotificationBodyValidation; - export const deleteNotificationParamValidation = z.object({ id: z.string().min(1, "Notification ID is required"), }); diff --git a/server/test/unit/providers/notifications/webhookProvider.test.ts b/server/test/unit/providers/notifications/webhookProvider.test.ts index caf17851b7..3b3c5d83aa 100644 --- a/server/test/unit/providers/notifications/webhookProvider.test.ts +++ b/server/test/unit/providers/notifications/webhookProvider.test.ts @@ -45,6 +45,98 @@ describe("WebhookProvider", () => { const { provider } = createProvider(); expect(await provider.sendTestAlert(makeNotification())).toBe(false); }); + + it("omits Authorization header when authType is none", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ authType: "none" })); + const headers = mockGotPost.mock.calls[0][1].headers; + expect(headers.Authorization).toBeUndefined(); + expect(headers["Content-Type"]).toBe("application/json"); + }); + + it("sends Basic Authorization when authType is basic", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert( + makeNotification({ + authType: "basic", + authUsername: "user", + authPassword: "secret", + }) + ); + const headers = mockGotPost.mock.calls[0][1].headers; + expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:secret").toString("base64")}`); + }); + + it("sends Bearer Authorization when authType is bearer", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert( + makeNotification({ + authType: "bearer", + authToken: "my-bearer-token", + }) + ); + const headers = mockGotPost.mock.calls[0][1].headers; + expect(headers.Authorization).toBe("Bearer my-bearer-token"); + }); + + it("omits Authorization when basic auth is missing password", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert( + makeNotification({ + authType: "basic", + authUsername: "user", + authPassword: undefined, + }) + ); + const headers = mockGotPost.mock.calls[0][1].headers; + expect(headers.Authorization).toBeUndefined(); + }); + + it("omits Authorization when bearer auth is missing token", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert( + makeNotification({ + authType: "bearer", + authToken: undefined, + }) + ); + const headers = mockGotPost.mock.calls[0][1].headers; + expect(headers.Authorization).toBeUndefined(); + }); + + it("omits Authorization when basic auth is missing username", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert( + makeNotification({ + authType: "basic", + authUsername: undefined, + authPassword: "secret", + }) + ); + const headers = mockGotPost.mock.calls[0][1].headers; + expect(headers.Authorization).toBeUndefined(); + }); + + it("handles empty strings for auth fields by omitting Authorization", async () => { + const { provider } = createProvider(); + + await provider.sendTestAlert( + makeNotification({ + authType: "basic", + authUsername: "", + authPassword: "", + }) + ); + expect(mockGotPost.mock.calls[0][1].headers.Authorization).toBeUndefined(); + + await provider.sendTestAlert( + makeNotification({ + authType: "bearer", + authToken: "", + }) + ); + expect(mockGotPost.mock.calls[1][1].headers.Authorization).toBeUndefined(); + }); }); describe("sendMessage", () => { @@ -98,5 +190,19 @@ describe("WebhookProvider", () => { expect(text).not.toContain("Additional Information"); expect(text).not.toContain("View Incident"); }); + + it("sends Basic Authorization on sendMessage when authType is basic", async () => { + const { provider } = createProvider(); + await provider.sendMessage( + makeNotification({ + authType: "basic", + authUsername: "user", + authPassword: "secret", + }) as any, + makeMessage() + ); + const headers = mockGotPost.mock.calls[0][1].headers; + expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:secret").toString("base64")}`); + }); }); });