Files
Termix/plugins/alerts/tests/backend/senders.test.ts
T

214 lines
6.0 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
import {
parseRecipients,
sendToChannel,
validateChannelConfig,
type OutgoingAlert,
type SenderDeps,
} from "../../src/backend/senders.js";
import { matchesCategory, severityRank } from "../../src/types.js";
import { parseAllowlist } from "../../src/backend/index.js";
import { cleanLink } from "../../src/backend/hub.js";
const ALERT: OutgoingAlert = {
title: "Disk full",
body: "/data at 99%",
severity: "critical",
category: "automations.notify",
source: "automations",
link: { url: "https://termix.example/hosts/1" },
};
function deps(response = new Response("ok")) {
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const value: SenderDeps = {
fetch: async (url, init) => {
calls.push({ url, init });
return response;
},
privateAllowlist: async () => ["ntfy.lan"],
smtp: async () => null,
sendMail: vi.fn(),
};
return { deps: value, calls };
}
describe("sendToChannel", () => {
it("posts ntfy with its priority, tag, click link and token", async () => {
const { deps: d, calls } = deps();
await sendToChannel(
{
type: "ntfy",
config: { url: "https://ntfy.sh/", topic: "ops", token: "tk_1" },
},
ALERT,
d,
);
expect(calls).toEqual([
{
url: "https://ntfy.sh/ops",
init: expect.objectContaining({
method: "POST",
body: "/data at 99%",
allowPrivateHosts: [],
headers: {
Title: "Disk full",
Priority: "5",
Tags: "rotating_light",
Click: "https://termix.example/hosts/1",
Authorization: "Bearer tk_1",
},
}),
},
]);
});
it("posts a Discord embed", async () => {
const { deps: d, calls } = deps();
await sendToChannel(
{
type: "discord",
config: {
url: "https://discord.com/api/webhooks/1/abc",
username: "Termix",
},
},
ALERT,
d,
);
const payload = JSON.parse(calls[0].init!.body!);
expect(payload).toMatchObject({
username: "Termix",
embeds: [
{
title: "Disk full",
description: "/data at 99%",
color: 15158332,
url: "https://termix.example/hosts/1",
},
],
});
});
it("puts on the webhook's own method and headers", async () => {
const { deps: d, calls } = deps();
await sendToChannel(
{
type: "webhook",
config: {
url: "https://hooks.example",
method: "PUT",
headers: { Authorization: "Bearer x" },
},
},
ALERT,
d,
);
expect(calls[0].init).toMatchObject({
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer x",
},
});
});
it("throws with the status when the receiver refuses", async () => {
const { deps: d } = deps(
new Response("slow down", { status: 429, statusText: "Too Many" }),
);
await expect(
sendToChannel(
{ type: "webhook", config: { url: "https://hooks.example" } },
ALERT,
d,
),
).rejects.toThrow("HTTP 429 Too Many: slow down");
});
it("never links to anything but http(s)", async () => {
const { deps: d, calls } = deps();
await sendToChannel(
{ type: "ntfy", config: { url: "https://ntfy.sh", topic: "t" } },
{ ...ALERT, link: { url: "javascript:alert(1)" } },
d,
);
expect(calls[0].init!.headers).not.toHaveProperty("Click");
});
});
describe("validateChannelConfig", () => {
it("accepts a working config of each type", () => {
expect(
validateChannelConfig("webhook", { url: "https://a.example" }),
).toBeNull();
expect(
validateChannelConfig("ntfy", { url: "https://ntfy.sh", topic: "t" }),
).toBeNull();
expect(
validateChannelConfig("discord", {
url: "https://discordapp.com/api/webhooks/1/x",
}),
).toBeNull();
expect(
validateChannelConfig("email", { to: "a@example.com; b@example.org" }),
).toBeNull();
});
it("names what is missing", () => {
expect(validateChannelConfig("ntfy", { url: "https://ntfy.sh" })).toMatch(
/topic/,
);
expect(validateChannelConfig("email", { to: "" })).toMatch(/address/);
});
});
describe("helpers", () => {
it("splits email recipients on commas, semicolons and spaces", () => {
expect(parseRecipients("a@x.io, b@x.io;c@x.io d@x.io")).toEqual([
"a@x.io",
"b@x.io",
"c@x.io",
"d@x.io",
]);
});
it("matches categories by pattern", () => {
expect(matchesCategory("*", "docker.stopped")).toBe(true);
expect(matchesCategory("", "docker.stopped")).toBe(true);
expect(matchesCategory("docker.*", "docker.stopped")).toBe(true);
expect(matchesCategory("docker.*", "dockerx.stopped")).toBe(false);
expect(
matchesCategory("acme-ssl.renewal_failed", "acme-ssl.renewal_failed"),
).toBe(true);
expect(matchesCategory("acme-ssl.renewal_failed", "acme-ssl.other")).toBe(
false,
);
});
it("orders severities with success alongside info", () => {
expect(severityRank("info")).toBe(severityRank("success"));
expect(severityRank("warning")).toBeGreaterThan(severityRank("info"));
expect(severityRank("critical")).toBeGreaterThan(severityRank("warning"));
});
it("reads the admin allowlist leniently", () => {
expect(parseAllowlist('["NTFY.lan", " mail.lan ", 3]')).toEqual([
"ntfy.lan",
"mail.lan",
]);
expect(parseAllowlist("not json")).toEqual([]);
expect(parseAllowlist(null)).toEqual([]);
});
it("keeps only a safe tab id and an http(s) url in a link", () => {
expect(cleanLink({ tab: "docker", url: "https://a.example" })).toEqual({
tab: "docker",
url: "https://a.example",
});
expect(cleanLink({ tab: "../../x", url: "file:///etc/passwd" })).toBeNull();
expect(cleanLink("docker")).toBeNull();
});
});