Files
TREK/server/tests/unit/plugins/operator-egress.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

150 lines
7.6 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.
/**
* Operator-supplied egress hosts.
*
* A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin that
* talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's hostname —
* so a community plugin would serve nobody. An ADMIN adds the hosts post-install and the
* runtime unions them into the child's allow-list at spawn.
*
* The invariants that keep this from becoming an egress bypass:
* - only a plugin that DECLARED operatorEgress may have hosts (install-time consent);
* - hosts are validated like manifest egress (no bare `*`, no scheme, no whole-TLD);
* - it is always the ADMIN, never an end user, who widens it;
* - changing the set RE-SPAWNS the plugin, because the child's guard is install-once.
*/
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 } };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
testDb.prepare(
`INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config, operator_egress)
VALUES (?, ?, 'inactive', 0, '1.0.0', ?, ?, '{}', '{}', ?)`,
).run(id, id, JSON.stringify(perms), JSON.stringify(perms), operatorEgress ? 1 : 0);
}
let rt: PluginRuntimeService;
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeEach(() => {
testDb.prepare('DELETE FROM plugins').run();
testDb.prepare('DELETE FROM plugin_egress_hosts').run();
testDb.prepare('DELETE FROM plugin_actions').run();
rt = new PluginRuntimeService();
});
describe('operator-supplied egress hosts', () => {
it('OEG-001 — an admin can add hosts to a plugin that declared operatorEgress', async () => {
install('gotify', true);
expect(rt.wantsOperatorEgress('gotify')).toBe(true);
expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']);
expect(rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']);
});
it('OEG-002 — a plugin that did NOT declare it can never have hosts added', async () => {
install('sneaky', false);
expect(rt.wantsOperatorEgress('sneaky')).toBe(false);
// This is the load-bearing check: without it an admin could silently widen egress for
// ANY plugin, and the install-time consent would stop bounding what's possible.
await expect(rt.setOperatorEgressHosts('sneaky', ['evil.example.com'])).rejects.toThrow(/did not declare operatorEgress/);
expect(rt.operatorEgressHosts('sneaky')).toEqual([]);
});
it('OEG-003 — hosts are validated exactly like manifest egress', async () => {
install('gotify', true);
for (const bad of ['*', '*.com', 'https://gotify.example.com', 'has space', 'a/b']) {
await expect(rt.setOperatorEgressHosts('gotify', [bad])).rejects.toThrow(/invalid host/);
}
// A legitimate wildcard with a real multi-label suffix is fine.
expect(await rt.setOperatorEgressHosts('gotify', ['*.mydomain.com'])).toEqual(['*.mydomain.com']);
});
it('OEG-004 — hosts are normalized and de-duplicated', async () => {
install('gotify', true);
expect(await rt.setOperatorEgressHosts('gotify', ['Gotify.MyDomain.com', 'gotify.mydomain.com.', ' ', ''])).toEqual([
'gotify.mydomain.com',
]);
});
it('OEG-005 — setting the list replaces it (a removed host is really gone)', async () => {
install('gotify', true);
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
await rt.setOperatorEgressHosts('gotify', ['b.example.com']);
expect(rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']);
});
it('OEG-006 — the manifest rejects operatorEgress without an outbound permission', () => {
const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false };
expect(() => parseManifest({ ...base, permissions: ['db:own'], operatorEgress: true })).toThrow(/requires an http:outbound/);
expect(() => parseManifest({ ...base, permissions: [], operatorEgress: 'yes' })).toThrow(ManifestError);
// …and accepts the real thing.
const m = parseManifest({ ...base, permissions: ['http:outbound:gotify.net'], egress: ['gotify.net'], operatorEgress: true });
expect(m.operatorEgress).toBe(true);
});
it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => {
install('gotify', true);
await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']);
await rt.uninstall('gotify', false);
// A LATER plugin reusing this id must not silently inherit hosts approved for another.
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
});
});
describe('settings-page actions (runtime)', () => {
function declareAction(id: string, key: string) {
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, NULL, 0, 0)')
.run(id, key, key);
}
it('ACT-001 — actionsOf returns the declared descriptors', () => {
install('p', false);
declareAction('p', 'testConnection');
expect(rt.actionsOf('p')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false }]);
});
it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => {
install('p', false);
declareAction('p', 'testConnection');
// The key is caller-supplied (it comes off the URL), so the host must check it
// against the manifest rather than forwarding whatever it is handed to the child.
await expect(rt.invokeAction('p', 'somethingElse', 1)).rejects.toThrow(/did not declare action/);
await expect(rt.invokeAction('p', '__proto__', 1)).rejects.toThrow(/did not declare action/);
});
it('ACT-003 — a plugin with no actions can never be invoked', async () => {
install('p', false);
await expect(rt.invokeAction('p', 'testConnection', 1)).rejects.toThrow(/did not declare action/);
});
});
describe('the admin list surfaces operator egress (so the chip can be shown)', () => {
it('OEG-008 — reports operatorEgress + the host count', async () => {
const { PluginsService } = await import('../../../src/nest/plugins/plugins.service');
process.env.TREK_PLUGINS_ENABLED = 'true';
install('gotify', true);
install('plain', false);
const before = new PluginsService().list().plugins;
expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 });
// A plugin that never asked for it must never invite the admin to add hosts.
expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 });
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
const after = new PluginsService().list().plugins;
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
delete process.env.TREK_PLUGINS_ENABLED;
});
});