Merge branch 'develop' into chore/form-refactor
This commit is contained in:
15 files changed
+375
-6
No files matched your search
@@ -80,7 +80,9 @@ Usage instructions can be found [here](https://checkmate.so/docs).
|
|||||||
<a id="installation"></a>
|
<a id="installation"></a>
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB:
|
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service.
|
||||||
|
|
||||||
|
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
|
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
|
||||||
@@ -151,7 +153,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
- Selective disk monitoring with mountpoint selection
|
- Selective disk monitoring with mountpoint selection
|
||||||
- Incidents at a glance
|
- Incidents at a glance
|
||||||
- Status pages with 4 beautiful themes
|
- Status pages with 4 beautiful themes
|
||||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||||
- Scheduled maintenance
|
- Scheduled maintenance
|
||||||
- JSON query monitoring
|
- JSON query monitoring
|
||||||
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ interface UseNotificationFormOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDefaults(data: Notification | null): NotificationFormData {
|
function buildDefaults(data: Notification | null): NotificationFormData {
|
||||||
// Every channel field defaults to "" (not undefined) so untouched required
|
|
||||||
// fields fail zod with their custom messages instead of a type error.
|
|
||||||
return {
|
return {
|
||||||
type: data?.type ?? "email",
|
type: data?.type ?? "email",
|
||||||
notificationName: data?.notificationName || "",
|
notificationName: data?.notificationName || "",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const NotificationChannels = [
|
|||||||
"slack",
|
"slack",
|
||||||
"discord",
|
"discord",
|
||||||
"webhook",
|
"webhook",
|
||||||
|
"rocket_chat",
|
||||||
"pager_duty",
|
"pager_duty",
|
||||||
"matrix",
|
"matrix",
|
||||||
"teams",
|
"teams",
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ const webhookSchema = baseSchema.extend({
|
|||||||
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
|
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rocketChatSchema = baseSchema.extend({
|
||||||
|
type: z.literal("rocket_chat"),
|
||||||
|
address: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Webhook URL is required")
|
||||||
|
.url({ protocol: /^https?$/, message: "Please enter a valid URL" }),
|
||||||
|
});
|
||||||
|
|
||||||
const pagerDutySchema = baseSchema.extend({
|
const pagerDutySchema = baseSchema.extend({
|
||||||
type: z.literal("pager_duty"),
|
type: z.literal("pager_duty"),
|
||||||
address: z.string().min(1, "Integration key is required"),
|
address: z.string().min(1, "Integration key is required"),
|
||||||
@@ -81,6 +89,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
slackSchema,
|
slackSchema,
|
||||||
discordSchema,
|
discordSchema,
|
||||||
webhookSchema,
|
webhookSchema,
|
||||||
|
rocketChatSchema,
|
||||||
pagerDutySchema,
|
pagerDutySchema,
|
||||||
matrixSchema,
|
matrixSchema,
|
||||||
teamsSchema,
|
teamsSchema,
|
||||||
|
|||||||
+28
-1
@@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "notifications",
|
"name": "notifications",
|
||||||
"description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts."
|
"description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "maintenance-window",
|
"name": "maintenance-window",
|
||||||
@@ -879,6 +879,9 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/WebhookNotification"
|
"$ref": "#/components/schemas/WebhookNotification"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RocketChatNotification"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SlackNotification"
|
"$ref": "#/components/schemas/SlackNotification"
|
||||||
},
|
},
|
||||||
@@ -912,6 +915,7 @@
|
|||||||
"mapping": {
|
"mapping": {
|
||||||
"email": "#/components/schemas/EmailNotification",
|
"email": "#/components/schemas/EmailNotification",
|
||||||
"webhook": "#/components/schemas/WebhookNotification",
|
"webhook": "#/components/schemas/WebhookNotification",
|
||||||
|
"rocket_chat": "#/components/schemas/RocketChatNotification",
|
||||||
"slack": "#/components/schemas/SlackNotification",
|
"slack": "#/components/schemas/SlackNotification",
|
||||||
"discord": "#/components/schemas/DiscordNotification",
|
"discord": "#/components/schemas/DiscordNotification",
|
||||||
"pager_duty": "#/components/schemas/PagerDutyNotification",
|
"pager_duty": "#/components/schemas/PagerDutyNotification",
|
||||||
@@ -1036,6 +1040,29 @@
|
|||||||
"address": "https://example.com/hooks/checkmate"
|
"address": "https://example.com/hooks/checkmate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"RocketChatNotification": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"notificationName": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["rocket_chat"]
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["notificationName", "type", "address"],
|
||||||
|
"example": {
|
||||||
|
"notificationName": "Rocket.Chat alerts",
|
||||||
|
"type": "rocket_chat",
|
||||||
|
"address": "https://chat.example.com/hooks/integration-id/token"
|
||||||
|
}
|
||||||
|
},
|
||||||
"SlackNotification": {
|
"SlackNotification": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export function getOpenApiSpec(): JsonObject {
|
|||||||
{ name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." },
|
{ name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." },
|
||||||
{
|
{
|
||||||
name: "notifications",
|
name: "notifications",
|
||||||
description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.",
|
description:
|
||||||
|
"Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts.",
|
||||||
},
|
},
|
||||||
{ name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." },
|
{ name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." },
|
||||||
{ name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." },
|
{ name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." },
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
|
|||||||
component: "WebhookNotification",
|
component: "WebhookNotification",
|
||||||
example: { notificationName: "Custom webhook", type: "webhook", address: "https://example.com/hooks/checkmate" },
|
example: { notificationName: "Custom webhook", type: "webhook", address: "https://example.com/hooks/checkmate" },
|
||||||
},
|
},
|
||||||
|
rocket_chat: {
|
||||||
|
component: "RocketChatNotification",
|
||||||
|
example: {
|
||||||
|
notificationName: "Rocket.Chat alerts",
|
||||||
|
type: "rocket_chat",
|
||||||
|
address: "https://chat.example.com/hooks/integration-id/token",
|
||||||
|
},
|
||||||
|
},
|
||||||
slack: {
|
slack: {
|
||||||
component: "SlackNotification",
|
component: "SlackNotification",
|
||||||
example: { notificationName: "#alerts", type: "slack", address: "https://hooks.slack.com/services/T000/B000/XXXX" },
|
example: { notificationName: "#alerts", type: "slack", address: "https://hooks.slack.com/services/T000/B000/XXXX" },
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
|||||||
roomId: z.union([z.string(), z.literal("")]).optional(),
|
roomId: z.union([z.string(), z.literal("")]).optional(),
|
||||||
accessToken: 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({
|
||||||
|
protocol: /^https?$/,
|
||||||
|
message: "Please enter a valid Rocket.Chat webhook URL",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
// Slack notification
|
// Slack notification
|
||||||
z.object({
|
z.object({
|
||||||
notificationName: z.string().min(1, "Notification name is required"),
|
notificationName: z.string().min(1, "Notification name is required"),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { ILogger } from "@/utils/logger.js";
|
|||||||
// Notification providers
|
// Notification providers
|
||||||
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
|
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
|
||||||
import { WebhookProvider } from "@/domain/notifications/providers/webhook.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 { SlackProvider } from "@/domain/notifications/providers/slack.js";
|
||||||
import { EmailProvider } from "@/domain/notifications/providers/email.js";
|
import { EmailProvider } from "@/domain/notifications/providers/email.js";
|
||||||
import { DiscordProvider } from "@/domain/notifications/providers/discord.js";
|
import { DiscordProvider } from "@/domain/notifications/providers/discord.js";
|
||||||
@@ -157,6 +158,7 @@ export const buildShared = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const webhookProvider = new WebhookProvider(logger);
|
const webhookProvider = new WebhookProvider(logger);
|
||||||
|
const rocketChatProvider = new RocketChatProvider(logger);
|
||||||
const slackProvider = new SlackProvider(logger);
|
const slackProvider = new SlackProvider(logger);
|
||||||
const emailProvider = new EmailProvider(emailService, logger);
|
const emailProvider = new EmailProvider(emailService, logger);
|
||||||
const discordProvider = new DiscordProvider(logger);
|
const discordProvider = new DiscordProvider(logger);
|
||||||
@@ -170,6 +172,7 @@ export const buildShared = async ({
|
|||||||
|
|
||||||
const notificationProviders: NotificationProviderRegistry = {
|
const notificationProviders: NotificationProviderRegistry = {
|
||||||
webhook: webhookProvider,
|
webhook: webhookProvider,
|
||||||
|
rocket_chat: rocketChatProvider,
|
||||||
email: emailProvider,
|
email: emailProvider,
|
||||||
slack: slackProvider,
|
slack: slackProvider,
|
||||||
discord: discordProvider,
|
discord: discordProvider,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
|||||||
"slack",
|
"slack",
|
||||||
"discord",
|
"discord",
|
||||||
"webhook",
|
"webhook",
|
||||||
|
"rocket_chat",
|
||||||
"pager_duty",
|
"pager_duty",
|
||||||
"matrix",
|
"matrix",
|
||||||
"teams",
|
"teams",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const NotificationChannels = [
|
|||||||
"slack",
|
"slack",
|
||||||
"discord",
|
"discord",
|
||||||
"webhook",
|
"webhook",
|
||||||
|
"rocket_chat",
|
||||||
"pager_duty",
|
"pager_duty",
|
||||||
"matrix",
|
"matrix",
|
||||||
"teams",
|
"teams",
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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<Notification>): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.incident) {
|
||||||
|
lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { 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 { 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",
|
||||||
|
].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 link when incident data is present", async () => {
|
||||||
|
const { provider } = createProvider();
|
||||||
|
|
||||||
|
await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithIncident());
|
||||||
|
|
||||||
|
const payload = mockGotPost.mock.calls[0][1].json;
|
||||||
|
expect(payload.text).toContain("Incident: https://app.example.com/infrastructure/mon-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the incident link when incident data is absent", async () => {
|
||||||
|
const { provider } = createProvider();
|
||||||
|
|
||||||
|
await provider.sendMessage(
|
||||||
|
makeNotification({ type: "rocket_chat" }),
|
||||||
|
makeMessage({
|
||||||
|
type: "monitor_up",
|
||||||
|
severity: "success",
|
||||||
|
monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "up" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = mockGotPost.mock.calls[0][1].json;
|
||||||
|
expect(payload.text).not.toContain("Incident:");
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user