diff --git a/server/src/api/validation/notificationValidation.ts b/server/src/api/validation/notificationValidation.ts index bd3cdd6c2..fea35e89d 100644 --- a/server/src/api/validation/notificationValidation.ts +++ b/server/src/api/validation/notificationValidation.ts @@ -23,6 +23,14 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), }), + // Rocket.Chat notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("rocket_chat"), + address: z + .url({ message: "Please enter a valid Rocket.Chat webhook URL" }) + .refine((url) => /^https?:\/\//i.test(url), { message: "Rocket.Chat webhook URL must use HTTP or HTTPS" }), + }), // Slack notification z.object({ notificationName: z.string().min(1, "Notification name is required"), diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index 0f7813d8a..b278f6eab 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -23,6 +23,7 @@ import { ILogger } from "@/utils/logger.js"; // Notification providers import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js"; import { WebhookProvider } from "@/domain/notifications/providers/webhook.js"; +import { RocketChatProvider } from "@/domain/notifications/providers/rocketChat.js"; import { SlackProvider } from "@/domain/notifications/providers/slack.js"; import { EmailProvider } from "@/domain/notifications/providers/email.js"; import { DiscordProvider } from "@/domain/notifications/providers/discord.js"; @@ -157,6 +158,7 @@ export const buildShared = async ({ }); const webhookProvider = new WebhookProvider(logger); + const rocketChatProvider = new RocketChatProvider(logger); const slackProvider = new SlackProvider(logger); const emailProvider = new EmailProvider(emailService, logger); const discordProvider = new DiscordProvider(logger); @@ -170,6 +172,7 @@ export const buildShared = async ({ const notificationProviders: NotificationProviderRegistry = { webhook: webhookProvider, + rocket_chat: rocketChatProvider, email: emailProvider, slack: slackProvider, discord: discordProvider, diff --git a/server/src/domain/notifications/notification.model.ts b/server/src/domain/notifications/notification.model.ts index 797b3e768..518a4fc3f 100755 --- a/server/src/domain/notifications/notification.model.ts +++ b/server/src/domain/notifications/notification.model.ts @@ -30,6 +30,7 @@ const NotificationSchema = new Schema( "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index 8ae617a88..7c4c9f75d 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -3,6 +3,7 @@ export const NotificationChannels = [ "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts new file mode 100644 index 000000000..aa6852d74 --- /dev/null +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -0,0 +1,88 @@ +const SERVICE_NAME = "RocketChatProvider"; +import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; +import got from "got"; + +export class RocketChatProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: getTestMessage() }, + headers: { + "Content-Type": "application/json", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Rocket.Chat test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: this.buildText(message) }, + headers: { + "Content-Type": "application/json", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Rocket.Chat notification failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private buildText(message: NotificationMessage): string { + const lines = [ + message.content.title, + message.content.summary, + "", + `Monitor: ${message.monitor.name}`, + `Type: ${message.monitor.type}`, + `Status: ${message.monitor.status}`, + `URL: ${message.monitor.url}`, + ]; + + if (message.content.details?.length) { + lines.push("", "Details:", ...message.content.details.map((detail) => `- ${detail}`)); + } + if (message.content.thresholds?.length) { + lines.push( + "", + "Thresholds:", + ...message.content.thresholds.map( + (threshold) => `- ${threshold.metric.toUpperCase()}: ${threshold.formattedValue} (threshold: ${threshold.threshold}${threshold.unit})` + ) + ); + } + + lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`); + + return lines.join("\n"); + } +} diff --git a/server/test/unit/notifications/notificationType.test.ts b/server/test/unit/notifications/notificationType.test.ts new file mode 100644 index 000000000..0029fbd46 --- /dev/null +++ b/server/test/unit/notifications/notificationType.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "@jest/globals"; +import { NotificationModel } from "@/domain/notifications/notification.model.js"; +import { NotificationChannels } from "@/domain/notifications/notification.type.js"; + +describe("NotificationChannels", () => { + it("includes Rocket.Chat", () => { + expect(NotificationChannels).toContain("rocket_chat"); + }); + + it("allows Rocket.Chat to be persisted", () => { + const typePath = NotificationModel.schema.path("type") as unknown as { enumValues: string[] }; + + expect(typePath.enumValues).toContain("rocket_chat"); + }); +}); diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts new file mode 100644 index 000000000..8ceb60bee --- /dev/null +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeMessage, 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 { RocketChatProvider } = await import("../../../../src/domain/notifications/providers/rocketChat.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + const provider = new RocketChatProvider(logger as any); + return { provider, logger }; +}; + +testNotificationProviderContract("RocketChatProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification({ type: "rocket_chat" }), +}); + +describe("RocketChatProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + it("sends a text-only test alert", async () => { + const { provider } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); + + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ + json: { text: "This is a test notification from Checkmate" }, + timeout: { request: 30000 }, + retry: { limit: 0 }, + }) + ); + }); + + it("returns false when the test webhook URL is missing", async () => { + const { provider } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat", address: "" })); + + expect(result).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + + it("returns false and logs when test delivery fails", async () => { + mockGotPost.mockRejectedValue(new Error("network failure")); + const { provider, logger } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rocket.Chat test alert failed", + service: "RocketChatProvider", + method: "sendTestAlert", + }) + ); + }); + + it("sends monitor details as a text-only message", async () => { + const { provider } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ + timeout: { request: 30000 }, + retry: { limit: 0 }, + json: { + text: [ + "Monitor Down: Test Monitor", + 'Monitor "Test Monitor" is currently down.', + "", + "Monitor: Test Monitor", + "Type: http", + "Status: down", + "URL: https://example.com", + "", + "Details:", + "- URL: https://example.com", + "- Status: Down", + "", + "Incident: https://app.example.com/infrastructure/mon-1", + ].join("\n"), + }, + }) + ); + }); + + it("returns false when the message webhook URL is missing", async () => { + const { provider } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat", address: "" }), makeMessage()); + + expect(result).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + + it("includes threshold breaches in the text", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithThresholds()); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).toContain("Thresholds:\n- CPU: 90.0% (threshold: 80%)\n- MEMORY: 85.0% (threshold: 70%)"); + }); + + it("includes a monitor-scoped incident link without synthetic incident data", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).toContain("Incident: https://app.example.com/infrastructure/mon-1"); + }); + + it("returns false and logs when message delivery fails", async () => { + mockGotPost.mockRejectedValue(new Error("network failure")); + const { provider, logger } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rocket.Chat notification failed", + service: "RocketChatProvider", + method: "sendMessage", + }) + ); + }); +}); diff --git a/server/test/unit/validation/notificationValidation.test.ts b/server/test/unit/validation/notificationValidation.test.ts new file mode 100644 index 000000000..5cb2c1d59 --- /dev/null +++ b/server/test/unit/validation/notificationValidation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "@jest/globals"; +import { createNotificationBodyValidation } from "@/api/validation/notificationValidation.js"; + +describe("notification validation", () => { + it("accepts a Rocket.Chat incoming webhook", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address: "https://chat.example.com/hooks/integration-id/token", + }); + + expect(result.success).toBe(true); + }); + + it("accepts a self-hosted Rocket.Chat webhook over HTTP", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Local Rocket.Chat alerts", + type: "rocket_chat", + address: "http://localhost:3000/hooks/integration-id/token", + }); + + expect(result.success).toBe(true); + }); + + it("rejects an invalid Rocket.Chat incoming webhook URL", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address: "not-a-url", + }); + + expect(result.success).toBe(false); + }); + + it.each(["ftp://chat.example.com/hooks/integration-id/token", "mailto:admin@example.com", "data:text/plain,hello", "javascript:alert(1)"])( + "rejects a Rocket.Chat webhook using an unsupported protocol: %s", + (address) => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address, + }); + + expect(result.success).toBe(false); + } + ); +});