mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +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.
57 lines
3.1 KiB
TypeScript
57 lines
3.1 KiB
TypeScript
/**
|
|
* PluginUserSettingsController (#plugins): a user's own scope:'user' settings. Thin —
|
|
* PluginsService holds the storage/masking logic (separately tested); here we prove the
|
|
* gates (runtime off / no user / inactive plugin) and the delegation.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const { pluginsEnabled, getMock } = vi.hoisted(() => ({
|
|
pluginsEnabled: vi.fn(() => true),
|
|
getMock: vi.fn(() => ({ 1: 1 })), // active by default
|
|
}));
|
|
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
|
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
|
|
|
|
import { PluginUserSettingsController } from '../../../src/nest/plugins/plugin-user-settings.controller';
|
|
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
|
import type { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
|
function ctrl() {
|
|
const svc = {
|
|
userSettingsFields: vi.fn(() => [{ key: 'apiKey', secret: true }]),
|
|
getUserConfig: vi.fn(() => ({ apiKey: '••••••••' })),
|
|
updateUserConfig: vi.fn((_id: string, _uid: number, patch: Record<string, unknown>) => ({ ...patch, apiKey: '••••••••' })),
|
|
} as unknown as PluginsService;
|
|
// The controller now also takes the runtime (for settings-page actions).
|
|
const runtime = { actionsOf: () => [], invokeAction: vi.fn() } as unknown as PluginRuntimeService;
|
|
return { c: new PluginUserSettingsController(svc, runtime), svc, runtime };
|
|
}
|
|
|
|
describe('PluginUserSettingsController', () => {
|
|
beforeEach(() => { pluginsEnabled.mockReturnValue(true); getMock.mockReturnValue({ 1: 1 }); });
|
|
|
|
it('GET returns fields + masked config for a bound user; empty when gated', () => {
|
|
const { c } = ctrl();
|
|
expect(c.get('p', req(5))).toEqual({ fields: [{ key: 'apiKey', secret: true }], config: { apiKey: '••••••••' }, actions: [] });
|
|
pluginsEnabled.mockReturnValue(false);
|
|
expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
|
|
pluginsEnabled.mockReturnValue(true);
|
|
expect(c.get('p', req(undefined))).toEqual({ fields: [], config: {}, actions: [] });
|
|
getMock.mockReturnValue(undefined as never);
|
|
expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
|
|
});
|
|
|
|
it('POST delegates the patch for a bound user; empty when gated', () => {
|
|
const { c, svc } = ctrl();
|
|
expect(c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: { units: 'metric', apiKey: '••••••••' } });
|
|
expect(svc.updateUserConfig).toHaveBeenCalledWith('p', 5, { units: 'metric' });
|
|
// a non-object body → empty patch (no throw)
|
|
c.update('p', {}, req(5));
|
|
expect(svc.updateUserConfig).toHaveBeenCalledWith('p', 5, {});
|
|
getMock.mockReturnValue(undefined as never);
|
|
expect(c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: {} });
|
|
});
|
|
});
|