mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05: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
@@ -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",
|
||||
]);
|
||||
|
||||
BIN
Binary file not shown.
+8
-1
@@ -412,6 +412,14 @@ export const ROWS: Record<string, Row[]> = {
|
||||
enabled: 1,
|
||||
},
|
||||
],
|
||||
dismissed_alerts: [
|
||||
{
|
||||
id: 1,
|
||||
user_id: USERS.admin,
|
||||
alert_id: "d4-dismissed-announcement",
|
||||
dismissed_at: T,
|
||||
},
|
||||
],
|
||||
|
||||
snippet_folders: [
|
||||
{
|
||||
@@ -839,6 +847,5 @@ export const CORE_TABLES = new Set([
|
||||
"ssh_data",
|
||||
"host_access",
|
||||
"user_preferences",
|
||||
"notification_channels",
|
||||
"settings",
|
||||
]);
|
||||
@@ -337,6 +337,14 @@ ALTER TABLE `user_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0058_boring_avengers ====
|
||||
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
|
||||
-- renames the first into its own namespace and copies the second at
|
||||
-- activation, both after this migration, so a real DROP TABLE here would
|
||||
-- delete them first. notification_channels stays in place, unused, until
|
||||
-- 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
@@ -634,6 +642,23 @@ CREATE TABLE IF NOT EXISTS `automation_channels` (
|
||||
);
|
||||
RENAME TABLE `automation_channels` TO `p_automations_channels`;
|
||||
|
||||
-- ==== plugin automations 0002_unlink_channels.sql ====
|
||||
-- automations 0002: unlink_channels
|
||||
-- Hand-written: channels moved from core's notification_channels to the
|
||||
-- alerts plugin, so the link table drops its foreign key to the old table.
|
||||
-- The constraint's name differs between installs, so the table is rebuilt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_automations_channels_v2` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`automation_id` int NOT NULL,
|
||||
`channel_id` int NOT NULL,
|
||||
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO `p_automations_channels_v2` (`id`, `automation_id`, `channel_id`) SELECT `id`, `automation_id`, `channel_id` FROM `p_automations_channels`;
|
||||
DROP TABLE `p_automations_channels`;
|
||||
RENAME TABLE `p_automations_channels_v2` TO `p_automations_channels`;
|
||||
CREATE UNIQUE INDEX `idx_automation_channels_pair` ON `p_automations_channels` (`automation_id`, `channel_id`);
|
||||
|
||||
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
|
||||
-- homepage 0001: adopt_homepage_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
@@ -729,6 +754,62 @@ CREATE TABLE IF NOT EXISTS `ai_proposals` (
|
||||
);
|
||||
RENAME TABLE `ai_proposals` TO `p_ai_proposals`;
|
||||
|
||||
-- ==== plugin alerts 0001_create_alerts.sql ====
|
||||
-- alerts 0001: create_alerts
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_alerts_channels` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` varchar(32) NOT NULL,
|
||||
`config` text,
|
||||
`enabled` boolean NOT NULL DEFAULT true,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_alerts_channels_user` ON `p_alerts_channels` (`user_id`);
|
||||
CREATE TABLE IF NOT EXISTS `p_alerts_items` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`source` varchar(64) NOT NULL,
|
||||
`category` varchar(128) NOT NULL,
|
||||
`severity` varchar(16) NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`body` text,
|
||||
`link` text,
|
||||
`context` text,
|
||||
`dedupe_key` varchar(255),
|
||||
`deliveries` text,
|
||||
`read_at` varchar(255),
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_alerts_items_user` ON `p_alerts_items` (`user_id`, `created_at`);
|
||||
CREATE INDEX `idx_alerts_items_unread` ON `p_alerts_items` (`user_id`, `read_at`);
|
||||
CREATE INDEX `idx_alerts_items_dedupe` ON `p_alerts_items` (`user_id`, `dedupe_key`);
|
||||
CREATE TABLE IF NOT EXISTS `p_alerts_rules` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`pattern` varchar(128) NOT NULL DEFAULT '*',
|
||||
`min_severity` varchar(16) NOT NULL DEFAULT 'warning',
|
||||
`channel_ids` text NOT NULL,
|
||||
`enabled` boolean NOT NULL DEFAULT true,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_alerts_rules_user` ON `p_alerts_rules` (`user_id`);
|
||||
CREATE TABLE IF NOT EXISTS `dismissed_alerts` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) 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
|
||||
);
|
||||
RENAME TABLE `dismissed_alerts` TO `p_alerts_dismissed`;
|
||||
|
||||
-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ====
|
||||
-- file-manager 0001: adopt_file_manager_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -363,6 +363,14 @@ ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIM
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0059_square_korg ====
|
||||
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
|
||||
-- renames the first into its own namespace and copies the second at
|
||||
-- activation, both after this migration, so a real DROP TABLE here would
|
||||
-- delete them first. notification_channels stays in place, unused, until
|
||||
-- 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
@@ -682,6 +690,24 @@ CREATE TABLE IF NOT EXISTS "automation_channels" (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
|
||||
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
|
||||
|
||||
-- ==== plugin automations 0002_unlink_channels.sql ====
|
||||
-- automations 0002: unlink_channels
|
||||
-- Hand-written: channels moved from core's notification_channels to the
|
||||
-- alerts plugin, so the link table drops its foreign key to the old table.
|
||||
-- The constraint's name differs between installs, so the table is rebuilt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"automation_id" integer NOT NULL,
|
||||
"channel_id" integer NOT NULL,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
|
||||
DROP TABLE "p_automations_channels";
|
||||
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
|
||||
SELECT setval(pg_get_serial_sequence('p_automations_channels', 'id'), COALESCE((SELECT MAX("id") FROM "p_automations_channels"), 1));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
|
||||
|
||||
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
|
||||
-- homepage 0001: adopt_homepage_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
@@ -784,6 +810,63 @@ CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id",
|
||||
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id");
|
||||
ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals";
|
||||
|
||||
-- ==== plugin alerts 0001_create_alerts.sql ====
|
||||
-- alerts 0001: create_alerts
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_alerts_channels" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"type" varchar(32) NOT NULL,
|
||||
"config" text,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_channels_user" ON "p_alerts_channels" ("user_id");
|
||||
CREATE TABLE IF NOT EXISTS "p_alerts_items" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"source" varchar(64) NOT NULL,
|
||||
"category" varchar(128) NOT NULL,
|
||||
"severity" varchar(16) NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"body" text,
|
||||
"link" text,
|
||||
"context" text,
|
||||
"dedupe_key" varchar(255),
|
||||
"deliveries" text,
|
||||
"read_at" varchar(255),
|
||||
"created_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_items_user" ON "p_alerts_items" ("user_id", "created_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_items_unread" ON "p_alerts_items" ("user_id", "read_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_items_dedupe" ON "p_alerts_items" ("user_id", "dedupe_key");
|
||||
CREATE TABLE IF NOT EXISTS "p_alerts_rules" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"pattern" varchar(128) NOT NULL DEFAULT '*',
|
||||
"min_severity" varchar(16) NOT NULL DEFAULT 'warning',
|
||||
"channel_ids" text NOT NULL,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_rules_user" ON "p_alerts_rules" ("user_id");
|
||||
CREATE TABLE IF NOT EXISTS "dismissed_alerts" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) 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 IF NOT EXISTS "idx_dismissed_alerts_user_id" ON "dismissed_alerts" ("user_id");
|
||||
ALTER TABLE "dismissed_alerts" RENAME TO "p_alerts_dismissed";
|
||||
|
||||
-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ====
|
||||
-- file-manager 0001: adopt_file_manager_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
/**
|
||||
* ctx.notify and ctx.fetch: each is gated on its capability and audited,
|
||||
* notify only ever reaches the acting user's own channels, and fetch goes
|
||||
* through the SSRF guard with only the hosts the caller listed.
|
||||
* ctx.notify and ctx.fetch: each is gated on its capability and audited.
|
||||
* notify resolves the audience in core and hands the hub plain user ids, and
|
||||
* fetch goes through the SSRF guard with only the hosts the caller listed.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PluginNotification,
|
||||
PluginNotifyHub,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
channels: new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
}>
|
||||
>(),
|
||||
delivered: [] as Array<{ channelId: number; title: string }>,
|
||||
failChannel: null as number | null,
|
||||
users: [
|
||||
{ id: "alice", isAdmin: true },
|
||||
{ id: "bob", isAdmin: false },
|
||||
{ id: "carol", isAdmin: false },
|
||||
],
|
||||
permissions: new Map<string, string[]>([
|
||||
["bob", ["docker.view"]],
|
||||
["carol", ["docker.view"]],
|
||||
]),
|
||||
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
||||
lastTls: null as Record<string, unknown> | null,
|
||||
lastSignal: null as AbortSignal | null,
|
||||
@@ -35,28 +36,28 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentNotificationChannelRepository: () => ({
|
||||
listNotificationChannels: async (userId: string) =>
|
||||
state.channels.get(userId) ?? [],
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => state.users,
|
||||
findById: async (id: string) =>
|
||||
state.users.find((user) => user.id === id) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (userId: string, permission: string) =>
|
||||
state.permissions.get(userId)?.includes(permission) ?? false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/notification-sender.js", () => ({
|
||||
deliverNotification: async (
|
||||
channel: { id: number },
|
||||
notification: { title: string },
|
||||
) => {
|
||||
if (channel.id === state.failChannel) throw new Error("HTTP 500");
|
||||
state.delivered.push({ channelId: channel.id, title: notification.title });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: async (
|
||||
url: string,
|
||||
@@ -74,13 +75,18 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import { clearNotifyHub } from "../../plugins/notify-hub.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
grants.set("demo", capabilities);
|
||||
function contextFor(
|
||||
capabilities: string[],
|
||||
id = "demo",
|
||||
granted: string[] = capabilities,
|
||||
) {
|
||||
grants.set(id, granted);
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
@@ -89,26 +95,38 @@ function contextFor(capabilities: string[]) {
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
const handle = createPluginHandle(id, { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
function fakeHub() {
|
||||
const calls: Array<{
|
||||
source: string;
|
||||
recipients: string[];
|
||||
notification: PluginNotification;
|
||||
}> = [];
|
||||
const hub: PluginNotifyHub = {
|
||||
deliver: async (input) => {
|
||||
calls.push(input);
|
||||
return {
|
||||
recipients: input.recipients.length,
|
||||
delivered: 0,
|
||||
failures: [],
|
||||
};
|
||||
},
|
||||
channels: async (userId) => [
|
||||
{ id: 1, name: `channel of ${userId}`, type: "webhook", enabled: true },
|
||||
],
|
||||
};
|
||||
return { hub, calls };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.channels.clear();
|
||||
state.delivered.length = 0;
|
||||
state.failChannel = null;
|
||||
clearNotifyHub();
|
||||
state.fetchCalls.length = 0;
|
||||
state.channels.set("alice", [
|
||||
{ id: 1, name: "ops", type: "webhook", config: "{}", enabled: 1 },
|
||||
{ id: 2, name: "muted", type: "ntfy", config: "{}", enabled: 0 },
|
||||
{ id: 3, name: "pager", type: "discord", config: "{}", enabled: 1 },
|
||||
]);
|
||||
state.channels.set("bob", [
|
||||
{ id: 9, name: "bob's", type: "webhook", config: "{}", enabled: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("ctx.notify", () => {
|
||||
@@ -123,43 +141,118 @@ describe("ctx.notify", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a call with no acting user", async () => {
|
||||
it("refuses a send with neither an acting user nor an audience", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
await expect(
|
||||
ctx.notify.send([1], { title: "t", body: "b" }),
|
||||
).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("lists the actor's channels without their config", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
const channels = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.channels(),
|
||||
await expect(ctx.notify.send({ title: "t" })).rejects.toThrow(
|
||||
/acting user/,
|
||||
);
|
||||
expect(channels).toEqual([
|
||||
{ id: 1, name: "ops", type: "webhook", enabled: true },
|
||||
{ id: 2, name: "muted", type: "ntfy", enabled: false },
|
||||
{ id: 3, name: "pager", type: "discord", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends only to the actor's enabled channels and reports failures", async () => {
|
||||
it("reaches nobody while no hub is running", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
const result = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.send({ title: "Disk" }),
|
||||
);
|
||||
expect(result).toEqual({ recipients: 0, delivered: 0, failures: [] });
|
||||
expect(
|
||||
await runAsActor("alice", "request", () => ctx.notify.channels()),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("hands the hub the acting user and the sending plugin by default", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
state.failChannel = 3;
|
||||
|
||||
const result = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.send([1, 2, 3, 9], { title: "Disk", body: "full" }),
|
||||
ctx.notify.send({ title: "Disk", severity: "critical" }),
|
||||
);
|
||||
|
||||
expect(state.delivered).toEqual([{ channelId: 1, title: "Disk" }]);
|
||||
expect(result).toEqual({
|
||||
delivered: 1,
|
||||
failures: [{ channelId: 3, name: "pager", error: "HTTP 500" }],
|
||||
});
|
||||
expect(result.recipients).toBe(1);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
source: "demo",
|
||||
recipients: ["alice"],
|
||||
notification: { title: "Disk", severity: "critical" },
|
||||
},
|
||||
]);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_notify_send",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a named user, admins and a permission in core", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
|
||||
await ctx.notify.send({ title: "a", audience: { userId: "bob" } });
|
||||
await ctx.notify.send({ title: "b", audience: "admins" });
|
||||
await ctx.notify.send({
|
||||
title: "c",
|
||||
audience: { permission: "docker.view" },
|
||||
});
|
||||
|
||||
expect(calls.map((call) => call.recipients)).toEqual([
|
||||
["bob"],
|
||||
["alice"],
|
||||
["bob", "carol"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not call the hub for an audience that names nobody", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
|
||||
const result = await ctx.notify.send({
|
||||
title: "x",
|
||||
audience: { userId: "ghost" },
|
||||
});
|
||||
|
||||
expect(result.recipients).toBe(0);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks the hub for the acting user's channels", async () => {
|
||||
const { hub } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
expect(
|
||||
await runAsActor("bob", "request", () => ctx.notify.channels()),
|
||||
).toEqual([
|
||||
{ id: 1, name: "channel of bob", type: "webhook", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("only serves a hub the manifest declares notify:hub for", () => {
|
||||
const { hub } = fakeHub();
|
||||
expect(() =>
|
||||
contextFor(["notify:send"], "rogue").notify.serve(hub),
|
||||
).toThrow(/notify:hub/);
|
||||
});
|
||||
|
||||
it("refuses a second hub from another plugin", () => {
|
||||
contextFor(["notify:hub"], "hub").notify.serve(fakeHub().hub);
|
||||
expect(() =>
|
||||
contextFor(["notify:hub"], "other").notify.serve(fakeHub().hub),
|
||||
).toThrow(/already/);
|
||||
});
|
||||
|
||||
it("stops using a hub once it is revoked or its grant is gone", async () => {
|
||||
const first = fakeHub();
|
||||
const revoke = contextFor(["notify:hub"], "hub").notify.serve(first.hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
revoke();
|
||||
await ctx.notify.send({ title: "x", audience: "admins" });
|
||||
expect(first.calls).toEqual([]);
|
||||
|
||||
const second = fakeHub();
|
||||
contextFor(["notify:hub"], "ungranted", []).notify.serve(second.hub);
|
||||
await ctx.notify.send({ title: "y", audience: "admins" });
|
||||
expect(second.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.fetch", () => {
|
||||
|
||||
@@ -265,6 +265,7 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
["/plugin-api/secret-sources", "d4-source"],
|
||||
["/plugin-api/session-sharing/rooms", "d4-room"],
|
||||
["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"],
|
||||
["/plugin-api/alerts/channels", "d4-channel"],
|
||||
];
|
||||
const failures: string[] = [];
|
||||
for (const [url, marker, as] of checks) {
|
||||
@@ -500,6 +501,17 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
).toBe("d4-source-token");
|
||||
});
|
||||
|
||||
it("moves every notification channel into alerts, config readable", async () => {
|
||||
const response = await get(current(), "/plugin-api/alerts/channels/1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
name: "d4-channel",
|
||||
type: "ntfy",
|
||||
usable: true,
|
||||
config: { url: "https://ntfy.d4.example/d4" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every role's permissions", async () => {
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* The 2.8 to 2.9 channel move. Every notification channel reaches the alerts
|
||||
* plugin's table with its id, its config sealed with the installation key once
|
||||
* the owner's data key opens, and nothing left readable in the old table.
|
||||
* A closed or wrong key leaves the config as it was, and a later run for that
|
||||
* user finishes the job. Running it twice changes nothing.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
deks: new Map<string, Buffer>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
|
||||
}));
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:v1:${value}`,
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return { databaseLogger: log };
|
||||
});
|
||||
|
||||
const { runNotificationChannelMigration } =
|
||||
await import("../../upgrade/notification-channel-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
const SOURCE_DDL = `
|
||||
CREATE TABLE notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const TARGET_DDL = `
|
||||
CREATE TABLE "p_alerts_channels" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"config" text,
|
||||
"enabled" integer NOT NULL DEFAULT 1,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
function setup({ target }: { target: boolean }) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(SOURCE_DDL);
|
||||
if (target) sqlite.exec(TARGET_DDL);
|
||||
h.db = drizzle(sqlite);
|
||||
h.deks.clear();
|
||||
}
|
||||
|
||||
function addChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
config: Record<string, unknown>,
|
||||
options: { encrypt?: boolean; enabled?: boolean } = {},
|
||||
) {
|
||||
let dek = h.deks.get(userId);
|
||||
if (!dek) {
|
||||
dek = crypto.randomBytes(32);
|
||||
h.deks.set(userId, dek);
|
||||
}
|
||||
const text = JSON.stringify(config);
|
||||
const stored =
|
||||
options.encrypt === false
|
||||
? text
|
||||
: FieldCrypto.encryptField(text, dek, String(id), "config");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO notification_channels (id, user_id, name, type, config, enabled) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
userId,
|
||||
`channel-${id}`,
|
||||
"ntfy",
|
||||
stored,
|
||||
options.enabled === false ? 0 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
const targetRows = () =>
|
||||
sqlite
|
||||
.prepare(
|
||||
'SELECT id, user_id, name, type, config, enabled FROM "p_alerts_channels" ORDER BY id',
|
||||
)
|
||||
.all() as Array<{
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
}>;
|
||||
|
||||
const sealed = (config: Record<string, unknown>) =>
|
||||
`sysenc:v1:${JSON.stringify(config)}`;
|
||||
|
||||
describe("runNotificationChannelMigration", () => {
|
||||
beforeEach(() => setup({ target: true }));
|
||||
|
||||
it("does nothing until the plugin's table exists", async () => {
|
||||
setup({ target: false });
|
||||
addChannel(1, "alice", { url: "https://ntfy.sh", topic: "a" });
|
||||
expect(await runNotificationChannelMigration()).toEqual({
|
||||
moved: 0,
|
||||
sealed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies every channel with its id and seals its config", async () => {
|
||||
addChannel(4, "alice", { url: "https://ntfy.sh", topic: "a", token: "tk" });
|
||||
addChannel(7, "bob", { url: "https://hooks.example" }, { enabled: false });
|
||||
|
||||
expect(await runNotificationChannelMigration()).toEqual({
|
||||
moved: 2,
|
||||
sealed: 2,
|
||||
});
|
||||
|
||||
expect(targetRows()).toEqual([
|
||||
{
|
||||
id: 4,
|
||||
user_id: "alice",
|
||||
name: "channel-4",
|
||||
type: "ntfy",
|
||||
config: sealed({ url: "https://ntfy.sh", topic: "a", token: "tk" }),
|
||||
enabled: 1,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
user_id: "bob",
|
||||
name: "channel-7",
|
||||
type: "ntfy",
|
||||
config: sealed({ url: "https://hooks.example" }),
|
||||
enabled: 0,
|
||||
},
|
||||
]);
|
||||
// No token is left readable in the old table.
|
||||
expect(
|
||||
sqlite.prepare("SELECT config FROM notification_channels").all(),
|
||||
).toEqual([{ config: "" }, { config: "" }]);
|
||||
});
|
||||
|
||||
it("seals a config 2.8 never encrypted", async () => {
|
||||
addChannel(
|
||||
2,
|
||||
"alice",
|
||||
{ url: "https://ntfy.sh", topic: "p" },
|
||||
{
|
||||
encrypt: false,
|
||||
},
|
||||
);
|
||||
await runNotificationChannelMigration();
|
||||
expect(targetRows()[0].config).toBe(
|
||||
sealed({ url: "https://ntfy.sh", topic: "p" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for a closed key, then seals at that user's login", async () => {
|
||||
addChannel(3, "carol", { url: "https://ntfy.sh", topic: "c" });
|
||||
const dek = h.deks.get("carol")!;
|
||||
h.deks.delete("carol");
|
||||
|
||||
expect(await runNotificationChannelMigration()).toEqual({
|
||||
moved: 1,
|
||||
sealed: 0,
|
||||
});
|
||||
const raw = targetRows()[0].config;
|
||||
expect(raw.startsWith("sysenc:")).toBe(false);
|
||||
expect(FieldCrypto.isEncrypted(raw)).toBe(true);
|
||||
|
||||
h.deks.set("carol", dek);
|
||||
expect(await runNotificationChannelMigration("carol")).toEqual({
|
||||
moved: 0,
|
||||
sealed: 1,
|
||||
});
|
||||
expect(targetRows()[0].config).toBe(
|
||||
sealed({ url: "https://ntfy.sh", topic: "c" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a config the key cannot open as it was", async () => {
|
||||
addChannel(5, "dave", { url: "https://ntfy.sh", topic: "d" });
|
||||
h.deks.set("dave", crypto.randomBytes(32));
|
||||
|
||||
await runNotificationChannelMigration();
|
||||
|
||||
const raw = targetRows()[0].config;
|
||||
expect(raw.startsWith("sysenc:")).toBe(false);
|
||||
expect(FieldCrypto.isEncrypted(raw)).toBe(true);
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
addChannel(1, "alice", { url: "https://ntfy.sh", topic: "a" });
|
||||
await runNotificationChannelMigration();
|
||||
const first = targetRows();
|
||||
|
||||
expect(await runNotificationChannelMigration()).toEqual({
|
||||
moved: 0,
|
||||
sealed: 0,
|
||||
});
|
||||
expect(targetRows()).toEqual(first);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user