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

329 lines
9.2 KiB
TypeScript

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);
});
});