mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
214 lines
6.0 KiB
TypeScript
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();
|
|
});
|
|
});
|