mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45: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,328 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { addChannel, startServer, WEBHOOK, type TestServer } from "./helpers";
|
||||
|
||||
const mail = vi.hoisted(() => ({ sent: [] as unknown[] }));
|
||||
|
||||
vi.mock("../../src/backend/mail.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../src/backend/mail.js")
|
||||
>("../../src/backend/mail.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMail: async (smtp: unknown, message: unknown) => {
|
||||
mail.sent.push({ smtp, message });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
mail.sent.length = 0;
|
||||
});
|
||||
|
||||
const inbox = async (s: TestServer, user = "alice") =>
|
||||
(await s.request("GET", "/items", { user })).body;
|
||||
|
||||
describe("the alert hub", () => {
|
||||
it("puts an alert in each recipient's inbox with safe defaults", async () => {
|
||||
server = await startServer();
|
||||
const result = await server.hub.deliver({
|
||||
source: "docker",
|
||||
recipients: ["alice", "bob", "alice"],
|
||||
notification: {
|
||||
title: "Container stopped",
|
||||
body: "web exited",
|
||||
link: { tab: "docker", url: "javascript:alert(1)" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ recipients: 2, delivered: 0, failures: [] });
|
||||
for (const user of ["alice", "bob"]) {
|
||||
const { items, unread } = await inbox(server, user);
|
||||
expect(unread).toBe(1);
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
source: "docker",
|
||||
category: "docker",
|
||||
severity: "warning",
|
||||
title: "Container stopped",
|
||||
body: "web exited",
|
||||
link: { tab: "docker" },
|
||||
readAt: null,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a repeat while the first is still unread", async () => {
|
||||
server = await startServer();
|
||||
const send = () =>
|
||||
server!.hub.deliver({
|
||||
source: "acme-ssl",
|
||||
recipients: ["alice"],
|
||||
notification: { title: "Renewal failed", dedupeKey: "renew:a" },
|
||||
});
|
||||
|
||||
expect((await send()).recipients).toBe(1);
|
||||
expect((await send()).recipients).toBe(0);
|
||||
expect((await inbox(server)).items).toHaveLength(1);
|
||||
|
||||
await server.request("POST", "/items/read", { body: { all: true } });
|
||||
expect((await send()).recipients).toBe(1);
|
||||
expect((await inbox(server)).items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sends to the channels a matching rule names, and records how it went", async () => {
|
||||
server = await startServer({
|
||||
respond: (url) =>
|
||||
url.includes("broken")
|
||||
? new Response("nope", { status: 500, statusText: "Server Error" })
|
||||
: new Response("ok"),
|
||||
});
|
||||
const ops = await addChannel(server, WEBHOOK);
|
||||
const broken = await addChannel(server, {
|
||||
...WEBHOOK,
|
||||
name: "broken",
|
||||
config: { url: "https://broken.example/hook" },
|
||||
});
|
||||
const quiet = await addChannel(server, {
|
||||
...WEBHOOK,
|
||||
name: "quiet",
|
||||
config: { url: "https://quiet.example/hook" },
|
||||
});
|
||||
await server.request("POST", "/rules", {
|
||||
body: {
|
||||
name: "Docker",
|
||||
match: "docker.*",
|
||||
minSeverity: "warning",
|
||||
channelIds: [ops, broken],
|
||||
},
|
||||
});
|
||||
await server.request("POST", "/rules", {
|
||||
body: {
|
||||
name: "Only critical",
|
||||
match: "*",
|
||||
minSeverity: "critical",
|
||||
channelIds: [quiet],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await server.hub.deliver({
|
||||
source: "docker",
|
||||
recipients: ["alice"],
|
||||
notification: {
|
||||
title: "Container stopped",
|
||||
category: "docker.container_stopped",
|
||||
severity: "warning",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.delivered).toBe(1);
|
||||
expect(result.failures).toEqual([
|
||||
{
|
||||
channelId: broken,
|
||||
name: "broken",
|
||||
error: "HTTP 500 Server Error: nope",
|
||||
},
|
||||
]);
|
||||
expect(server.fetches.map((call) => call.url)).toEqual([
|
||||
"https://hooks.example/alert",
|
||||
"https://broken.example/hook",
|
||||
]);
|
||||
const body = JSON.parse(server.fetches[0].init!.body as string);
|
||||
expect(body).toMatchObject({
|
||||
title: "Container stopped",
|
||||
severity: "warning",
|
||||
category: "docker.container_stopped",
|
||||
source: "docker",
|
||||
});
|
||||
const [item] = (await inbox(server)).items;
|
||||
expect(item.deliveries).toEqual([
|
||||
{ channelId: ops, name: "ops", ok: true },
|
||||
{
|
||||
channelId: broken,
|
||||
name: "broken",
|
||||
ok: false,
|
||||
error: "HTTP 500 Server Error: nope",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds the channels a sender names, but only the recipient's own", async () => {
|
||||
server = await startServer();
|
||||
const mine = await addChannel(server, WEBHOOK);
|
||||
const theirs = await addChannel(
|
||||
server,
|
||||
{ ...WEBHOOK, config: { url: "https://bob.example/hook" } },
|
||||
"bob",
|
||||
);
|
||||
|
||||
const result = await server.hub.deliver({
|
||||
source: "automations",
|
||||
recipients: ["alice"],
|
||||
notification: { title: "Ping", channelIds: [mine, theirs] },
|
||||
});
|
||||
|
||||
expect(result.delivered).toBe(1);
|
||||
expect(server.fetches.map((call) => call.url)).toEqual([
|
||||
"https://hooks.example/alert",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a disabled channel", async () => {
|
||||
server = await startServer();
|
||||
const id = await addChannel(server, WEBHOOK);
|
||||
await server.request("PUT", `/channels/${id}`, {
|
||||
body: { enabled: false },
|
||||
});
|
||||
|
||||
const result = await server.hub.deliver({
|
||||
source: "automations",
|
||||
recipients: ["alice"],
|
||||
notification: { title: "Ping", channelIds: [id] },
|
||||
});
|
||||
|
||||
expect(result.delivered).toBe(0);
|
||||
expect(server.fetches).toEqual([]);
|
||||
});
|
||||
|
||||
it("reaches a private address only with the opt-in and the admin allowlist", async () => {
|
||||
server = await startServer({
|
||||
coreSettings: {
|
||||
notification_private_endpoint_allowlist: JSON.stringify(["NTFY.LAN"]),
|
||||
},
|
||||
});
|
||||
const open = await addChannel(server, {
|
||||
name: "lan",
|
||||
type: "ntfy",
|
||||
config: {
|
||||
url: "http://ntfy.lan",
|
||||
topic: "alerts",
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
const closed = await addChannel(server, {
|
||||
name: "lan2",
|
||||
type: "ntfy",
|
||||
config: { url: "http://ntfy.lan", topic: "other" },
|
||||
});
|
||||
|
||||
await server.hub.deliver({
|
||||
source: "automations",
|
||||
recipients: ["alice"],
|
||||
notification: { title: "Ping", channelIds: [open, closed] },
|
||||
});
|
||||
|
||||
expect(server.fetches.map((call) => call.init?.allowPrivateHosts)).toEqual([
|
||||
["ntfy.lan"],
|
||||
[],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emails through the admin's SMTP server", async () => {
|
||||
server = await startServer({
|
||||
settings: {
|
||||
smtpHost: "smtp.example",
|
||||
smtpPort: 465,
|
||||
smtpSecure: true,
|
||||
smtpUser: "termix",
|
||||
smtpPassword: "pw",
|
||||
smtpFrom: "Termix <alerts@example.com>",
|
||||
},
|
||||
});
|
||||
const id = await addChannel(server, {
|
||||
name: "mail",
|
||||
type: "email",
|
||||
config: { to: "ops@example.com, oncall@example.com" },
|
||||
});
|
||||
|
||||
const result = await server.hub.deliver({
|
||||
source: "automations",
|
||||
recipients: ["alice"],
|
||||
notification: {
|
||||
title: "Disk full",
|
||||
body: "/data at 99%",
|
||||
channelIds: [id],
|
||||
link: { url: "https://termix.example/hosts/1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.delivered).toBe(1);
|
||||
expect(mail.sent).toEqual([
|
||||
{
|
||||
smtp: {
|
||||
host: "smtp.example",
|
||||
port: 465,
|
||||
secure: true,
|
||||
user: "termix",
|
||||
password: "pw",
|
||||
from: "Termix <alerts@example.com>",
|
||||
},
|
||||
message: {
|
||||
to: ["ops@example.com", "oncall@example.com"],
|
||||
subject: "[Termix] Disk full",
|
||||
text: "/data at 99%\n\nhttps://termix.example/hosts/1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an email channel as failed while SMTP is not set up", async () => {
|
||||
server = await startServer({
|
||||
settings: {
|
||||
smtpHost: "smtp.example",
|
||||
smtpFrom: "alerts@example.com",
|
||||
},
|
||||
});
|
||||
const id = await addChannel(server, {
|
||||
name: "mail",
|
||||
type: "email",
|
||||
config: { to: "ops@example.com" },
|
||||
});
|
||||
await server.mock.ctx.settings.set("smtpHost", "");
|
||||
|
||||
const result = await server.hub.deliver({
|
||||
source: "automations",
|
||||
recipients: ["alice"],
|
||||
notification: { title: "Disk full", channelIds: [id] },
|
||||
});
|
||||
|
||||
expect(result.failures).toEqual([
|
||||
{
|
||||
channelId: id,
|
||||
name: "mail",
|
||||
error: "Email is not set up on this server",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists a user's channels without their config", async () => {
|
||||
server = await startServer();
|
||||
const id = await addChannel(server, WEBHOOK);
|
||||
expect(await server.hub.channels("alice")).toEqual([
|
||||
{ id, name: "ops", type: "webhook", enabled: true },
|
||||
]);
|
||||
expect(await server.hub.channels("bob")).toEqual([]);
|
||||
});
|
||||
|
||||
it("wipes everything a user had when their data is wiped", async () => {
|
||||
server = await startServer();
|
||||
await addChannel(server, WEBHOOK);
|
||||
await server.hub.deliver({
|
||||
source: "docker",
|
||||
recipients: ["alice", "bob"],
|
||||
notification: { title: "x" },
|
||||
});
|
||||
|
||||
server.mock.ctx.events.emit("user.data_wiped", { userId: "alice" });
|
||||
|
||||
await vi.waitFor(async () =>
|
||||
expect((await inbox(server!)).items).toEqual([]),
|
||||
);
|
||||
expect(await server.hub.channels("alice")).toEqual([]);
|
||||
expect((await inbox(server, "bob")).items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user