mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +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.
194 lines
9.0 KiB
TypeScript
194 lines
9.0 KiB
TypeScript
/**
|
||
* A plugin channel may only ever ADD a channel — never override or shadow a built-in.
|
||
*
|
||
* The runtime mints plugin ids with pluginChannelId() and leaves the built-in-only
|
||
* privileges off, so none of this is reachable through a manifest. These tests pin the
|
||
* REGISTRY's own enforcement, so the guarantee survives a bug or a future caller that
|
||
* hands the registry a channel it shouldn't. Without it, a channel claiming `email`
|
||
* rides the user's email opt-in and receives admin-scoped notifications.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null } };
|
||
});
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||
vi.mock('../../../src/services/apiKeyCrypto', () => ({ decrypt_api_key: (v: string) => v, maybe_encrypt_api_key: (v: string) => v, encrypt_api_key: (v: string) => v }));
|
||
const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn().mockResolvedValue({ accepted: ['a@b.c'] }) }));
|
||
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: sendMailMock, verify: vi.fn() })) } }));
|
||
vi.stubGlobal('fetch', vi.fn());
|
||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||
vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })), createPinnedDispatcher: vi.fn(() => ({})) }));
|
||
|
||
import { createTables } from '../../../src/db/schema';
|
||
import { runMigrations } from '../../../src/db/migrations';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import { createUser, createAdmin, setAppSetting, setNotificationChannels } from '../../helpers/factories';
|
||
import { send } from '../../../src/services/notificationService';
|
||
import { getPreferencesMatrix } from '../../../src/services/notificationPreferencesService';
|
||
import {
|
||
setPluginChannelSource,
|
||
listChannels,
|
||
getChannel,
|
||
registerChannel,
|
||
type ExternalChannel,
|
||
} from '../../../src/services/notifications/channelRegistry';
|
||
|
||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||
beforeEach(() => { resetTestDb(testDb); setPluginChannelSource(null); });
|
||
|
||
const rogueSend = vi.fn().mockResolvedValue(true);
|
||
const rogueGlobal = vi.fn().mockResolvedValue(true);
|
||
|
||
function setSmtp(): void {
|
||
setAppSetting(testDb, 'smtp_host', 'mail.test.com');
|
||
setAppSetting(testDb, 'smtp_port', '587');
|
||
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
|
||
}
|
||
|
||
/** A channel claiming a BUILT-IN id plus the built-in-only privilege flags. */
|
||
function rogue(over: Partial<ExternalChannel> = {}): ExternalChannel {
|
||
return {
|
||
id: 'email',
|
||
source: 'builtin',
|
||
bypassesActiveToggleForAdminEvents: true,
|
||
supportsAdminGlobal: true,
|
||
supportsEvent: () => true,
|
||
isConfiguredFor: () => true,
|
||
sendToUser: rogueSend,
|
||
sendGlobal: rogueGlobal,
|
||
...over,
|
||
} as ExternalChannel;
|
||
}
|
||
|
||
const TRIP_INVITE = { event: 'trip_invite', actorId: null, scope: 'user', targetId: 0, params: { trip: 'Rome', actor: 'A', invitee: 'B', tripId: '1' } } as const;
|
||
|
||
describe('a plugin channel can never override a built-in', () => {
|
||
beforeEach(() => { rogueSend.mockClear(); rogueGlobal.mockClear(); });
|
||
|
||
it('CHOVR-001 — a channel claiming a built-in id is dropped from the registry', () => {
|
||
setPluginChannelSource(() => [rogue()]);
|
||
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
|
||
expect(getChannel('email')!.source).toBe('builtin');
|
||
});
|
||
|
||
it('CHOVR-002 — it does NOT ride the user’s email opt-in', async () => {
|
||
const { user } = createUser(testDb);
|
||
setSmtp();
|
||
setNotificationChannels(testDb, 'email'); // the user enabled EMAIL, not a plugin
|
||
setPluginChannelSource(() => [rogue()]);
|
||
|
||
await send({ ...TRIP_INVITE, targetId: user.id });
|
||
|
||
expect(rogueSend).not.toHaveBeenCalled();
|
||
expect(sendMailMock).toHaveBeenCalledTimes(1); // the real email channel still delivers
|
||
});
|
||
|
||
it('CHOVR-003 — an un-namespaced id is dropped even when it collides with nothing', () => {
|
||
setPluginChannelSource(() => [rogue({ id: 'carrier-pigeon', source: 'plugin' })]);
|
||
expect(listChannels().map(c => c.id)).not.toContain('carrier-pigeon');
|
||
});
|
||
|
||
it('CHOVR-004 — a properly namespaced channel IS admitted', () => {
|
||
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]);
|
||
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
|
||
});
|
||
|
||
it('CHOVR-005 — an admitted plugin channel has its built-in-only privileges stripped', () => {
|
||
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' })]);
|
||
const ch = getChannel('plugin:gotify')!;
|
||
expect(ch.source).toBe('plugin'); // it cannot self-declare as a built-in
|
||
expect(ch.bypassesActiveToggleForAdminEvents).toBe(false);
|
||
expect(ch.supportsAdminGlobal).toBe(false);
|
||
expect(ch.sendGlobal).toBeUndefined();
|
||
});
|
||
|
||
it('CHOVR-006 — a plugin channel gets NO admin-scoped event, even claiming to support one', async () => {
|
||
createAdmin(testDb);
|
||
setSmtp();
|
||
setNotificationChannels(testDb, 'plugin:gotify');
|
||
// supportsEvent lies and says yes to everything; the registry strips what makes it matter.
|
||
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', supportsEvent: () => true })]);
|
||
|
||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
|
||
|
||
expect(rogueSend).not.toHaveBeenCalled();
|
||
expect(rogueGlobal).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('CHOVR-007 — duplicate plugin ids collapse to one (no double send, no double column)', async () => {
|
||
const { user } = createUser(testDb);
|
||
setNotificationChannels(testDb, 'plugin:gotify');
|
||
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' }), rogue({ id: 'plugin:gotify' })]);
|
||
|
||
expect(listChannels().filter(c => c.id === 'plugin:gotify')).toHaveLength(1);
|
||
await send({ ...TRIP_INVITE, targetId: user.id });
|
||
expect(rogueSend).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('CHOVR-008 — the preferences matrix never shows a duplicate column', () => {
|
||
const { user } = createUser(testDb);
|
||
setNotificationChannels(testDb, 'email');
|
||
setPluginChannelSource(() => [rogue()]);
|
||
const ids = getPreferencesMatrix(user.id, 'user').channels.map(c => c.id);
|
||
expect(ids.filter(i => i === 'email')).toHaveLength(1);
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
});
|
||
|
||
it('CHOVR-009 — a built-in may not claim the plugin namespace either', () => {
|
||
expect(() => registerChannel(rogue({ id: 'plugin:sneaky' }))).toThrow(/plugin namespace/);
|
||
});
|
||
});
|
||
|
||
describe('a live plugin channel needs no second opt-in', () => {
|
||
beforeEach(() => { rogueSend.mockClear(); });
|
||
|
||
it('CHOVR-011 — it appears in the matrix with NOTHING in notification_channels', () => {
|
||
const { user } = createUser(testDb);
|
||
setNotificationChannels(testDb, 'none'); // the admin has enabled no built-in at all
|
||
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', label: 'Gotify' })]);
|
||
|
||
const matrix = getPreferencesMatrix(user.id, 'user');
|
||
const ch = matrix.channels.find(c => c.id === 'plugin:gotify')!;
|
||
// Enabling the PLUGIN is the opt-in. There is no UI that can write a `plugin:` id into
|
||
// the notification_channels CSV, so requiring one meant the channel could never show.
|
||
expect(ch.active).toBe(true);
|
||
expect(ch.label).toBe('Gotify');
|
||
expect(matrix.implemented_combos['trip_invite']).toContain('plugin:gotify');
|
||
});
|
||
|
||
it('CHOVR-012 — and it actually DELIVERS with nothing in notification_channels', async () => {
|
||
const { user } = createUser(testDb);
|
||
setNotificationChannels(testDb, 'none');
|
||
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]);
|
||
|
||
await send({ ...TRIP_INVITE, targetId: user.id });
|
||
expect(rogueSend).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('CHOVR-013 — a BUILT-IN still needs its explicit switch', async () => {
|
||
const { user } = createUser(testDb);
|
||
setSmtp();
|
||
setNotificationChannels(testDb, 'none'); // email NOT enabled
|
||
sendMailMock.mockClear();
|
||
|
||
await send({ ...TRIP_INVITE, targetId: user.id });
|
||
expect(sendMailMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('CHOVR-014 — an unconfigured user sees the column but gets no delivery', async () => {
|
||
const { user } = createUser(testDb);
|
||
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', isConfiguredFor: () => false })]);
|
||
|
||
const ch = getPreferencesMatrix(user.id, 'user').channels.find(c => c.id === 'plugin:gotify')!;
|
||
expect(ch.active).toBe(true); // shown — so they can see it and go configure it
|
||
expect(ch.configured).toBe(false); // …but flagged as needing setup
|
||
|
||
await send({ ...TRIP_INVITE, targetId: user.id });
|
||
expect(rogueSend).not.toHaveBeenCalled(); // no credentials → nothing to send with
|
||
});
|
||
});
|