mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -0,0 +1,213 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user