mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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.
206 lines
11 KiB
TypeScript
206 lines
11 KiB
TypeScript
/**
|
|
* The read-side plugin service + controller (#plugins, M0). Lists installed
|
|
* plugins and reports whether the runtime is enabled (TREK_PLUGINS_ENABLED).
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
const { testDb } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec(`CREATE TABLE plugins (
|
|
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
|
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
|
|
sort_order INTEGER DEFAULT 0);
|
|
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
|
|
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
|
|
return { testDb: db };
|
|
});
|
|
vi.mock('../../../src/db/database', () => ({ db: testDb }));
|
|
|
|
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
|
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
|
|
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
|
|
|
|
beforeEach(() => {
|
|
testDb.exec('DELETE FROM plugins');
|
|
testDb.exec('DELETE FROM plugin_settings_fields');
|
|
delete process.env.TREK_PLUGINS_ENABLED;
|
|
});
|
|
afterEach(() => {
|
|
delete process.env.TREK_PLUGINS_ENABLED;
|
|
});
|
|
|
|
describe('PluginsService.list', () => {
|
|
it('returns the installed plugins and the runtime-enabled flag', () => {
|
|
testDb
|
|
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
|
|
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
|
|
const out = new PluginsService().list();
|
|
expect(out.enabled).toBe(true);
|
|
expect(out.plugins).toHaveLength(1);
|
|
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
|
|
});
|
|
|
|
it('reports enabled by default (no kill switch set)', () => {
|
|
testDb
|
|
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
|
|
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
|
|
|
const out = new PluginsService().list();
|
|
expect(out.enabled).toBe(true);
|
|
expect(out.plugins).toHaveLength(1);
|
|
});
|
|
|
|
it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', () => {
|
|
process.env.TREK_PLUGINS_ENABLED = 'false';
|
|
const out = new PluginsService().list();
|
|
expect(out.enabled).toBe(false);
|
|
expect(out.plugins).toEqual([]);
|
|
});
|
|
|
|
it('controller delegates to the service', () => {
|
|
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
|
|
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
|
|
const res = new PluginsController(svc, runtime, {} as never).list();
|
|
expect(svc.list).toHaveBeenCalled();
|
|
expect(res).toEqual({ enabled: false, plugins: [] });
|
|
});
|
|
});
|
|
|
|
describe('PluginsFeedController (client feed)', () => {
|
|
it('returns active plugins when enabled, nothing when disabled', () => {
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
|
|
const feed = new PluginsFeedController();
|
|
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
const active = feed.list();
|
|
expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box', slot: 'sidebar' }]);
|
|
|
|
process.env.TREK_PLUGINS_ENABLED = 'false';
|
|
expect(feed.list().plugins).toEqual([]);
|
|
});
|
|
|
|
it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', () => {
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
const out = new PluginsFeedController().list();
|
|
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
|
|
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
|
|
});
|
|
|
|
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', () => {
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run();
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
|
|
});
|
|
|
|
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', () => {
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run();
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
|
|
});
|
|
|
|
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', () => {
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('t','T','trip-page','Box','active','{\"tripPage\":{\"replaces\":[\"transports\",\"buchungen\"],\"position\":1}}')").run();
|
|
// a hand-edited row trying to hide 'plan' (or junk) is filtered here, not just at install
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('evil','E','trip-page','Box','active','{\"tripPage\":{\"replaces\":[\"plan\",\"nope\"],\"position\":-3}}')").run();
|
|
// the capability is meaningless off a trip-page and must not leak onto widgets
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run();
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
const out = new PluginsFeedController().list();
|
|
expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 });
|
|
expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined();
|
|
expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('PluginsController M2 endpoints', () => {
|
|
const svc = {
|
|
getInstanceConfig: vi.fn(() => ({ a: 1 })),
|
|
updateInstanceConfig: vi.fn(() => ({ a: 2 })),
|
|
} as unknown as PluginsService;
|
|
|
|
beforeEach(() => {
|
|
(svc.getInstanceConfig as ReturnType<typeof vi.fn>).mockClear();
|
|
(svc.updateInstanceConfig as ReturnType<typeof vi.fn>).mockClear();
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
});
|
|
|
|
it('get/update config delegate to the service', () => {
|
|
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never;
|
|
const c = new PluginsController(svc, rt, {} as never);
|
|
expect(c.getConfig('x')).toEqual({ config: { a: 1 } });
|
|
expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } });
|
|
});
|
|
|
|
it('activate spawns via the runtime when enabled', async () => {
|
|
const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never;
|
|
const out = await new PluginsController(svc, rt, {} as never).activate('x');
|
|
expect(out).toEqual({ status: 'active' });
|
|
});
|
|
|
|
it('activate is 503 when the runtime is disabled', async () => {
|
|
process.env.TREK_PLUGINS_ENABLED = 'false';
|
|
const rt = { activate: vi.fn(), isActive: vi.fn() } as never;
|
|
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 });
|
|
});
|
|
|
|
it('activate surfaces an activation error as 400', async () => {
|
|
const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never;
|
|
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it('deactivate stops the plugin (and cascades to dependents)', async () => {
|
|
const deactivateWithDependents = vi.fn(async () => ['x']);
|
|
const rt = { deactivateWithDependents } as never;
|
|
expect(await new PluginsController(svc, rt, {} as never).deactivate('x')).toEqual({ status: 'inactive' });
|
|
expect(deactivateWithDependents).toHaveBeenCalledWith('x');
|
|
});
|
|
});
|
|
|
|
describe('PluginsService instance config', () => {
|
|
it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', () => {
|
|
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run();
|
|
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
|
|
|
|
const svc = new PluginsService();
|
|
const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
|
|
// client gets the masked view
|
|
expect(masked.api_key).toBe('••••••••');
|
|
expect(masked.server).toBe('https://h');
|
|
|
|
// stored value is encrypted, not plaintext
|
|
const stored = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config);
|
|
expect(stored.api_key).not.toBe('super-secret');
|
|
expect(String(stored.api_key)).toMatch(/^enc:/);
|
|
expect(stored.server).toBe('https://h');
|
|
|
|
// an unchanged mask does not overwrite the stored secret
|
|
svc.updateInstanceConfig('x', { api_key: '••••••••' });
|
|
const still = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config);
|
|
expect(still.api_key).toBe(stored.api_key);
|
|
|
|
expect(svc.getInstanceConfig('x').api_key).toBe('••••••••');
|
|
});
|
|
|
|
it('throws for an unknown plugin', () => {
|
|
expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/);
|
|
expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/);
|
|
});
|
|
});
|
|
|
|
describe('PluginsService error log', () => {
|
|
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
|
|
it('lists and clears a plugin error log', () => {
|
|
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
|
|
const svc = new PluginsService();
|
|
expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
|
|
svc.clearErrors('p');
|
|
expect(svc.errors('p')).toEqual([]);
|
|
});
|
|
});
|