Files
TREK/plugin-sdk/test/cli.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

212 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { validateManifest } from '../src/index.js';
import { scaffold } from '../src/cli/create.js';
import { PERMISSION_CATALOG, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js';
import { resolveMenuChoice, PRIMARY_MENU, ADVANCED_MENU } from '../src/cli/menu.js';
describe('scaffold egress (http:outbound)', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'egress-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('writes egress so an http:outbound plugin validates', () => {
scaffold('net-plug', 'integration', tmp, { permissions: ['http:outbound'], egress: ['api.example.com'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'net-plug', 'trek-plugin.json'), 'utf8'));
expect(m.egress).toEqual(['api.example.com']);
expect(validateManifest(m).ok).toBe(true);
});
it('without egress the same manifest is invalid — which the wizard now prevents', () => {
scaffold('net-plug', 'integration', tmp, { permissions: ['http:outbound'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'net-plug', 'trek-plugin.json'), 'utf8'));
expect(m.egress).toBeUndefined();
expect(validateManifest(m).ok).toBe(false); // http:outbound requires an egress allow-list
});
it('omits egress entirely when none is given (no empty array noise)', () => {
scaffold('plain-plug', 'integration', tmp, { permissions: ['db:own'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'plain-plug', 'trek-plugin.json'), 'utf8'));
expect('egress' in m).toBe(false);
});
});
describe('permission catalog', () => {
it('covers exactly the known permission ids (none lost in the move to ui.ts)', () => {
expect(KNOWN_PERMISSIONS).toEqual([
'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:write:costs',
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips',
'db:meta',
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:notification-channel', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound',
]);
for (const p of PERMISSION_CATALOG) expect(p.hint.length).toBeGreaterThan(0); // every option is described
});
});
describe('resolveMenuChoice', () => {
it('maps every menu value (commands + control entries) to itself', () => {
for (const item of [...PRIMARY_MENU, ...ADVANCED_MENU]) {
expect(resolveMenuChoice(item.value)).toBe(item.value);
}
});
it('returns undefined for anything not in the menu', () => {
expect(resolveMenuChoice('nope')).toBeUndefined();
expect(resolveMenuChoice('')).toBeUndefined();
});
});
describe('missingArgs', () => {
it('reports the absent required flags, in order', () => {
expect(missingArgs({}, ['repo', 'tag'])).toEqual(['repo', 'tag']);
expect(missingArgs({ repo: 'a/b' }, ['repo', 'tag'])).toEqual(['tag']);
expect(missingArgs({ repo: 'a/b', tag: 'v1.0.0' }, ['repo', 'tag'])).toEqual([]);
});
});
describe('isInteractive', () => {
it('is false when stdin/stdout are not TTYs (CI / pipes — the parity path)', () => {
const inTTY = process.stdin.isTTY;
const outTTY = process.stdout.isTTY;
try {
(process.stdin as { isTTY?: boolean }).isTTY = undefined;
(process.stdout as { isTTY?: boolean }).isTTY = undefined;
expect(isInteractive()).toBe(false);
(process.stdin as { isTTY?: boolean }).isTTY = true;
(process.stdout as { isTTY?: boolean }).isTTY = true;
expect(isInteractive()).toBe(true);
} finally {
(process.stdin as { isTTY?: boolean }).isTTY = inTTY;
(process.stdout as { isTTY?: boolean }).isTTY = outTTY;
}
});
});
describe('scaffold + validate dependencies', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deps-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('scaffolds empty dependency arrays that validate', () => {
scaffold('dep-plug', 'integration', tmp, { permissions: ['db:own'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'dep-plug', 'trek-plugin.json'), 'utf8'));
expect(m.requiredAddons).toEqual([]);
expect(m.pluginDependencies).toEqual([]);
expect(validateManifest(m).ok).toBe(true);
});
it('scaffolds requiredAddons passed as an option', () => {
scaffold('addon-plug', 'integration', tmp, { permissions: ['db:own'], requiredAddons: ['budget'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'addon-plug', 'trek-plugin.json'), 'utf8'));
expect(m.requiredAddons).toEqual(['budget']);
expect(validateManifest(m).ok).toBe(true);
});
});
describe('validateManifest dependency rules', () => {
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
it('accepts valid requiredAddons + pluginDependencies', () => {
const r = validateManifest({ ...base, requiredAddons: ['budget', 'journey'], pluginDependencies: [{ id: 'koffi', version: '>=1.0.0 <2.0.0' }] });
expect(r.ok).toBe(true);
expect(r.manifest?.requiredAddons).toEqual(['budget', 'journey']);
expect(r.manifest?.pluginDependencies).toEqual([{ id: 'koffi', version: '>=1.0.0 <2.0.0' }]);
});
it('rejects a bad addon id, bad dep range, self-dependency, and duplicates', () => {
expect(validateManifest({ ...base, requiredAddons: ['Nope!'] }).ok).toBe(false);
expect(validateManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: 'nope' }] }).ok).toBe(false);
expect(validateManifest({ ...base, pluginDependencies: [{ id: 'my-plug', version: '*' }] }).ok).toBe(false);
expect(validateManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: '*' }, { id: 'koffi', version: '^1' }] }).ok).toBe(false);
});
});
describe('notification-channel template', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chan-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
function make(over: Parameters<typeof scaffold>[3] = {}) {
scaffold('my-gotify', 'integration', tmp, { template: 'notification-channel', ...over });
return JSON.parse(fs.readFileSync(path.join(tmp, 'my-gotify', 'trek-plugin.json'), 'utf8'));
}
it('scaffolds a valid manifest with the hook grant, matching egress, and the capability', () => {
const m = make({ egress: ['gotify.example.com'] });
expect(m.type).toBe('integration'); // a channel is NOT a new plugin type
expect(m.permissions).toContain('hook:notification-channel');
expect(m.permissions).toContain('http:outbound:gotify.example.com');
expect(m.egress).toEqual(['gotify.example.com']);
expect(m.capabilities.notificationChannel.title).toBe('My Gotify');
expect(validateManifest(m).ok).toBe(true);
});
it('declares the per-user credential as a secret, required, user-scoped field', () => {
const m = make();
const token = m.settings.find((s: { key: string }) => s.key === 'appToken');
// These three flags are exactly what makes the host hand it to the hook as `config`.
expect(token.scope).toBe('user');
expect(token.secret).toBe(true);
expect(token.required).toBe(true);
});
it('generates a server entry that implements the hook', () => {
make();
const js = fs.readFileSync(path.join(tmp, 'my-gotify', 'server', 'index.js'), 'utf8');
expect(js).toContain('notificationChannel');
expect(js).toContain('async send(msg, config, ctx)');
expect(js).toContain('config.appToken');
// The invariant is about WHERE ctx.settings.get() may be used, not whether:
// - the HOOK is userless, so it must read credentials from its `config` argument;
// - an ACTION is user-initiated, so it SHOULD read them via ctx.settings.get().
const hookBody = js.slice(js.indexOf('notificationChannel:'));
expect(hookBody).not.toContain('ctx.settings.get');
const actionBody = js.slice(js.indexOf('actions:'), js.indexOf('hooks:'));
expect(actionBody).toContain('await ctx.settings.get');
});
it('scaffolds a Test connection action so the user can verify credentials', () => {
const m = make();
expect(m.actions).toEqual([{ key: 'testConnection', label: 'Test connection' }]);
expect(validateManifest(m).ok).toBe(true);
});
it('rejects a malformed action key', () => {
const m = make();
expect(validateManifest({ ...m, actions: [{ key: '__proto__', label: 'x' }] }).ok).toBe(false);
expect(validateManifest({ ...m, actions: [{ key: 'a', label: 'x' }, { key: 'a', label: 'y' }] }).ok).toBe(false);
});
it('is server-only: no client/ dir, and a UI type is refused', () => {
make();
expect(fs.existsSync(path.join(tmp, 'my-gotify', 'client'))).toBe(false);
expect(() => scaffold('my-widget', 'widget', tmp, { template: 'notification-channel' })).toThrow(/requires type "integration"/);
});
});
describe('capabilities.notificationChannel validation', () => {
const base = {
id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration',
nativeModules: false, permissions: ['hook:notification-channel'],
};
it('accepts a narrowed event list', () => {
const r = validateManifest({ ...base, capabilities: { notificationChannel: { events: ['trip_invite', 'booking_change'] } } });
expect(r.ok).toBe(true);
});
it('rejects an event a plugin channel can never carry', () => {
// version_available is admin-scoped — it goes out over the admin's own credentials.
const r = validateManifest({ ...base, capabilities: { notificationChannel: { events: ['version_available'] } } });
expect(r.ok).toBe(false);
expect(r.errors!.join(' ')).toMatch(/not a plugin-deliverable event/);
});
it('rejects the capability without the matching grant', () => {
const r = validateManifest({ ...base, permissions: ['db:own'], capabilities: { notificationChannel: { title: 'X' } } });
expect(r.ok).toBe(false);
expect(r.errors!.join(' ')).toMatch(/requires the "hook:notification-channel" permission/);
});
});