feat: unify alerts into one plugin, channels, rules and inbox

This commit is contained in:
LukeGus committed 2026-09-25 16:26:37 -05:00
1 parent fbe736bc20
commit 7c02daccf4
129 files changed
+20957 -2687

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type {
PluginFetchInit,
PluginNotifyHub,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface FetchCall {
url: string;
init?: PluginFetchInit;
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
hub: PluginNotifyHub;
fetches: FetchCall[];
/** Where the router is served, for a raw fetch such as the stream. */
url: string;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it, with every outbound request answered by `respond`.
*/
export async function startServer(
options: {
permissions?: string[];
settings?: Record<string, unknown>;
coreSettings?: Record<string, string>;
respond?: (url: string, init?: PluginFetchInit) => Response;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of ["alice", "bob"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
const fetches: FetchCall[] = [];
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["alerts.use"],
settings: options.settings,
coreSettings: options.coreSettings,
fetch: async (url, init) => {
fetches.push({ url, init });
return options.respond?.(url, init) ?? new Response("ok");
},
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const hub = mock.notifyHub.current;
if (!hub) throw new Error("The plugin did not serve a hub");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") || undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
hub,
fetches,
url: `http://127.0.0.1:${port}`,
async request(method, path, { user = "alice", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) {
await Promise.resolve(dispose()).catch(() => {});
}
db.close();
},
};
}
/** A channel for alice through the route, returning its id. */
export async function addChannel(
server: TestServer,
body: Record<string, unknown>,
user = "alice",
): Promise<number> {
const response = await server.request("POST", "/channels", { user, body });
if (response.status !== 201) {
throw new Error(`channel not created: ${JSON.stringify(response.body)}`);
}
return response.body.id;
}
export const WEBHOOK = {
name: "ops",
type: "webhook",
config: { url: "https://hooks.example/alert" },
};
+328
View File
@@ -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);
});
});
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// dismissed_alerts as core's SQLite bootstrap created it before 2.9.0, with
// the index performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
alert_id TEXT NOT NULL,
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_dismissed_alerts_user_id ON dismissed_alerts(user_id);
INSERT INTO users (id, username) VALUES ('alice', 'alice');
INSERT INTO dismissed_alerts (id, user_id, alert_id) VALUES (3, 'alice', 'old-news');
`;
const OWN = ["p_alerts_channels", "p_alerts_items", "p_alerts_rules"];
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
function tables(target: TestDb): string[] {
return (
target.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>
).map((row) => row.name);
}
function indexes(target: TestDb, table: string): string[] {
return (
target.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as Array<{ name: string }>
)
.map((row) => row.name)
.sort();
}
describe("the alerts tables", () => {
it("adopts dismissed_alerts with its rows and its one index", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => sqlite.exec(LEGACY_DDL),
});
const names = tables(db);
expect(names).toContain("p_alerts_dismissed");
expect(names).not.toContain("dismissed_alerts");
for (const table of OWN) expect(names).toContain(table);
expect(
db.sqlite
.prepare("SELECT id, user_id, alert_id FROM p_alerts_dismissed")
.all(),
).toEqual([{ id: 3, user_id: "alice", alert_id: "old-news" }]);
expect(indexes(db, "p_alerts_dismissed")).toEqual([
"idx_dismissed_alerts_user_id",
]);
// Deleting the user still takes their rows with them.
db.sqlite.prepare("DELETE FROM users WHERE id = 'alice'").run();
expect(
db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_alerts_dismissed").get(),
).toEqual({ n: 0 });
});
it("creates the same tables on a fresh install", async () => {
db = await createTestDb(pluginDir);
const names = tables(db);
for (const table of [...OWN, "p_alerts_dismissed"]) {
expect(names).toContain(table);
}
expect(indexes(db, "p_alerts_items")).toEqual([
"idx_alerts_items_dedupe",
"idx_alerts_items_unread",
"idx_alerts_items_user",
]);
});
});
+434
View File
@@ -0,0 +1,434 @@
import { afterEach, describe, expect, it } from "vitest";
import { FEED_URL } from "../../src/backend/announcements.js";
import { addChannel, startServer, WEBHOOK, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
describe("permissions", () => {
it("refuses a user without alerts.use", async () => {
server = await startServer({ permissions: [] });
for (const [method, path] of [
["GET", "/items"],
["GET", "/channels"],
["POST", "/channels"],
["GET", "/rules"],
]) {
expect((await server.request(method, path, { body: {} })).status).toBe(
403,
);
}
});
});
describe("channels", () => {
it("creates, reads back, updates and deletes a channel", async () => {
server = await startServer();
const id = await addChannel(server, {
name: "pager",
type: "ntfy",
config: { url: "https://ntfy.sh", topic: "ops", token: "tk_1" },
});
const list = await server.request("GET", "/channels");
expect(list.body).toEqual([
expect.objectContaining({
id,
name: "pager",
type: "ntfy",
enabled: true,
usable: true,
}),
]);
expect(list.body[0].config).toBeUndefined();
const detail = await server.request("GET", `/channels/${id}`);
expect(detail.body.config).toEqual({
url: "https://ntfy.sh",
topic: "ops",
token: "tk_1",
});
// Stored sealed, never as plain JSON.
const stored = server.db.sqlite
.prepare("SELECT config FROM p_alerts_channels WHERE id = ?")
.get(id) as { config: string };
expect(stored.config).not.toContain("tk_1");
const renamed = await server.request("PUT", `/channels/${id}`, {
body: { name: "night pager" },
});
expect(renamed.body).toMatchObject({ name: "night pager", type: "ntfy" });
expect(renamed.body.config.token).toBe("tk_1");
expect((await server.request("DELETE", `/channels/${id}`)).status).toBe(
200,
);
expect((await server.request("GET", "/channels")).body).toEqual([]);
});
it("refuses a config that cannot send", async () => {
server = await startServer();
for (const body of [
{ name: "", type: "webhook", config: { url: "https://a.example" } },
{ name: "x", type: "sms", config: {} },
{ name: "x", type: "webhook", config: { url: "ftp://a.example" } },
{ name: "x", type: "ntfy", config: { url: "https://ntfy.sh" } },
{
name: "x",
type: "discord",
config: { url: "https://example.com/api/webhooks/1" },
},
{ name: "x", type: "email", config: { to: "not an address" } },
]) {
const response = await server.request("POST", "/channels", { body });
expect(response.status, JSON.stringify(body)).toBe(400);
}
});
it("keeps each user's channels to themselves", async () => {
server = await startServer();
const id = await addChannel(server, WEBHOOK);
for (const [method, path] of [
["GET", `/channels/${id}`],
["PUT", `/channels/${id}`],
["DELETE", `/channels/${id}`],
["POST", `/channels/${id}/test`],
]) {
const response = await server.request(method, path, {
user: "bob",
body: { name: "mine" },
});
expect(response.status, `${method} ${path}`).toBe(404);
}
expect(
(await server.request("GET", "/channels", { user: "bob" })).body,
).toEqual([]);
});
it("sends a test and reports why one failed", async () => {
server = await startServer({
respond: (url) =>
url.includes("down")
? new Response("", { status: 502, statusText: "Bad Gateway" })
: new Response("ok"),
});
const good = await addChannel(server, WEBHOOK);
const bad = await addChannel(server, {
...WEBHOOK,
config: { url: "https://down.example" },
});
expect(
(await server.request("POST", `/channels/${good}/test`)).body,
).toEqual({ success: true });
expect(
(await server.request("POST", `/channels/${bad}/test`)).body,
).toEqual({ success: false, error: "HTTP 502 Bad Gateway" });
});
it("says email is unavailable until SMTP is set up", async () => {
server = await startServer();
expect((await server.request("GET", "/meta")).body).toEqual({
emailAvailable: false,
channelTypes: ["webhook", "ntfy", "discord", "email"],
});
await server.mock.ctx.settings.set("smtpHost", "smtp.example");
await server.mock.ctx.settings.set("smtpFrom", "alerts@example.com");
expect((await server.request("GET", "/meta")).body.emailAvailable).toBe(
true,
);
});
});
describe("inbox", () => {
async function seed(s: TestServer) {
for (const title of ["one", "two", "three"]) {
await s.hub.deliver({
source: "docker",
recipients: ["alice"],
notification: {
title,
severity: title === "two" ? "critical" : "info",
},
});
}
await s.hub.deliver({
source: "docker",
recipients: ["bob"],
notification: { title: "bob only" },
});
}
it("lists newest first, filters and pages", async () => {
server = await startServer();
await seed(server);
const all = await server.request("GET", "/items");
expect(all.body.unread).toBe(3);
expect(all.body.items.map((item: { title: string }) => item.title)).toEqual(
["three", "two", "one"],
);
const critical = await server.request("GET", "/items?severity=critical");
expect(
critical.body.items.map((item: { title: string }) => item.title),
).toEqual(["two"]);
const page = await server.request(
"GET",
`/items?limit=1&before=${all.body.items[0].id}`,
);
expect(
page.body.items.map((item: { title: string }) => item.title),
).toEqual(["two"]);
});
it("marks read and unread, and counts what is left", async () => {
server = await startServer();
await seed(server);
const { items } = (await server.request("GET", "/items")).body;
const read = await server.request("POST", "/items/read", {
body: { ids: [items[0].id] },
});
expect(read.body).toEqual({ count: 2 });
const unread = await server.request("GET", "/items?unread=true");
expect(unread.body.items).toHaveLength(2);
await server.request("POST", "/items/read", {
body: { ids: [items[0].id], read: false },
});
expect((await server.request("GET", "/unread")).body).toEqual({
count: 3,
});
await server.request("POST", "/items/read", { body: { all: true } });
expect((await server.request("GET", "/unread")).body).toEqual({
count: 0,
});
// Bob's alert is his own.
expect(
(await server.request("GET", "/unread", { user: "bob" })).body,
).toEqual({ count: 1 });
});
it("deletes one alert, then every read one", async () => {
server = await startServer();
await seed(server);
const { items } = (await server.request("GET", "/items")).body;
expect(
(await server.request("DELETE", `/items/${items[0].id}`, { user: "bob" }))
.status,
).toBe(404);
await server.request("DELETE", `/items/${items[0].id}`);
await server.request("POST", "/items/read", {
body: { ids: [items[1].id] },
});
expect((await server.request("DELETE", "/items?read=true")).body).toEqual({
removed: 1,
});
expect(
(await server.request("GET", "/items")).body.items.map(
(item: { title: string }) => item.title,
),
).toEqual(["one"]);
});
it("lists the categories a user has alerts from", async () => {
server = await startServer();
await server.hub.deliver({
source: "acme-ssl",
recipients: ["alice"],
notification: { title: "x", category: "acme-ssl.renewal_failed" },
});
expect((await server.request("GET", "/categories")).body).toEqual([
{ source: "acme-ssl", category: "acme-ssl.renewal_failed" },
]);
});
});
describe("rules", () => {
it("creates, updates and deletes a rule over the user's own channels", async () => {
server = await startServer();
const mine = await addChannel(server, WEBHOOK);
const theirs = await addChannel(server, WEBHOOK, "bob");
expect(
(
await server.request("POST", "/rules", {
body: { name: "All", channelIds: [theirs] },
})
).status,
).toBe(400);
expect(
(
await server.request("POST", "/rules", {
body: { name: "All", channelIds: [] },
})
).status,
).toBe(400);
const created = await server.request("POST", "/rules", {
body: { name: "All", channelIds: [mine] },
});
expect(created.status).toBe(201);
expect(created.body).toMatchObject({
name: "All",
match: "*",
minSeverity: "warning",
channelIds: [mine],
enabled: true,
});
await server.request("PUT", `/rules/${created.body.id}`, {
body: {
name: "Docker",
match: "docker.*",
minSeverity: "critical",
channelIds: [mine],
enabled: false,
},
});
expect((await server.request("GET", "/rules")).body).toEqual([
{
id: created.body.id,
name: "Docker",
match: "docker.*",
minSeverity: "critical",
channelIds: [mine],
enabled: false,
},
]);
expect(
(
await server.request("DELETE", `/rules/${created.body.id}`, {
user: "bob",
})
).status,
).toBe(404);
await server.request("DELETE", `/rules/${created.body.id}`);
expect((await server.request("GET", "/rules")).body).toEqual([]);
});
it("drops a deleted channel from the rules that named it", async () => {
server = await startServer();
const a = await addChannel(server, WEBHOOK);
const b = await addChannel(server, { ...WEBHOOK, name: "b" });
await server.request("POST", "/rules", {
body: { name: "Both", channelIds: [a, b] },
});
await server.request("DELETE", `/channels/${a}`);
expect((await server.request("GET", "/rules")).body[0].channelIds).toEqual([
b,
]);
});
});
describe("announcements", () => {
const future = new Date(Date.now() + 86_400_000).toISOString();
const past = new Date(Date.now() - 86_400_000).toISOString();
const feed = [
{
id: "release-2-9",
title: "Termix 2.9 is out",
message: "Plugins are here.",
expiresAt: future,
type: "success",
actionUrl: "https://termix.site/blog",
actionText: "Read more",
},
{
id: "old",
title: "Gone",
message: "Expired",
expiresAt: past,
},
];
const respond = (url: string) =>
url === FEED_URL ? Response.json(feed) : new Response("ok");
it("brings each live announcement into the inbox once", async () => {
server = await startServer({ respond });
const first = await server.request("GET", "/items");
expect(first.body.items).toEqual([
expect.objectContaining({
source: "termix",
category: "termix.announcement",
severity: "success",
title: "Termix 2.9 is out",
body: "Plugins are here.",
link: { url: "https://termix.site/blog" },
context: { announcementId: "release-2-9", actionText: "Read more" },
}),
]);
await server.request("POST", "/items/read", { body: { all: true } });
const again = await server.request("GET", "/items");
expect(again.body.items).toHaveLength(1);
expect(again.body.unread).toBe(0);
});
it("never brings back one the user deleted", async () => {
server = await startServer({ respond });
const { items } = (await server.request("GET", "/items")).body;
await server.request("DELETE", `/items/${items[0].id}`);
expect((await server.request("GET", "/items")).body.items).toEqual([]);
expect(
server.db.sqlite.prepare("SELECT alert_id FROM p_alerts_dismissed").all(),
).toEqual([{ alert_id: "release-2-9" }]);
});
it("shows none while an admin has them turned off", async () => {
server = await startServer({ respond, settings: { announcements: false } });
expect((await server.request("GET", "/items")).body.items).toEqual([]);
expect(server.fetches).toEqual([]);
});
});
describe("the event stream", () => {
it("sends the unread count, then each new alert", async () => {
server = await startServer();
const controller = new AbortController();
const response = await fetch(`${server.url}/stream`, {
headers: { "x-test-user": "alice" },
signal: controller.signal,
});
expect(response.headers.get("content-type")).toContain("text/event-stream");
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let text = "";
const readUntil = async (needle: string) => {
while (!text.includes(needle)) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
};
await readUntil('"count":0');
expect(text).toContain("event: unread");
await server.hub.deliver({
source: "docker",
recipients: ["alice"],
notification: { title: "Live one" },
});
await readUntil("Live one");
await readUntil('"count":1');
expect(text).toContain("event: item");
controller.abort();
});
});
@@ -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();
});
});
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, waitFor } from "@testing-library/react";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
const ITEMS = [
{
id: 2,
source: "acme-ssl",
category: "acme-ssl.renewal_failed",
severity: "critical",
title: "Certificate renewal failed",
body: "rate limited",
link: null,
context: null,
deliveries: [{ channelId: 1, name: "pager", ok: false, error: "HTTP 500" }],
readAt: null,
createdAt: new Date().toISOString(),
},
{
id: 1,
source: "termix",
category: "termix.announcement",
severity: "info",
title: "Termix 2.9 is out",
body: null,
link: { url: "https://termix.site" },
context: { actionText: "Read more" },
deliveries: null,
readAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
},
];
function stubApi() {
const calls: Array<[string, string, unknown?]> = [];
const api = {
get: vi.fn(async (url: string) => {
calls.push(["GET", url]);
if (url.startsWith("/items"))
return { data: { items: ITEMS, unread: 1 } };
if (url === "/unread") return { data: { count: 1 } };
return { data: [] };
}),
post: vi.fn(async (url: string, body?: unknown) => {
calls.push(["POST", url, body]);
return { data: { count: 0 } };
}),
delete: vi.fn(async (url: string) => {
calls.push(["DELETE", url]);
return { data: { success: true } };
}),
put: vi.fn(),
patch: vi.fn(),
} as unknown as PluginApiClient;
return { api, calls };
}
describe(`${manifest.id} activate`, () => {
it("registers a footer rail item, its panel and tab, the popups and its actions", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.railItems()).toEqual([
expect.objectContaining({ id: "alerts", permission: "alerts.use" }),
]);
expect(rendered.registered.panels()).toEqual(["alerts"]);
expect(rendered.registered.tabs()).toEqual(["alerts"]);
expect(rendered.registered.slot("shell.overlay")).toEqual([
"alerts.toaster",
]);
expect(rendered.registered.actions().sort()).toEqual([
"alerts.open",
"alerts.openChannels",
]);
});
it("opens the tab from its actions", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
await rendered.app.invokeAction("alerts.openChannels");
expect(rendered.shellCalls).toContainEqual({
method: "openSingletonTab",
args: ["alerts"],
});
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
expect(app.registered.slot("shell.overlay")).toEqual([]);
});
});
describe("the alerts panel", () => {
it("lists alerts, flags a failed delivery and marks one read", async () => {
const { api, calls } = stubApi();
rendered = await renderWithApp(plugin, { manifest, locales, api });
const panel = rendered.renderPanel("alerts", { active: true });
await waitFor(() =>
expect(panel.textContent).toContain("Certificate renewal failed"),
);
expect(panel.textContent).toContain("Termix 2.9 is out");
expect(panel.textContent).toContain("1 channel failed");
expect(panel.textContent).toContain("Read more");
fireEvent.click(panel.querySelector('[aria-label="Mark as read"]')!);
await waitFor(() =>
expect(calls).toContainEqual([
"POST",
"/items/read",
{ ids: [2], read: true },
]),
);
});
it("deletes an alert", async () => {
const { api, calls } = stubApi();
rendered = await renderWithApp(plugin, { manifest, locales, api });
const panel = rendered.renderPanel("alerts", { active: true });
await waitFor(() =>
expect(panel.textContent).toContain("Certificate renewal failed"),
);
fireEvent.click(panel.querySelectorAll('[aria-label="Delete"]')[0]);
await waitFor(() => expect(calls).toContainEqual(["DELETE", "/items/2"]));
await waitFor(() =>
expect(panel.textContent).not.toContain("Certificate renewal failed"),
);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from "vitest";
import {
createAlertsStore,
parseServerSentEvents,
} from "../../src/frontend/store";
import { shouldPopUp } from "../../src/frontend/popups";
import {
configFrom,
parseHeaders,
EMPTY,
} from "../../src/frontend/channel-config";
import type { AlertItem } from "../../src/types";
function item(id: number, severity: AlertItem["severity"]): AlertItem {
return {
id,
source: "docker",
category: "docker",
severity,
title: `alert ${id}`,
body: null,
link: null,
context: null,
deliveries: null,
readAt: null,
createdAt: new Date().toISOString(),
};
}
/** A response whose body streams the given chunks, then stays open. */
function streaming(chunks: string[]): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
},
});
return new Response(body, {
headers: { "Content-Type": "text/event-stream" },
});
}
describe("parseServerSentEvents", () => {
it("parses whole events and keeps the rest for later", () => {
const events: Array<{ event: string; data: string }> = [];
const rest = parseServerSentEvents(
'event: unread\ndata: {"count":2}\n\n: keepalive\n\nevent: item\ndata: {"id"',
(event) => events.push(event),
);
expect(events).toEqual([{ event: "unread", data: '{"count":2}' }]);
expect(rest).toBe('event: item\ndata: {"id"');
});
});
describe("createAlertsStore", () => {
it("follows the unread count and hands on each alert once as new", async () => {
const first = item(7, "critical");
const store = createAlertsStore({
connect: async () =>
streaming([
'event: unread\ndata: {"count":3}\n\n',
`event: item\ndata: ${JSON.stringify(first)}\n\n`,
`event: item\ndata: ${JSON.stringify({ ...first, deliveries: [] })}\n\n`,
]),
loadUnread: async () => 0,
});
const seen: Array<[number, boolean]> = [];
store.onItem((alert, isNew) => seen.push([alert.id, isNew]));
store.start();
await vi.waitFor(() => expect(seen).toHaveLength(2));
store.stop();
expect(store.unread()).toBe(3);
expect(seen).toEqual([
[7, true],
[7, false],
]);
expect(store.version()).toBe(2);
});
it("falls back to the count route while the stream is down", async () => {
const loadUnread = vi.fn(async () => 5);
const store = createAlertsStore({
connect: async () => new Response("", { status: 503 }),
loadUnread,
retryMs: 10_000,
});
store.start();
await vi.waitFor(() => expect(store.unread()).toBe(5));
store.stop();
});
});
describe("popups", () => {
it("follows the user's level", () => {
expect(shouldPopUp(item(1, "info"), "all")).toBe(true);
expect(shouldPopUp(item(1, "info"), "warning")).toBe(false);
expect(shouldPopUp(item(1, "warning"), "warning")).toBe(true);
expect(shouldPopUp(item(1, "warning"), "critical")).toBe(false);
expect(shouldPopUp(item(1, "critical"), "critical")).toBe(true);
expect(shouldPopUp(item(1, "critical"), "off")).toBe(false);
});
});
describe("channel config", () => {
it("reads headers one per line", () => {
expect(
parseHeaders("Authorization: Bearer x\nbad line\nX-Id: 7 "),
).toEqual({ Authorization: "Bearer x", "X-Id": "7" });
});
it("builds only the fields each type uses", () => {
expect(
configFrom({
...EMPTY,
type: "webhook",
url: " https://a.example ",
headers: "A: b",
allowPrivateNetwork: true,
}),
).toEqual({
url: "https://a.example",
method: "POST",
headers: { A: "b" },
allowPrivateNetwork: true,
});
expect(
configFrom({ ...EMPTY, type: "email", to: "ops@example.com" }),
).toEqual({ to: "ops@example.com" });
expect(
configFrom({
...EMPTY,
type: "discord",
url: "https://discord.com/api/webhooks/1",
allowPrivateNetwork: true,
}),
).toEqual({ url: "https://discord.com/api/webhooks/1" });
});
});