Files
TREK/server/tests/unit/plugins/operator-egress.test.ts
T
jubnl b82962f95b Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
2026-09-02 23:06:03 +02:00

191 lines
10 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);
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
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 { createPluginRuntime } from '../../helpers/plugin-host';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy';
import { AddonsService } from '../../../src/nest/addons/addons.service';
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 = createPluginRuntime(new DatabaseService(dbConn));
});
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-009 — an operatorEgress plugin may ship an EMPTY egress[]; anyone else may not', () => {
const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false };
// A self-hosted target (Gotify, ntfy) has no host the author can name at publish time.
const m = parseManifest({ ...base, permissions: ['http:outbound'], operatorEgress: true });
expect(m.egress).toEqual([]);
expect(m.operatorEgress).toBe(true);
// Without the flag an empty egress[] is still refused — this is what stops a plugin
// from asking for outbound while declaring no reach at all.
expect(() => parseManifest({ ...base, permissions: ['http:outbound'] })).toThrow(/egress\[\] is empty/);
});
it('OEG-010 — an operatorEgress plugin with no configured hosts still reaches nothing', async () => {
// It ACTIVATES (it may have useful offline features), but the child's allow-list is the
// union of its http:outbound:<host> grants and the admin's hosts — both empty here.
install('gotify', true, ['http:outbound']);
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
expect(makeHostAllow([])('gotify.mydomain.com')).toBe(false);
});
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, scope: 'user' | 'instance' = 'user') {
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
.run(id, key, key, scope);
}
it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', () => {
install('p', false);
declareAction('p', 'testConnection');
declareAction('p', 'purge', 'instance');
expect(rt.actionsOf('p', 'user')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false, scope: 'user' }]);
expect(rt.actionsOf('p', 'instance')).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
});
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, 'user')).rejects.toThrow(/did not declare action/);
await expect(rt.invokeAction('p', '__proto__', 1, 'user')).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, 'user')).rejects.toThrow(/did not declare action/);
});
it('ACT-004 — a key declared in the OTHER scope is refused (the user route cannot fire an admin button)', async () => {
install('p', false);
declareAction('p', 'purge', 'instance');
declareAction('p', 'testConnection', 'user');
await expect(rt.invokeAction('p', 'purge', 1, 'user')).rejects.toThrow(/did not declare action "purge" in scope user/);
await expect(rt.invokeAction('p', 'testConnection', 1, 'instance')).rejects.toThrow(/did not declare action "testConnection" in scope instance/);
});
});
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);
// list() resolves required-addon dependencies through AddonsService, so it gets a real
// one over the same DB. These fixtures declare no dependencies, so it is never consulted.
const listPlugins = () => {
const dbs = new DatabaseService(dbConn);
return new PluginsService(dbs, new AddonsService(dbs)).list().plugins;
};
const before = listPlugins();
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 = listPlugins();
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
delete process.env.TREK_PLUGINS_ENABLED;
});
});