mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +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,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" },
|
||||
};
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user