Files
TREK/server/tests/unit/services/channelRegistry.test.ts
T
jubnl c9627af239 feat(notifications): let a plugin register a notification channel
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.
2026-07-11 19:26:53 +02:00

116 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});