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

435 lines
13 KiB
TypeScript

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