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

@@ -1,93 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DismissedAlertRepository } from "../../../database/repositories/dismissed-alert-repository.js";
describe("DismissedAlertRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<DismissedAlertRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new DismissedAlertRepository(context, onWrite);
}
it("creates, lists, and finds dismissed alerts by user", async () => {
const repo = await createRepository();
await repo.create("user-1", "alert-1");
await repo.create("user-1", "alert-2");
await repo.create("user-2", "alert-3");
expect(await repo.listAlertIdsByUserId("user-1")).toEqual([
"alert-1",
"alert-2",
]);
expect((await repo.findForUser("user-1", "alert-1"))?.alertId).toBe(
"alert-1",
);
expect(await repo.findForUser("user-1", "alert-3")).toBeNull();
expect(
(await repo.listByUserId("user-1")).map((row) => row.alertId),
).toEqual(["alert-1", "alert-2"]);
});
it("creates import alerts without duplicating user alert pairs", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(
repo.createForImport("user-1", "alert-1", "2026-01-01T00:00:00.000Z"),
).resolves.toBe(true);
await expect(
repo.createForImport("user-1", "alert-1", "2026-01-02T00:00:00.000Z"),
).resolves.toBe(false);
const alerts = await repo.listByUserId("user-1");
expect(alerts).toHaveLength(1);
expect(alerts[0]).toMatchObject({
alertId: "alert-1",
dismissedAt: "2026-01-01T00:00:00.000Z",
});
expect(writeCount).toBe(1);
});
it("deletes dismissed alerts and only triggers writes for changed rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create("user-1", "alert-1");
await repo.create("user-1", "alert-2");
await repo.create("user-2", "alert-3");
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-1", "missing")).toBe(false);
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-1", "alert-1")).toBe(true);
expect(writeCount).toBe(4);
expect(await repo.listAlertIdsByUserId("user-1")).toEqual(["alert-2"]);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(5);
});
});
@@ -1,69 +0,0 @@
/**
* POST /notification-channels/:id/test sends through deliverNotification,
* the same path an automation's notify step takes.
*/
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
deliver: vi.fn(),
channel: null as null | { id: number; type: string; config: string },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentNotificationChannelRepository: () => ({
findNotificationChannelForUser: async () => h.channel,
}),
}));
vi.mock("../../../utils/notification-sender.js", () => ({
deliverNotification: h.deliver,
}));
const { default: router } =
await import("../../../database/routes/notification-channels-routes.js");
const app = express().use(express.json()).use(router);
beforeEach(() => {
h.deliver.mockReset();
h.channel = { id: 3, type: "ntfy", config: '{"url":"https://ntfy.sh"}' };
});
describe("POST /notification-channels/:id/test", () => {
it("delivers a test notification to the channel", async () => {
h.deliver.mockResolvedValue(undefined);
const response = await request(app).post("/notification-channels/3/test");
expect(response.body).toEqual({ success: true });
expect(h.deliver).toHaveBeenCalledWith(
h.channel,
expect.objectContaining({ severity: "info" }),
);
});
it("reports the delivery error", async () => {
h.deliver.mockRejectedValue(new Error("ntfy channel is missing a topic"));
const response = await request(app).post("/notification-channels/3/test");
expect(response.body).toEqual({
success: false,
error: "ntfy channel is missing a topic",
});
});
it("answers 404 for a channel the user does not own", async () => {
h.channel = null;
const response = await request(app).post("/notification-channels/9/test");
expect(response.status).toBe(404);
});
});
@@ -29,7 +29,6 @@ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"),
}));
@@ -126,7 +125,6 @@ describe("resetUserPassword", () => {
expect(calls.deletedFor).toEqual([
"usage:user-1",
"activity:user-1",
"alerts:user-1",
"hosts:user-1",
"credentials:user-1",
]);