mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
c9627af239
TREK's four channels (in-app, email, webhook, ntfy) were a closed set: notificationService.send() dispatched with four copy-pasted `if` blocks and no provider abstraction, so a fifth channel meant editing eight files by hand. A plugin could produce a notification via ctx.notify.send(), but never deliver one. A plugin now registers a channel with `hooks.notificationChannel` + `hook:notification-channel` on a plain `type: 'integration'` — not a new manifest type, so the TREK-Plugins registry schema and both its CI gates are untouched. Core refactor - New channel registry (services/notifications/): email/webhook/ntfy become ExternalChannel providers wrapping the EXISTING send functions — no delivery logic is rewritten, only relocated. In-app deliberately stays out: it writes typed rows with scope/target/callbacks, not a rendered title+body, the same line shared/ already draws with i18n/externalNotifications. - The event text is now rendered once per recipient instead of once per channel. - The channel set is open: NotifChannel becomes a string, the matrix is registry-derived, and the UI columns are server-driven. The DB column was already bare TEXT and the Zod contract already a string record — only the TypeScript and the two UIs were ever closed. The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls out of the request; a notification is host-initiated for an ARBITRARY recipient, so ctx.settings.get() would return undefined. The host resolves the recipient's decrypted scope:'user' settings itself and passes them as an argument. That is what lets a channel plugin be handed someone's push token WITHOUT being handed the right to read their trips as them. Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's `notification_channels` list. A built-in always exists in code and needs an explicit switch; a plugin channel only exists because an admin enabled that plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin toggle rebuilt it from three booleans, silently dropping anything else — so requiring a second opt-in meant the channel could never be turned on at all.) Also fixed, found while building this: - Plugin settings keys were unvalidated, so a field named `__proto__` or `constructor` resolved off Object.prototype: a REQUIRED field with such a name reported as configured for every user who had configured nothing — enough, for a channel, to be dispatched to everyone with no credentials. Keys are now constrained at install and the config blob is parsed null-prototype, so it is impossible even for an already-installed plugin. - A `select` field's options were cast straight through, so the obvious `["1","5"]` form rendered every dropdown entry BLANK (the client reads value/label). Now coerced, and malformed options are rejected. Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted service can't name the operator's host at publish time, so an admin adds it post-install and the runtime re-spawns the child with the widened allow-list — only for a plugin that DECLARED operatorEgress, and only an admin, never a user); settings-page actions (a "Test connection" button, user-initiated so ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped notification-channel template in the SDK. Verified end to end against a real Gotify container, not just in tests.
116 lines
4.3 KiB
TypeScript
116 lines
4.3 KiB
TypeScript
/**
|
||
* Unit tests for the external notification channel registry.
|
||
* Covers CHREG-001 to CHREG-008.
|
||
*/
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import {
|
||
listChannels,
|
||
getChannel,
|
||
registerChannel,
|
||
setPluginChannelSource,
|
||
pluginChannelId,
|
||
isPluginChannelId,
|
||
__resetChannelsForTest,
|
||
type ChannelMessage,
|
||
type ExternalChannel,
|
||
} from '../../../src/services/notifications/channelRegistry';
|
||
import { registerBuiltinChannels } from '../../../src/services/notifications/builtins';
|
||
|
||
function fakeChannel(id: string, over: Partial<ExternalChannel> = {}): ExternalChannel {
|
||
return {
|
||
id,
|
||
source: 'plugin',
|
||
label: id,
|
||
supportsEvent: () => true,
|
||
isConfiguredFor: () => true,
|
||
sendToUser: async () => true,
|
||
...over,
|
||
};
|
||
}
|
||
|
||
const MSG: ChannelMessage = { event: 'trip_invite', title: 't', body: 'b' };
|
||
|
||
beforeEach(() => {
|
||
__resetChannelsForTest();
|
||
registerBuiltinChannels();
|
||
});
|
||
|
||
afterEach(() => {
|
||
setPluginChannelSource(null);
|
||
});
|
||
|
||
describe('channelRegistry', () => {
|
||
it('CHREG-001 — the three built-in external channels are registered; in-app is not', () => {
|
||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
|
||
expect(getChannel('inapp')).toBeUndefined();
|
||
});
|
||
|
||
it('CHREG-002 — plugin channels come from the injected source and are namespaced', () => {
|
||
setPluginChannelSource(() => [fakeChannel(pluginChannelId('gotify'))]);
|
||
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
|
||
expect(getChannel('plugin:gotify')?.source).toBe('plugin');
|
||
expect(isPluginChannelId('plugin:gotify')).toBe(true);
|
||
expect(isPluginChannelId('email')).toBe(false);
|
||
});
|
||
|
||
it('CHREG-003 — a plugin channel disappears when the runtime stops reporting it', () => {
|
||
let live = true;
|
||
setPluginChannelSource(() => (live ? [fakeChannel('plugin:gotify')] : []));
|
||
expect(getChannel('plugin:gotify')).toBeDefined();
|
||
live = false;
|
||
expect(getChannel('plugin:gotify')).toBeUndefined();
|
||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
|
||
});
|
||
|
||
it('CHREG-004 — a throwing plugin source cannot take notifications down', () => {
|
||
setPluginChannelSource(() => {
|
||
throw new Error('runtime exploded');
|
||
});
|
||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
|
||
});
|
||
|
||
it('CHREG-005 — a plugin can never claim a built-in id', () => {
|
||
// The prefix is the whole defence: an id without it is not a plugin channel id,
|
||
// and pluginChannelId() is the only way the runtime mints one.
|
||
expect(pluginChannelId('email')).toBe('plugin:email');
|
||
expect(getChannel('email')?.source).toBe('builtin');
|
||
});
|
||
|
||
it('CHREG-006 — only email declares the admin-scoped toggle bypass', () => {
|
||
expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true);
|
||
expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
|
||
expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
|
||
});
|
||
|
||
it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => {
|
||
expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true);
|
||
expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true);
|
||
expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined();
|
||
});
|
||
|
||
it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => {
|
||
for (const id of ['email', 'webhook', 'ntfy']) {
|
||
expect(getChannel(id)!.supportsEvent('trip_invite')).toBe(true);
|
||
expect(getChannel(id)!.supportsEvent('version_available')).toBe(true);
|
||
expect(getChannel(id)!.supportsEvent('synology_session_cleared')).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('CHREG-009 — registerChannel replaces an existing id rather than duplicating it', () => {
|
||
registerChannel(fakeChannel('email', { source: 'builtin' }));
|
||
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
|
||
});
|
||
|
||
it('CHREG-010 — a channel that rejects is the caller’s problem, not the registry’s', async () => {
|
||
const boom = fakeChannel('plugin:boom', {
|
||
sendToUser: async () => {
|
||
throw new Error('nope');
|
||
},
|
||
});
|
||
setPluginChannelSource(() => [boom]);
|
||
await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope');
|
||
// and the registry is unharmed
|
||
expect(listChannels()).toHaveLength(4);
|
||
});
|
||
});
|