refactor(plugins): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 00:00:53 +02:00
parent e413308227
commit e28dc6de28
68 changed files with 743 additions and 706 deletions
+24 -24
View File
@@ -41,7 +41,7 @@ afterEach(() => {
});
describe('discoverPlugins', () => {
it('registers a new plugin inactive with its settings fields', () => {
it('registers a new plugin inactive with its settings fields', async () => {
writePlugin('flight-tracker', {
name: 'Flight',
type: 'widget',
@@ -52,7 +52,7 @@ describe('discoverPlugins', () => {
{ key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } },
],
});
const res = discoverPlugins(db);
const res = await discoverPlugins(db);
expect(res.discovered).toEqual(['flight-tracker']);
const row = db.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string };
@@ -64,12 +64,12 @@ describe('discoverPlugins', () => {
expect(field).toMatchObject({ field_key: 'api_key', secret: 1 });
});
it('persists each action with its scope', () => {
it('persists each action with its scope', async () => {
writePlugin('acts', {
name: 'Acts', type: 'integration', permissions: [],
actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }],
});
discoverPlugins(db);
await discoverPlugins(db);
const rows = db.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all();
expect(rows).toEqual([
{ action_key: 'ping', scope: 'user', danger: 0 },
@@ -77,39 +77,39 @@ describe('discoverPlugins', () => {
]);
});
it('keeps an existing plugin status + granted permissions on re-discovery', () => {
it('keeps an existing plugin status + granted permissions on re-discovery', async () => {
db.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run();
writePlugin('keep', { name: 'Keep v2', type: 'page', version: '2.0.0' });
discoverPlugins(db);
await discoverPlugins(db);
const row = db.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string };
expect(row.status).toBe('active'); // not downgraded
expect(row.version).toBe('2.0.0'); // metadata refreshed
expect(JSON.parse(row.granted_permissions)).toEqual(['db:own']); // grants preserved
});
it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', () => {
it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', async () => {
writePlugin('bom-plug', { type: 'integration' });
const mp = path.join(codeRoot, 'bom-plug', 'trek-plugin.json');
fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8'));
expect(discoverPlugins(db).discovered).toEqual(['bom-plug']);
expect((await discoverPlugins(db)).discovered).toEqual(['bom-plug']);
});
it('skips an invalid manifest and logs the reason', () => {
it('skips an invalid manifest and logs the reason', async () => {
writePlugin('bad', { type: 'not-a-type' });
const res = discoverPlugins(db);
const res = await discoverPlugins(db);
expect(res.skipped).toEqual(['bad']);
expect(db.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 });
expect((db.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery');
});
it('skips a plugin that ships native binaries', () => {
it('skips a plugin that ships native binaries', async () => {
writePlugin('native', { type: 'integration' }, () => {
fs.writeFileSync(path.join(codeRoot, 'native', 'server', 'addon.node'), '\0');
});
expect(discoverPlugins(db).skipped).toEqual(['native']);
expect((await discoverPlugins(db)).skipped).toEqual(['native']);
});
it('follows a symlinked dev-link plugin only when dev-link mode is on', () => {
it('follows a symlinked dev-link plugin only when dev-link mode is on', async () => {
const srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'disc-src-'));
const prev = process.env.TREK_PLUGINS_DEV_LINK;
try {
@@ -121,12 +121,12 @@ describe('discoverPlugins', () => {
// Off (default): a stale dev-link symlink is not discovered or registered.
delete process.env.TREK_PLUGINS_DEV_LINK;
expect(discoverPlugins(db).discovered).toEqual([]);
expect((await discoverPlugins(db)).discovered).toEqual([]);
expect(db.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toBeUndefined();
// On: the dev-link is followed and registered inactive.
process.env.TREK_PLUGINS_DEV_LINK = '1';
expect(discoverPlugins(db).discovered).toEqual(['linked']);
expect((await discoverPlugins(db)).discovered).toEqual(['linked']);
expect(db.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toMatchObject({ status: 'inactive' });
} finally {
if (prev === undefined) delete process.env.TREK_PLUGINS_DEV_LINK; else process.env.TREK_PLUGINS_DEV_LINK = prev;
@@ -134,34 +134,34 @@ describe('discoverPlugins', () => {
}
});
it('is a no-op when the plugins dir is absent', () => {
it('is a no-op when the plugins dir is absent', async () => {
process.env.TREK_PLUGINS_DIR = path.join(codeRoot, 'does-not-exist');
expect(discoverPlugins(db)).toEqual({ discovered: [], skipped: [] });
expect(await discoverPlugins(db)).toEqual({ discovered: [], skipped: [] });
});
describe('the TREK range', () => {
it('persists the range and its lower bound', () => {
it('persists the range and its lower bound', async () => {
writePlugin('ranged', { trek: '>=3.2.0 <4.0.0' });
discoverPlugins(db);
await discoverPlugins(db);
expect(db.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get())
.toMatchObject({ trek_range: '>=3.2.0 <4.0.0', min_trek_version: '3.2.0' });
});
it('still registers a plugin that declares NO range — it must not vanish', () => {
it('still registers a plugin that declares NO range — it must not vanish', async () => {
// Discovery is a reconciler, not a gate: it only logs and skips on a throw and never
// touches the plugins row, so refusing here would leave an existing plugin's stale
// enabled=1 row to be spawned by the next boot — invisible AND running. The row is
// registered with a null range and the activation gate refuses it (TREK_VERSION_UNKNOWN).
writePlugin('rangeless', {});
expect(discoverPlugins(db).discovered).toEqual(['rangeless']);
expect((await discoverPlugins(db)).discovered).toEqual(['rangeless']);
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null });
});
it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', () => {
it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', async () => {
writePlugin('shrink', { trek: '>=3.0.0' });
discoverPlugins(db);
await discoverPlugins(db);
writePlugin('shrink', { trek: '>=3.0.0 <3.1.0' });
discoverPlugins(db);
await discoverPlugins(db);
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' });
});
});
+2 -2
View File
@@ -103,7 +103,7 @@ const registry = createTestPluginRegistry([
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards),
]);
const factory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} };
const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter);
/**
* A response read loosely, on purpose.
@@ -224,7 +224,7 @@ describe('DbRpc — the unconditional three', () => {
calls.push({ callerId, targetId, fn, args, uid });
return { echoed: true };
},
emitPluginEvent: (sourceId, event, payload) => {
emitPluginEvent: async (sourceId, event, payload) => {
emits.push({ sourceId, event, payload });
},
};
@@ -52,18 +52,18 @@ beforeEach(async () => {
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.wantsOperatorEgress('gotify')).toBe(true);
expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']);
expect(rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']);
expect(await 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);
expect(await 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([]);
expect(await rt.operatorEgressHosts('sneaky')).toEqual([]);
});
it('OEG-003 — hosts are validated exactly like manifest egress', async () => {
@@ -86,7 +86,7 @@ describe('operator-supplied egress hosts', () => {
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']);
expect(await rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']);
});
it('OEG-006 — the manifest rejects operatorEgress without an outbound permission', () => {
@@ -113,7 +113,7 @@ describe('operator-supplied egress hosts', () => {
// 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(await rt.operatorEgressHosts('gotify')).toEqual([]);
expect(makeHostAllow([])('gotify.mydomain.com')).toBe(false);
});
@@ -122,7 +122,7 @@ describe('operator-supplied egress hosts', () => {
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([]);
expect(await rt.operatorEgressHosts('gotify')).toEqual([]);
});
});
@@ -132,12 +132,12 @@ describe('settings-page actions (runtime)', () => {
.run(id, key, key, scope);
}
it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', () => {
it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', async () => {
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' }]);
expect(await rt.actionsOf('p', 'user')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false, scope: 'user' }]);
expect(await 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 () => {
+23 -23
View File
@@ -44,9 +44,9 @@ describe('appendAudit hash chain', () => {
let db: ReturnType<typeof makeDb>;
beforeEach(() => { db = makeDb(); });
it('chains each entry off the previous hash (per plugin)', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' });
it('chains each entry off the previous hash (per plugin)', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' });
const rows = db.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>;
expect(rows).toHaveLength(2);
expect(rows[0].prev_hash).toBeNull();
@@ -54,53 +54,53 @@ describe('appendAudit hash chain', () => {
expect(rows[1].hash).not.toBe(rows[0].hash);
});
it('keeps separate chains per plugin', () => {
appendAudit(db, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
it('keeps separate chains per plugin', async () => {
await appendAudit(db, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const a = db.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null };
expect(a.prev_hash).toBeNull(); // b's first entry doesn't chain off a's
});
it('records denials too (code is the error code)', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' });
const row = readAudit(db, 'p')[0] as { code: string; method: string };
it('records denials too (code is the error code)', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' });
const row = (await readAudit(db, 'p'))[0] as { code: string; method: string };
expect(row.code).toBe('RESOURCE_FORBIDDEN');
});
it('readAuditForUser returns one user\'s actions across ALL plugins, newest first, with the plugin name', () => {
it('readAuditForUser returns one user\'s actions across ALL plugins, newest first, with the plugin name', async () => {
db.exec("CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT)");
db.prepare("INSERT INTO plugins (id, name) VALUES ('koffi','Koffi'), ('flight','Flight Tracker')").run();
appendAudit(db, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user
const mine = readAuditForUser(db, 42) as Array<{ plugin_id: string; plugin_name: string; method: string }>;
await appendAudit(db, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user
const mine = (await readAuditForUser(db, 42)) as Array<{ plugin_id: string; plugin_name: string; method: string }>;
expect(mine).toHaveLength(2); // only user 42's rows, across both plugins
expect(mine[0]).toMatchObject({ plugin_id: 'flight', plugin_name: 'Flight Tracker', method: 'reservations.create' }); // newest first
expect(mine[1]).toMatchObject({ plugin_id: 'koffi', plugin_name: 'Koffi' });
expect(mine.some((r) => r.plugin_id === 'koffi' && r.method === 'trips.getById' && (r as { resource?: string }).resource === 'trip:2')).toBe(false); // never another user's
});
it('readAudit returns newest first with the projected fields only', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const rows = readAudit(db, 'p') as Array<Record<string, unknown>>;
it('readAudit returns newest first with the projected fields only', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const rows = (await readAudit(db, 'p')) as Array<Record<string, unknown>>;
expect(rows[0]).toHaveProperty('method', 'trips.getById');
expect(rows[0]).not.toHaveProperty('prev_hash'); // internal chain fields not exposed
});
it('pruneAudit keeps only the newest N rows per plugin, leaving the retained window chain-consistent', () => {
for (let i = 0; i < 50; i++) appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' });
appendAudit(db, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' });
pruneAudit(db, 'p', 10);
it('pruneAudit keeps only the newest N rows per plugin, leaving the retained window chain-consistent', async () => {
for (let i = 0; i < 50; i++) await appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' });
await appendAudit(db, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' });
await pruneAudit(db, 'p', 10);
const rows = db.prepare("SELECT resource, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = 'p' ORDER BY id ASC").all() as Array<{ resource: string; prev_hash: string | null; hash: string }>;
expect(rows).toHaveLength(10);
expect(rows[rows.length - 1].resource).toBe('trip:49'); // newest kept
// each retained row is still self-consistent: hash === sha256(prev_hash + row-content)
// (proven by re-appending on top — the chain continues from the surviving tip)
appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' });
await appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' });
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 });
// pruning one plugin never touches another's rows
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='other'").get()).toMatchObject({ c: 1 });
expect(() => pruneAudit(db, 'p', 0)).not.toThrow(); // 0 = disabled, no-op
await expect(pruneAudit(db, 'p', 0)).resolves.toBeUndefined(); // 0 = disabled, no-op
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 });
});
});
+30 -30
View File
@@ -70,15 +70,15 @@ const req = (p: string, host?: string) => ({ params: { path: p }, get: (h: strin
function runtime(active = true, hosts: string[] = [], operatorHosts: string[] = []): PluginRuntimeService {
return {
isActive: vi.fn(() => active),
outboundHostsOf: vi.fn(() => hosts),
operatorEgressHosts: vi.fn(() => operatorHosts),
outboundHostsOf: vi.fn(async () => hosts),
operatorEgressHosts: vi.fn(async () => operatorHosts),
} as unknown as PluginRuntimeService;
}
describe('PluginFrameController', () => {
it('serves index.html with the opaque-frame CSP + sandbox', () => {
it('serves index.html with the opaque-frame CSP + sandbox', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['api.weather.com'])).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true, ['api.weather.com'])).serve('widget', req(''), res as never);
expect(res.filePath).toContain(path.join('widget', 'client', 'index.html'));
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("default-src 'none'");
@@ -91,53 +91,53 @@ describe('PluginFrameController', () => {
// Under the Nest ExpressAdapter, res.sendFile(absolutePath) resolves against the
// rewritten req.url and 404s spuriously — files must go out root-relative.
it('serves files with an explicit root, not an absolute path', () => {
it('serves files with an explicit root, not an absolute path', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.fileRoot).toBe(path.join(codeRoot, 'widget', 'client'));
});
// The frame document runs at an opaque origin, so its own <script src> loads are
// cross-origin fetches; helmet's CORP: same-origin makes the browser drop them
// and a multi-file client dies on boot.
it('marks responses cross-origin loadable for the opaque frame', () => {
it('marks responses cross-origin loadable for the opaque frame', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.headers['Cross-Origin-Resource-Policy']).toBe('cross-origin');
});
// An operatorEgress plugin's hosts are supplied by the ADMIN after install, so they are not
// in the manifest grants. activate() unions them into the child's egress guard; the frame
// must match, or a plugin with a UI can call the host from its server but not its iframe.
it('includes admin-supplied operatorEgress hosts in connect-src', () => {
it('includes admin-supplied operatorEgress hosts in connect-src', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['api.weather.com'], ['gotify.home.lan'])).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true, ['api.weather.com'], ['gotify.home.lan'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain('https://api.weather.com');
expect(csp).toContain('https://gotify.home.lan');
});
it('does not duplicate a host granted in the manifest AND added by the admin', () => {
it('does not duplicate a host granted in the manifest AND added by the admin', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['ntfy.sh'], ['ntfy.sh'])).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true, ['ntfy.sh'], ['ntfy.sh'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp.match(/https:\/\/ntfy\.sh/g)).toHaveLength(1);
});
it('refuses to interpolate a malformed operator host into the CSP', () => {
it('refuses to interpolate a malformed operator host into the CSP', async () => {
const res = fakeRes();
// The admin writer validates hosts, but connect-src is the last line of defence: a token
// with a space or a bare * would widen the whole policy.
new PluginFrameController(runtime(true, [], ['evil.com https://*', '*'])).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true, [], ['evil.com https://*', '*'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("connect-src 'self'");
expect(csp).not.toContain('evil.com');
expect(csp).not.toContain('*');
});
it('allows the plugin its own static assets when the Host header is clean', () => {
it('allows the plugin its own static assets when the Host header is clean', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('', 'trek.example.com:8443'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('', 'trek.example.com:8443'), res as never);
const csp = res.headers['Content-Security-Policy'];
// Scheme-less on purpose: matches the frame's own http AND https documents.
expect(csp).toContain("script-src 'self' 'unsafe-inline' trek.example.com:8443/plugin-frame/widget/");
@@ -145,54 +145,54 @@ describe('PluginFrameController', () => {
expect(csp).toContain("connect-src 'self' trek.example.com:8443/plugin-frame/widget/");
});
it('drops the own-assets source on a malformed Host header instead of widening the policy', () => {
it('drops the own-assets source on a malformed Host header instead of widening the policy', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('', 'evil.com *'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('', 'evil.com *'), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("script-src 'self' 'unsafe-inline';");
expect(csp).not.toContain('evil.com');
});
it('404 when the plugin is inactive', () => {
it('404 when the plugin is inactive', async () => {
const res = fakeRes();
new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
expect(res.statusCode).toBe(404);
});
it('404 when the runtime is disabled', () => {
it('404 when the runtime is disabled', async () => {
pluginsEnabledMock.mockReturnValueOnce(false);
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.statusCode).toBe(404);
});
it('403 on a path-traversal attempt', () => {
it('403 on a path-traversal attempt', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
expect(res.statusCode).toBe(403);
});
it('404 for a missing file', () => {
it('404 for a missing file', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
expect(res.statusCode).toBe(404);
});
// The lexical guard above cannot see through a link, and statSync follows one.
it('403 on a symlink inside client/ that points out of the plugin', () => {
it('403 on a symlink inside client/ that points out of the plugin', async () => {
if (!symlinksAvailable) return;
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('esc/leak.js'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('esc/leak.js'), res as never);
expect(res.statusCode).toBe(403);
expect(res.filePath).toBeUndefined();
});
// Dev-link mode: the plugin directory itself is a link, so the containment
// check has to resolve BOTH sides or local development stops working.
it('still serves a dev-linked plugin whose whole tree is a symlink', () => {
it('still serves a dev-linked plugin whose whole tree is a symlink', async () => {
if (!symlinksAvailable) return;
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('linked', req(''), res as never);
await new PluginFrameController(runtime(true)).serve('linked', req(''), res as never);
expect(res.statusCode).toBe(200);
expect(res.filePath).toContain('index.html');
});
+10 -10
View File
@@ -47,39 +47,39 @@ function build(overrides: { role?: string | undefined; allow?: boolean; addonOn?
}
describe('PluginGuards — tripRead', () => {
it('PGUARD-001 runs the read for a member and hands it the acting user', () => {
it('PGUARD-001 runs the read for a member and hands it the acting user', async () => {
const { guards } = build();
const read = vi.fn((userId: number) => ({ seenBy: userId }));
expect(guards.tripRead({ tripId: 1 }, ctx(42), read)).toEqual({ seenBy: 42 });
expect(await guards.tripRead({ tripId: 1 }, ctx(42), read)).toEqual({ seenBy: 42 });
expect(read).toHaveBeenCalledWith(42);
});
it('PGUARD-002 a userless context is refused before the read runs', () => {
it('PGUARD-002 a userless context is refused before the read runs', async () => {
const { guards } = build();
const read = vi.fn();
expect(() => guards.tripRead({ tripId: 1 }, ctx(undefined), read)).toThrow(
await expect(guards.tripRead({ tripId: 1 }, ctx(undefined), read)).rejects.toThrow(
new ForbiddenResource('trip reads require an authenticated user context'),
);
expect(read).not.toHaveBeenCalled();
});
it('PGUARD-003 a non-member is refused, naming the trip', () => {
it('PGUARD-003 a non-member is refused, naming the trip', async () => {
const { guards } = build();
const read = vi.fn();
expect(() => guards.tripRead({ tripId: 2 }, ctx(42), read)).toThrow(
await expect(guards.tripRead({ tripId: 2 }, ctx(42), read)).rejects.toThrow(
new ForbiddenResource('no access to trip 2'),
);
expect(read).not.toHaveBeenCalled();
});
it('PGUARD-004 a missing tripId is BAD_PARAMS, not a refusal', () => {
it('PGUARD-004 a missing tripId is BAD_PARAMS, not a refusal', async () => {
const { guards } = build();
expect(() => guards.tripRead({}, ctx(42), vi.fn())).toThrow(new BadParams('tripId must be a number'));
await expect(guards.tripRead({}, ctx(42), vi.fn())).rejects.toThrow(new BadParams('tripId must be a number'));
});
it('PGUARD-005 a numeric string tripId is accepted, as the wire contract allows', () => {
it('PGUARD-005 a numeric string tripId is accepted, as the wire contract allows', async () => {
const { guards } = build();
expect(guards.tripRead({ tripId: '1' }, ctx(42), (u) => u)).toBe(42);
expect(await guards.tripRead({ tripId: '1' }, ctx(42), (u) => u)).toBe(42);
});
});
@@ -63,7 +63,7 @@ const svc = () => {
* `parseSettingDefault` in manifest.ts, not just the settingsFields() read path.
*/
let codeRoot: string;
function installFixturePlugin(opts: { settings: Array<Record<string, unknown>> }) {
async function installFixturePlugin(opts: { settings: Array<Record<string, unknown>> }) {
const dir = path.join(codeRoot, 'fixture-id', 'server');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
@@ -71,7 +71,7 @@ function installFixturePlugin(opts: { settings: Array<Record<string, unknown>> }
JSON.stringify({ id: 'fixture-id', name: 'Fixture', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0', settings: opts.settings }),
);
fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports={}');
discoverPlugins(testDb);
await discoverPlugins(testDb);
}
beforeAll(() => {
@@ -91,22 +91,22 @@ afterEach(() => {
});
describe('instance settings fields', () => {
it('INS-001 — lists only the instance-scope fields, in declared order, with form metadata', () => {
it('INS-001 — lists only the instance-scope fields, in declared order, with form metadata', async () => {
install('p');
declareField('p', 'apiUrl', 'instance', { required: true, sortOrder: 1 });
declareField('p', 'apiKey', 'instance', { secret: true, sortOrder: 0 });
declareField('p', 'units', 'user', { sortOrder: 2 }); // must never leak into the admin form
const fields = svc().instanceSettingsFields('p');
const fields = await svc().instanceSettingsFields('p');
expect(fields.map((f) => f.key)).toEqual(['apiKey', 'apiUrl']);
expect(fields[0]).toMatchObject({ secret: true, required: false, input_type: 'text' });
expect(fields[1]).toMatchObject({ secret: false, required: true });
});
it('INS-002 — parses select options like the user form does', () => {
it('INS-002 — parses select options like the user form does', async () => {
install('p');
declareField('p', 'mode', 'instance', { options: '["fast","slow"]' });
expect(svc().instanceSettingsFields('p')[0].options).toEqual(['fast', 'slow']);
expect((await svc().instanceSettingsFields('p'))[0].options).toEqual(['fast', 'slow']);
});
it('INS-003 — the admin list carries the instance-field count (gates the menu item)', async () => {
@@ -137,14 +137,14 @@ describe('instance settings fields', () => {
testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id = 'with-action'").run();
});
it('INS-010 — persists a settings-field default and serves it on the fields list', () => {
installFixturePlugin({ settings: [{ key: 'oauth_authorize_url', required: true, default: 'https://auth.openbnb.org/authorize' }] });
const fields = svc().instanceSettingsFields('fixture-id');
it('INS-010 — persists a settings-field default and serves it on the fields list', async () => {
await installFixturePlugin({ settings: [{ key: 'oauth_authorize_url', required: true, default: 'https://auth.openbnb.org/authorize' }] });
const fields = await svc().instanceSettingsFields('fixture-id');
expect(fields[0].default).toBe('https://auth.openbnb.org/authorize');
});
it('INS-013 — drops a default the field cannot take: non-boolean on a checkbox, or not one of the select options', () => {
installFixturePlugin({
it('INS-013 — drops a default the field cannot take: non-boolean on a checkbox, or not one of the select options', async () => {
await installFixturePlugin({
settings: [
{ key: 'on', input_type: 'checkbox', default: 'true' },
{ key: 'mode', input_type: 'select', options: ['fast', 'slow'], default: 'warp' },
@@ -152,60 +152,60 @@ describe('instance settings fields', () => {
{ key: 'on_ok', input_type: 'checkbox', default: true },
],
});
const byKey = Object.fromEntries(svc().instanceSettingsFields('fixture-id').map((f) => [f.key, f.default]));
const byKey = Object.fromEntries((await svc().instanceSettingsFields('fixture-id')).map((f) => [f.key, f.default]));
expect(byKey).toEqual({ on: undefined, mode: undefined, mode_ok: 'a', on_ok: true });
});
it('INS-011 — drops a default on a secret field at parse time', () => {
installFixturePlugin({ settings: [{ key: 'token', secret: true, default: 'leak' }] });
expect(svc().instanceSettingsFields('fixture-id')[0].default).toBeUndefined();
it('INS-011 — drops a default on a secret field at parse time', async () => {
await installFixturePlugin({ settings: [{ key: 'token', secret: true, default: 'leak' }] });
expect((await svc().instanceSettingsFields('fixture-id'))[0].default).toBeUndefined();
});
});
describe('required settings are enforced on save', () => {
it('refuses a save that leaves a required instance field empty', () => {
installFixturePlugin({ settings: [{ key: 'client_id', required: true }] });
expect(() => svc().updateInstanceConfig('fixture-id', { client_id: ' ' })).toThrow(/Missing required setting "client_id"/);
it('refuses a save that leaves a required instance field empty', async () => {
await installFixturePlugin({ settings: [{ key: 'client_id', required: true }] });
await expect(svc().updateInstanceConfig('fixture-id', { client_id: ' ' })).rejects.toThrow(/Missing required setting "client_id"/);
});
it('accepts a partial patch when the required field is already stored', () => {
installFixturePlugin({ settings: [{ key: 'client_id', required: true }, { key: 'note', required: false }] });
it('accepts a partial patch when the required field is already stored', async () => {
await installFixturePlugin({ settings: [{ key: 'client_id', required: true }, { key: 'note', required: false }] });
const s = svc();
s.updateInstanceConfig('fixture-id', { client_id: 'abc' });
expect(() => s.updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow();
await s.updateInstanceConfig('fixture-id', { client_id: 'abc' });
await expect(s.updateInstanceConfig('fixture-id', { note: 'hi' })).resolves.toBeDefined();
});
it('accepts a user-scope partial patch when the required user field is already stored', () => {
installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }, { key: 'units', scope: 'user' }] });
it('accepts a user-scope partial patch when the required user field is already stored', async () => {
await installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }, { key: 'units', scope: 'user' }] });
const s = svc();
s.updateUserConfig('fixture-id', 1, { api_key: 'sk-1' });
expect(() => s.updateUserConfig('fixture-id', 1, { units: 'metric' })).not.toThrow();
await s.updateUserConfig('fixture-id', 1, { api_key: 'sk-1' });
await expect(s.updateUserConfig('fixture-id', 1, { units: 'metric' })).resolves.toBeDefined();
});
it('refuses a user-settings save that leaves a required user field empty', () => {
installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }] });
expect(() => svc().updateUserConfig('fixture-id', 1, { api_key: '' })).toThrow(/Missing required setting "api_key"/);
it('refuses a user-settings save that leaves a required user field empty', async () => {
await installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }] });
await expect(svc().updateUserConfig('fixture-id', 1, { api_key: '' })).rejects.toThrow(/Missing required setting "api_key"/);
});
it('exempts checkbox fields from required enforcement (consent, not a settings field)', () => {
installFixturePlugin({ settings: [{ key: 'accept_terms', input_type: 'checkbox', required: true }, { key: 'note', required: false }] });
it('exempts checkbox fields from required enforcement (consent, not a settings field)', async () => {
await installFixturePlugin({ settings: [{ key: 'accept_terms', input_type: 'checkbox', required: true }, { key: 'note', required: false }] });
// accept_terms is left entirely unset (never patched, nothing stored) — a
// non-checkbox required field in this state would throw.
expect(() => svc().updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow();
await expect(svc().updateInstanceConfig('fixture-id', { note: 'hi' })).resolves.toBeDefined();
});
it('a required field with a manifest default is satisfied by the default', () => {
installFixturePlugin({ settings: [{ key: 'region', required: true, default: 'eu' }, { key: 'note', required: false }] });
it('a required field with a manifest default is satisfied by the default', async () => {
await installFixturePlugin({ settings: [{ key: 'region', required: true, default: 'eu' }, { key: 'note', required: false }] });
// region is never patched and nothing is stored — the runtime will see the default,
// so refusing the save here would contradict what the child actually gets.
expect(() => svc().updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow();
await expect(svc().updateInstanceConfig('fixture-id', { note: 'hi' })).resolves.toBeDefined();
});
it('a stored secret (non-empty ciphertext) counts as filled on a later partial patch', () => {
installFixturePlugin({ settings: [{ key: 'api_key', secret: true, required: true }, { key: 'note', required: false }] });
it('a stored secret (non-empty ciphertext) counts as filled on a later partial patch', async () => {
await installFixturePlugin({ settings: [{ key: 'api_key', secret: true, required: true }, { key: 'note', required: false }] });
const s = svc();
s.updateInstanceConfig('fixture-id', { api_key: 'sk-real' });
expect(() => s.updateInstanceConfig('fixture-id', { api_key: '••••••••', note: 'hi' })).not.toThrow();
await s.updateInstanceConfig('fixture-id', { api_key: 'sk-real' });
await expect(s.updateInstanceConfig('fixture-id', { api_key: '••••••••', note: 'hi' })).resolves.toBeDefined();
});
});
@@ -239,12 +239,12 @@ describe('admin config endpoints (controller)', () => {
{ isManaged: () => false } as unknown as RuntimeEnvService,
);
it('INS-006 — GET :id/config returns the fields alongside the (masked) values', () => {
it('INS-006 — GET :id/config returns the fields alongside the (masked) values', async () => {
install('p');
declareField('p', 'apiUrl', 'instance');
testDb.prepare("UPDATE plugins SET config = '{\"apiUrl\":\"https://x.example\"}' WHERE id = 'p'").run();
const out = controllerWith({ actionsOf: () => [] }).getConfig('p');
const out = await controllerWith({ actionsOf: async () => [] }).getConfig('p');
expect(out.config).toEqual({ apiUrl: 'https://x.example' });
expect(out.fields.map((f: Record<string, unknown>) => f.key)).toEqual(['apiUrl']);
});
@@ -292,7 +292,7 @@ describe('admin config endpoints (controller)', () => {
expect(failed?.getStatus()).toBe(503);
// The respawn is a spawn: it must not run while the whole plugin system is off.
expect(respawnIfActive).not.toHaveBeenCalled();
expect(svc().getInstanceConfig('p')).toEqual({});
expect(await svc().getInstanceConfig('p')).toEqual({});
});
it('INS-011: a respawn that fails reports the save that DID happen, and stops claiming the plugin runs', async () => {
@@ -315,7 +315,7 @@ describe('admin config endpoints (controller)', () => {
config: { apiUrl: 'https://y.example' },
error: expect.stringMatching(/Settings saved.*db:read:trips/),
});
expect(svc().getInstanceConfig('p')).toEqual({ apiUrl: 'https://y.example' });
expect(await svc().getInstanceConfig('p')).toEqual({ apiUrl: 'https://y.example' });
// disable() leaves the row enabled, so the admin list would keep showing a plugin
// that no longer has a child. The enable toggle is where the reason is offered.
expect(deactivate).toHaveBeenCalledWith('p');
@@ -324,7 +324,7 @@ describe('admin config endpoints (controller)', () => {
describe('defaults reach the child at spawn', () => {
it('INS-012 — an unset instance field is handed to the child as its manifest default; a stored value wins', async () => {
installFixturePlugin({
await installFixturePlugin({
settings: [
{ key: 'api_url', default: 'https://api.example' },
{ key: 'retries', input_type: 'number', default: 3 },
@@ -332,7 +332,7 @@ describe('defaults reach the child at spawn', () => {
],
});
const s = svc();
s.updateInstanceConfig('fixture-id', { api_url: 'https://mine.example' });
await s.updateInstanceConfig('fixture-id', { api_url: 'https://mine.example' });
const rt = await createPluginRuntime(new DatabaseService(dbConn));
const sup = (rt as unknown as { supervisor: { activate: (...a: unknown[]) => Promise<void> } }).supervisor;
@@ -380,7 +380,7 @@ describe('instance-scope actions (admin)', () => {
declareAction('p', 'purge', 'instance');
declareAction('p', 'testConnection', 'user');
const { c } = await controller();
expect(c.getConfig('p').actions).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
expect((await c.getConfig('p')).actions).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
});
it('ACT-ADM-002 — POST runs the action as the clicking admin in the instance scope', async () => {
@@ -61,9 +61,9 @@ afterEach(() => {
});
describe('the advertised surface', () => {
it('MCPTOOLS-001: advertises a declared and implemented tool under the prefixed name', () => {
it('MCPTOOLS-001: advertises a declared and implemented tool under the prefixed name', async () => {
const { svc } = makeService();
const tools = svc.mcpTools(ctx);
const tools = await svc.mcpTools(ctx);
expect(tools).toHaveLength(1);
expect(tools[0].options.name).toBe('plugin_weather_forecast');
@@ -71,32 +71,32 @@ describe('the advertised surface', () => {
expect(tools[0].options.access).toEqual({ group: 'plugins', mode: 'use' });
});
it('MCPTOOLS-002: drops a tool the manifest declares but the build does not implement', () => {
it('MCPTOOLS-002: drops a tool the manifest declares but the build does not implement', async () => {
const { svc } = makeService({ implemented: { weather: [] } });
expect(svc.mcpTools(ctx)).toEqual([]);
expect(await svc.mcpTools(ctx)).toEqual([]);
});
it('MCPTOOLS-003: drops a tool the build reports but the manifest never declared', () => {
it('MCPTOOLS-003: drops a tool the build reports but the manifest never declared', async () => {
// The manifest is signed and re-consented; the loaded report is neither.
const { svc } = makeService({ declared: { weather: [] }, implemented: { weather: ['sneaky'] } });
expect(svc.mcpTools(ctx)).toEqual([]);
expect(await svc.mcpTools(ctx)).toEqual([]);
});
it('MCPTOOLS-004: a plugin without the grant never reaches the surface', () => {
it('MCPTOOLS-004: a plugin without the grant never reaches the surface', async () => {
// providersOf already filters on active AND reported AND granted.
const { svc, hooks } = makeService({ providers: [] });
expect(svc.mcpTools(ctx)).toEqual([]);
expect(await svc.mcpTools(ctx)).toEqual([]);
expect(hooks.providersOf).toHaveBeenCalledWith('mcpToolProvider');
});
it('MCPTOOLS-005: the kill switch empties the surface', () => {
it('MCPTOOLS-005: the kill switch empties the surface', async () => {
pluginsOn = false;
const { svc, hooks } = makeService();
expect(svc.mcpTools(ctx)).toEqual([]);
expect(await svc.mcpTools(ctx)).toEqual([]);
expect(hooks.providersOf).not.toHaveBeenCalled();
});
it('MCPTOOLS-006: one failing plugin costs only its own tools', () => {
it('MCPTOOLS-006: one failing plugin costs only its own tools', async () => {
const { svc } = makeService({
providers: ['broken', 'weather'],
declared: {
@@ -106,20 +106,20 @@ describe('the advertised surface', () => {
implemented: { weather: ['forecast'], broken: ['x'] },
});
const tools = svc.mcpTools(ctx);
const tools = await svc.mcpTools(ctx);
expect(tools.map((t) => t.options.name)).toEqual(['plugin_weather_forecast']);
});
it('MCPTOOLS-007: caps the tools one plugin may contribute', () => {
it('MCPTOOLS-007: caps the tools one plugin may contribute', async () => {
const many = Array.from({ length: 12 }, (_, i) => ({ ...weatherTool, name: `t${i}` }));
const { svc } = makeService({
declared: { weather: many },
implemented: { weather: many.map((t) => t.name) },
});
expect(svc.mcpTools(ctx)).toHaveLength(8);
expect(await svc.mcpTools(ctx)).toHaveLength(8);
});
it('MCPTOOLS-008: caps the whole surface and says so rather than truncating silently', () => {
it('MCPTOOLS-008: caps the whole surface and says so rather than truncating silently', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const eight = Array.from({ length: 8 }, (_, i) => ({ ...weatherTool, name: `t${i}` }));
const providers = ['p1', 'p2', 'p3', 'p4', 'p5'];
@@ -131,22 +131,22 @@ describe('the advertised surface', () => {
}
const { svc } = makeService({ providers, declared, implemented });
expect(svc.mcpTools(ctx)).toHaveLength(32);
expect(await svc.mcpTools(ctx)).toHaveLength(32);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dropped'));
});
it('MCPTOOLS-009: clamps annotations against the grants the plugin actually holds', () => {
it('MCPTOOLS-009: clamps annotations against the grants the plugin actually holds', async () => {
const claiming = [{ ...weatherTool, annotations: { readOnlyHint: true } }];
const { svc } = makeService({
declared: { weather: claiming },
grants: { weather: ['db:write:trips'] },
});
expect(svc.mcpTools(ctx)[0].options.annotations).toMatchObject({ readOnlyHint: false });
expect((await svc.mcpTools(ctx))[0].options.annotations).toMatchObject({ readOnlyHint: false });
});
it('MCPTOOLS-010: never advertises _meta or an outputSchema', () => {
it('MCPTOOLS-010: never advertises _meta or an outputSchema', async () => {
const { svc } = makeService({ declared: { weather: [{ ...weatherTool, _meta: { x: 1 } }] } });
const options = svc.mcpTools(ctx)[0].options;
const options = (await svc.mcpTools(ctx))[0].options;
expect(options._meta).toBeUndefined();
expect(options.outputSchema).toBeUndefined();
});
@@ -154,7 +154,7 @@ describe('the advertised surface', () => {
describe('invoking a tool', () => {
const invoke = async (svc: PluginMcpToolsService, args: unknown = { city: 'Lisbon' }) => {
const tool = svc.mcpTools(ctx)[0];
const tool = (await svc.mcpTools(ctx))[0];
return (await tool.handler(args, ctx)) as { content: Array<{ text: string }>; isError?: boolean };
};
@@ -191,7 +191,7 @@ describe('invoking a tool', () => {
it('MCPTOOLS-015: refuses once the kill switch goes off mid-session', async () => {
const { svc, callTool } = makeService();
const tool = svc.mcpTools(ctx)[0];
const tool = (await svc.mcpTools(ctx))[0];
pluginsOn = false;
const res = (await tool.handler({}, ctx)) as { isError?: boolean };
expect(res.isError).toBe(true);
@@ -200,29 +200,29 @@ describe('invoking a tool', () => {
});
describe('toMcpTextResult', () => {
it('MCPTOOLS-016: preserves a well-formed result, isError included', () => {
it('MCPTOOLS-016: preserves a well-formed result, isError included', async () => {
const given = { content: [{ type: 'text', text: 'hi' }], isError: true };
expect(toMcpTextResult(given)).toEqual(given);
});
it('MCPTOOLS-017: wraps a bare string', () => {
it('MCPTOOLS-017: wraps a bare string', async () => {
expect(toMcpTextResult('hello')).toEqual({ content: [{ type: 'text', text: 'hello' }] });
});
it('MCPTOOLS-018: survives a value that cannot be serialised', () => {
it('MCPTOOLS-018: survives a value that cannot be serialised', async () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const res = toMcpTextResult(cyclic) as { isError?: boolean };
expect(res.isError).toBe(true);
});
it('MCPTOOLS-019: caps a flood so a plugin cannot fill the assistant context', () => {
it('MCPTOOLS-019: caps a flood so a plugin cannot fill the assistant context', async () => {
const huge = 'x'.repeat(200_000);
const res = toMcpTextResult(huge);
expect(res.content[0].text.length).toBeLessThanOrEqual(64 * 1024);
});
it('MCPTOOLS-020: the budget covers the WHOLE result, not each block', () => {
it('MCPTOOLS-020: the budget covers the WHOLE result, not each block', async () => {
// Per-block slicing is not a limit: 100 blocks of the maximum size is 100
// times the maximum. This shape produced 6.5M characters before the fix.
const many = { content: Array.from({ length: 100 }, () => ({ type: 'text', text: 'x'.repeat(200_000) })) };
@@ -231,14 +231,14 @@ describe('toMcpTextResult', () => {
expect(total).toBeLessThan(70 * 1024);
});
it('MCPTOOLS-021: caps the number of content blocks and says it truncated', () => {
it('MCPTOOLS-021: caps the number of content blocks and says it truncated', async () => {
const many = { content: Array.from({ length: 500 }, (_, i) => ({ type: 'text', text: `b${i}` })) };
const res = toMcpTextResult(many);
expect(res.content.length).toBeLessThanOrEqual(33);
expect(res.content[res.content.length - 1].text).toContain('truncated');
});
it('MCPTOOLS-022: bounds a huge object without serialising it whole first', () => {
it('MCPTOOLS-022: bounds a huge object without serialising it whole first', async () => {
const fat = { rows: Array.from({ length: 20_000 }, (_, i) => ({ i, blob: 'y'.repeat(500) })) };
const res = toMcpTextResult(fat);
const total = res.content.reduce((n, c) => n + c.text.length, 0);
@@ -26,10 +26,10 @@ function fakeRes() {
}
function ctrl(over: Partial<PluginOAuthService> = {}) {
const svc = {
status: vi.fn(() => ({ configured: true, connected: false })),
startConnect: vi.fn(() => 'https://provider.example/auth?state=s'),
status: vi.fn(async () => ({ configured: true, connected: false })),
startConnect: vi.fn(async () => 'https://provider.example/auth?state=s'),
completeCallback: vi.fn(async () => undefined),
disconnect: vi.fn(),
disconnect: vi.fn(async () => undefined),
...over,
} as unknown as PluginOAuthService;
return { c: new PluginOAuthController(svc, new DatabaseService(dbConn)), svc };
@@ -38,19 +38,19 @@ function ctrl(over: Partial<PluginOAuthService> = {}) {
describe('PluginOAuthController', () => {
beforeEach(() => { pluginsEnabled.mockReturnValue(true); getMock.mockReturnValue({ 1: 1 }); });
it('status is gated: runtime off / no user / inactive → not configured', () => {
expect(ctrl().c.status('p', req(5))).toEqual({ configured: true, connected: false });
it('status is gated: runtime off / no user / inactive → not configured', async () => {
expect(await ctrl().c.status('p', req(5))).toEqual({ configured: true, connected: false });
pluginsEnabled.mockReturnValue(false);
expect(ctrl().c.status('p', req(5))).toEqual({ configured: false, connected: false });
expect(await ctrl().c.status('p', req(5))).toEqual({ configured: false, connected: false });
pluginsEnabled.mockReturnValue(true);
expect(ctrl().c.status('p', req(undefined))).toEqual({ configured: false, connected: false });
expect(await ctrl().c.status('p', req(undefined))).toEqual({ configured: false, connected: false });
getMock.mockReturnValue(undefined as never); // inactive
expect(ctrl().c.status('p', req(5))).toEqual({ configured: false, connected: false });
expect(await ctrl().c.status('p', req(5))).toEqual({ configured: false, connected: false });
});
it('connect returns the authorize URL for a bound user', () => {
it('connect returns the authorize URL for a bound user', async () => {
const { c, svc } = ctrl();
expect(c.connect('p', req(5))).toEqual({ authorizeUrl: 'https://provider.example/auth?state=s' });
expect(await c.connect('p', req(5))).toEqual({ authorizeUrl: 'https://provider.example/auth?state=s' });
expect(svc.startConnect).toHaveBeenCalledWith('p', 5, expect.any(Number));
});
@@ -68,9 +68,9 @@ describe('PluginOAuthController', () => {
expect(denyRes.redirectedTo).toBe('/settings?oauth=p:denied');
});
it('disconnect delegates for a bound user', () => {
it('disconnect delegates for a bound user', async () => {
const { c, svc } = ctrl();
expect(c.disconnect('p', req(5))).toEqual({ connected: false });
expect(await c.disconnect('p', req(5))).toEqual({ connected: false });
expect(svc.disconnect).toHaveBeenCalledWith('p', 5);
});
});
@@ -58,13 +58,13 @@ describe('PluginOAuthService', () => {
let svc: PluginOAuthService;
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(new DatabaseService(dbConn)); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
it('providerConfig returns null unless every piece is present, decrypting the secrets', () => {
expect(svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
it('providerConfig returns null unless every piece is present, decrypting the secrets', async () => {
expect(await svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
getDb.current = freshDb({ ...CFG, oauth_client_secret: '' });
expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
expect(await new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
});
it('providerConfig falls back to the manifest defaults for the endpoints the admin left unset', () => {
it('providerConfig falls back to the manifest defaults for the endpoints the admin left unset', async () => {
// A provider plugin ships its authorize/token URLs as defaults; the admin only ever
// types the client id/secret. Those two are secrets and can never carry a default.
const { oauth_authorize_url: _a, oauth_token_url: _t, oauth_scopes: _s, ...stored } = CFG;
@@ -75,7 +75,7 @@ describe('PluginOAuthService', () => {
ins.run('oauth_token_url', JSON.stringify('https://provider.example/token'));
ins.run('oauth_scopes', JSON.stringify('read'));
svc = new PluginOAuthService(new DatabaseService(dbConn));
expect(svc.providerConfig('p')).toEqual({
expect(await svc.providerConfig('p')).toEqual({
authorizeUrl: 'https://provider.example/authorize',
tokenUrl: 'https://provider.example/token',
scopes: 'read',
@@ -84,8 +84,8 @@ describe('PluginOAuthService', () => {
});
});
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
const url = new URL(svc.startConnect('p', 42, NOW));
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', async () => {
const url = new URL(await svc.startConnect('p', 42, NOW));
expect(url.origin + url.pathname).toBe('https://provider.example/authorize');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('client_id')).toBe('client-123');
@@ -96,28 +96,28 @@ describe('PluginOAuthService', () => {
const stored = rows.prepare('SELECT verifier, user_id FROM plugin_oauth_state WHERE state = ?').get(state) as { verifier: string; user_id: number };
expect(stored.user_id).toBe(42);
// a second connect replaces the first (one live state per user)
svc.startConnect('p', 42, NOW);
await svc.startConnect('p', 42, NOW);
expect((rows.prepare('SELECT COUNT(*) c FROM plugin_oauth_state WHERE user_id = 42').get() as { c: number }).c).toBe(1);
});
it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => {
it('rejects a non-https / loopback / metadata / internal authorize endpoint', async () => {
getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/https/);
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|private/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/loopback|private/);
// IPv6-literal loopback must not slip past the fast-fail
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/loopback/);
// cloud-metadata by literal is refused too
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/loopback|metadata/);
// an internal name suffix is refused
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/local/);
});
it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => {
const url = new URL(svc.startConnect('p', 42, NOW));
const url = new URL(await svc.startConnect('p', 42, NOW));
const state = url.searchParams.get('state')!;
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600, scope: 'read' }),
@@ -134,15 +134,15 @@ describe('PluginOAuthService', () => {
const tok = rows.prepare('SELECT access_token, refresh_token FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = 42').get('p') as { access_token: string; refresh_token: string };
expect(tok.access_token).toBe('enc:AT'); // encrypted at rest
expect(tok.refresh_token).toBe('enc:RT');
expect(svc.status('p', 42)).toMatchObject({ configured: true, connected: true });
expect(await svc.status('p', 42)).toMatchObject({ configured: true, connected: true });
// state is single-use — replaying it fails
await expect(svc.completeCallback('p', 42, 'the-code', state, NOW + 2000)).rejects.toThrow(/state/);
});
it('rejects a foreign or expired state', async () => {
const state = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
const state = new URL(await svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
await expect(svc.completeCallback('p', 99, 'code', state, NOW + 1000)).rejects.toThrow(/state/); // wrong user
const state2 = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
const state2 = new URL(await svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
await expect(svc.completeCallback('p', 42, 'code', state2, NOW + 20 * 60 * 1000)).rejects.toThrow(/state/); // > 10 min
});
@@ -166,7 +166,7 @@ describe('PluginOAuthService', () => {
});
it('routes the token exchange through the SSRF guard — a token_url resolving to cloud metadata is refused', async () => {
const state = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
const state = new URL(await svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ access_token: 'AT' }) } as Response);
// The provider's token_url now resolves to the cloud-metadata address.
dnsState.address = '169.254.169.254';
@@ -177,7 +177,7 @@ describe('PluginOAuthService', () => {
it('disconnect drops the user\'s tokens', async () => {
const rows = getDb.current as unknown as InstanceType<typeof Database>;
rows.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token) VALUES (?,?,?)').run('p', 42, 'enc:X');
svc.disconnect('p', 42);
expect(svc.status('p', 42).connected).toBe(false);
await svc.disconnect('p', 42);
expect((await svc.status('p', 42)).connected).toBe(false);
});
});
@@ -55,7 +55,7 @@ beforeEach(() => {
process.env.TREK_PLUGINS_ENABLED = 'true';
runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService;
registry = { install: vi.fn(), installWithDependencies: vi.fn(), recomputeUpdateHold: vi.fn() } as unknown as PluginRegistryService;
plugins = { resumeUpdates: vi.fn(() => true) } as unknown as PluginsService;
plugins = { resumeUpdates: vi.fn(async () => true) } as unknown as PluginsService;
controller = new PluginsController(plugins, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService);
});
@@ -175,7 +175,7 @@ describe('update hold wiring', () => {
});
it('POST :id/resume-updates is a 404 for an unknown plugin', async () => {
vi.mocked(plugins.resumeUpdates).mockReturnValue(false);
vi.mocked(plugins.resumeUpdates).mockResolvedValue(false);
const { status } = await wireFailure(async () => controller.resumeUpdates('ghost'));
expect(status).toBe(404);
@@ -22,43 +22,43 @@ import type { PluginsService } from '../../../src/nest/plugins/plugins.service';
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: '••••••••' })),
userSettingsFields: vi.fn(async () => [{ key: 'apiKey', secret: true }]),
getUserConfig: vi.fn(async () => ({ apiKey: '••••••••' })),
updateUserConfig: vi.fn(async (_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: vi.fn(() => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
const runtime = { actionsOf: vi.fn(async () => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), 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', () => {
it('GET returns fields + masked config for a bound user; empty when gated', async () => {
const { c } = ctrl();
expect(c.get('p', req(5))).toEqual({ fields: [{ key: 'apiKey', secret: true }], config: { apiKey: '••••••••' }, actions: [] });
expect(await 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: [] });
expect(await c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
pluginsEnabled.mockReturnValue(true);
expect(c.get('p', req(undefined))).toEqual({ fields: [], config: {}, actions: [] });
expect(await c.get('p', req(undefined))).toEqual({ fields: [], config: {}, actions: [] });
getMock.mockReturnValue(undefined as never);
expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
expect(await c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
});
it('POST delegates the patch for a bound user; empty when gated', () => {
it('POST delegates the patch for a bound user; empty when gated', async () => {
const { c, svc } = ctrl();
expect(c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: { units: 'metric', apiKey: '••••••••' } });
expect(await 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));
await 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: {} });
expect(await c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: {} });
});
it('GET lists USER-scope actions only, and POST runs one as the caller in the user scope', async () => {
const { c, runtime } = ctrl();
c.get('p', req(5));
await c.get('p', req(5));
expect(runtime.actionsOf).toHaveBeenCalledWith('p', 'user');
expect(await c.runAction('p', 'sync', req(5))).toEqual({ ok: true });
expect(runtime.invokeAction).toHaveBeenCalledWith('p', 'sync', 5, 'user');
@@ -49,44 +49,44 @@ describe('per-user plugin settings', () => {
svc = new PluginsService(dbs, new AddonsService(dbs));
});
it('lists only the user-scope fields, in order', () => {
const fields = svc.userSettingsFields('p');
it('lists only the user-scope fields, in order', async () => {
const fields = await svc.userSettingsFields('p');
expect(fields.map(f => f.key)).toEqual(['apiKey', 'units']); // not the instance field
expect(fields[0]).toMatchObject({ secret: true, required: true });
});
it('encrypts a secret at rest, masks it to the client, stores a plain field verbatim', () => {
const masked = svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
it('encrypts a secret at rest, masks it to the client, stores a plain field verbatim', async () => {
const masked = await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
expect(masked.apiKey).toBe('••••••••'); // never echoed
expect(masked.units).toBe('metric');
// decrypted runtime read returns the real value; the stored form is ciphertext
expect(userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123');
expect(svc.getUserConfig('p', 42).apiKey).toBe('••••••••');
expect(await userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123');
expect((await svc.getUserConfig('p', 42)).apiKey).toBe('••••••••');
});
it('an unchanged secret (the mask) keeps the stored ciphertext', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
svc.updateUserConfig('p', 42, { apiKey: '••••••••', units: 'imperial' }); // mask = untouched
expect(userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123'); // still the original
expect(svc.getUserConfig('p', 42).units).toBe('imperial');
it('an unchanged secret (the mask) keeps the stored ciphertext', async () => {
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
await svc.updateUserConfig('p', 42, { apiKey: '••••••••', units: 'imperial' }); // mask = untouched
expect(await userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123'); // still the original
expect((await svc.getUserConfig('p', 42)).units).toBe('imperial');
});
it('ignores keys that are not declared user-scope fields', () => {
it('ignores keys that are not declared user-scope fields', async () => {
// apiKey is required — filled here so the save isn't refused; the assertion is
// about adminOnly/bogus being dropped, not about required enforcement.
svc.updateUserConfig('p', 42, { apiKey: 'sk-123', adminOnly: 'nope', bogus: 'x', units: 'metric' } as Record<string, unknown>);
const cfg = svc.getUserConfig('p', 42);
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', adminOnly: 'nope', bogus: 'x', units: 'metric' } as Record<string, unknown>);
const cfg = await svc.getUserConfig('p', 42);
expect(cfg.adminOnly).toBeUndefined(); // instance field — not accepted here
expect(cfg.bogus).toBeUndefined();
expect(cfg.units).toBe('metric');
});
it('is per-user — one user cannot see another\'s value', () => {
it('is per-user — one user cannot see another\'s value', async () => {
// apiKey is required — filled here so the save isn't refused; the point of this
// test is that user 99 sees none of user 42's values.
svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
expect(svc.getUserConfig('p', 99).units).toBeUndefined();
expect(userSettings().readOne('p', 99, 'apiKey')).toBeUndefined();
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
expect((await svc.getUserConfig('p', 99)).units).toBeUndefined();
expect(await userSettings().readOne('p', 99, 'apiKey')).toBeUndefined();
});
});
@@ -107,29 +107,29 @@ describe('manifest defaults reach the runtime reads', () => {
ins.run('p', 'endpoint', 'text', 1, 0, 'user', 5, JSON.stringify('https://api.example'));
});
it('readOne falls back to the declared default when nothing is stored', () => {
expect(userSettings().readOne('p', 42, 'region')).toBe('eu');
expect(userSettings().readOne('p', 42, 'retries')).toBe(3); // JSON round-trip keeps the type
expect(userSettings().readOne('p', 42, 'apiKey')).toBeUndefined(); // no default declared
it('readOne falls back to the declared default when nothing is stored', async () => {
expect(await userSettings().readOne('p', 42, 'region')).toBe('eu');
expect(await userSettings().readOne('p', 42, 'retries')).toBe(3); // JSON round-trip keeps the type
expect(await userSettings().readOne('p', 42, 'apiKey')).toBeUndefined(); // no default declared
});
it('a stored value wins over the default', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' });
expect(userSettings().readOne('p', 42, 'region')).toBe('us');
expect(userSettings().readOne('p', 99, 'region')).toBe('eu'); // another user still gets the default
it('a stored value wins over the default', async () => {
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' });
expect(await userSettings().readOne('p', 42, 'region')).toBe('us');
expect(await userSettings().readOne('p', 99, 'region')).toBe('eu'); // another user still gets the default
});
it('readAll folds defaults in for the fields the user left unset', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' });
expect(userSettings().readAll('p', 42)).toMatchObject({ apiKey: 'sk-123', region: 'us', retries: 3, endpoint: 'https://api.example' });
it('readAll folds defaults in for the fields the user left unset', async () => {
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' });
expect(await userSettings().readAll('p', 42)).toMatchObject({ apiKey: 'sk-123', region: 'us', retries: 3, endpoint: 'https://api.example' });
});
it('hasRequired treats a required field with a default as filled', () => {
it('hasRequired treats a required field with a default as filled', async () => {
// apiKey is required with no default → not configured until stored.
expect(userSettings().hasRequired('p', 42)).toBe(false);
svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
expect(await userSettings().hasRequired('p', 42)).toBe(false);
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
// endpoint is required too, but its default satisfies it.
expect(userSettings().hasRequired('p', 42)).toBe(true);
expect(await userSettings().hasRequired('p', 42)).toBe(true);
});
});
@@ -147,15 +147,15 @@ describe('hasRequired applies the same "filled" rule as the save gate', () => {
.run('p', 'consent', 'checkbox', 1, 0, 'user', 9);
});
it('exempts a required checkbox (consent, not a settings field)', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123' }); // consent never set
expect(userSettings().hasRequired('p', 42)).toBe(true);
it('exempts a required checkbox (consent, not a settings field)', async () => {
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123' }); // consent never set
expect(await userSettings().hasRequired('p', 42)).toBe(true);
});
it('treats a whitespace-only value as empty', () => {
it('treats a whitespace-only value as empty', async () => {
(getDb.current as import('better-sqlite3').Database)
.prepare("INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES ('p', 42, ?, '')")
.run(JSON.stringify({ apiKey: ' ', consent: true }));
expect(userSettings().hasRequired('p', 42)).toBe(false);
expect(await userSettings().hasRequired('p', 42)).toBe(false);
});
});
@@ -29,23 +29,23 @@ describe('PluginsFeedController', () => {
const c = new PluginsFeedController(new DatabaseService(dbConn));
beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; });
it('returns an empty feed when the runtime is disabled', () => {
it('returns an empty feed when the runtime is disabled', async () => {
pluginsEnabled.mockReturnValue(false);
rows.value = [row()];
expect(c.list()).toEqual({ plugins: [] });
expect(await c.list()).toEqual({ plugins: [] });
});
it('serves routeProfiles only alongside the recorded hook:route-provider grant', () => {
it('serves routeProfiles only alongside the recorded hook:route-provider grant', async () => {
rows.value = [
row({ id: 'granted', capabilities: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }), granted_permissions: JSON.stringify(['hook:route-provider']) }),
row({ id: 'ungranted', capabilities: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }) }),
];
const { plugins } = c.list();
const { plugins } = await c.list();
expect(plugins.find(p => p.id === 'granted')?.routeProfiles).toEqual([{ id: 'ev', label: 'EV' }]);
expect(plugins.find(p => p.id === 'ungranted')?.routeProfiles).toBeUndefined();
});
it('re-validates hand-edited routeProfiles rows (bad ids dropped, labels capped, max 3)', () => {
it('re-validates hand-edited routeProfiles rows (bad ids dropped, labels capped, max 3)', async () => {
rows.value = [row({
id: 'edited',
capabilities: JSON.stringify({
@@ -57,25 +57,25 @@ describe('PluginsFeedController', () => {
}),
granted_permissions: JSON.stringify(['hook:route-provider']),
})];
const profiles = c.list().plugins[0].routeProfiles!;
const profiles = (await c.list()).plugins[0].routeProfiles!;
expect(profiles.every(p => /^[a-z][a-z0-9-]{0,23}$/.test(p.id))).toBe(true);
expect(profiles.length).toBeLessThanOrEqual(3);
expect(profiles.every(p => p.label.length <= 40)).toBe(true);
});
it('flags geolocation only when the grant is recorded', () => {
it('flags geolocation only when the grant is recorded', async () => {
rows.value = [
row({ id: 'granted', granted_permissions: JSON.stringify(['geolocation:read']) }),
row({ id: 'ungranted' }),
];
const { plugins } = c.list();
const { plugins } = await c.list();
expect(plugins.find(p => p.id === 'granted')?.geolocation).toBe(true);
expect(plugins.find(p => p.id === 'ungranted')?.geolocation).toBeUndefined();
});
it('survives malformed JSON blobs without dropping the plugin', () => {
it('survives malformed JSON blobs without dropping the plugin', async () => {
rows.value = [row({ capabilities: '{not json', granted_permissions: 'also not' })];
const { plugins } = c.list();
const { plugins } = await c.list();
expect(plugins).toHaveLength(1);
expect(plugins[0].routeProfiles).toBeUndefined();
expect(plugins[0].slot).toBe('sidebar');
@@ -102,15 +102,15 @@ describe('PluginsService.list', () => {
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
});
it('resumeUpdates clears the hold and reports whether the plugin existed', () => {
it('resumeUpdates clears the hold and reports whether the plugin existed', async () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
expect(svc.resumeUpdates('held')).toBe(true);
expect(await svc.resumeUpdates('held')).toBe(true);
expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
expect(svc.resumeUpdates('ghost')).toBe(false);
expect(await svc.resumeUpdates('ghost')).toBe(false);
});
it('reports enabled by default (no kill switch set)', async () => {
@@ -193,67 +193,67 @@ describe('PluginsService.list', () => {
});
});
it('controller delegates to the service', () => {
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
it('controller delegates to the service', async () => {
const svc = { list: vi.fn(async () => ({ 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, { isManaged: () => false } as unknown as RuntimeEnvService).list();
const res = await new PluginsController(svc, runtime, {} as never, { isManaged: () => false } as unknown as RuntimeEnvService).list();
expect(svc.list).toHaveBeenCalled();
expect(res).toEqual({ enabled: false, plugins: [] });
});
});
describe('PluginsFeedController (client feed)', () => {
it('returns active plugins when enabled, nothing when disabled', () => {
it('returns active plugins when enabled, nothing when disabled', async () => {
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(new DatabaseService(dbConn));
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = feed.list();
const active = await 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([]);
expect((await feed.list()).plugins).toEqual([]);
});
it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', () => {
it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', async () => {
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(new DatabaseService(dbConn)).list();
const out = await new PluginsFeedController(new DatabaseService(dbConn)).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)', () => {
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', async () => {
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(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
});
it('exposes settingsUi only when the capability is exactly true', () => {
it('exposes settingsUi only when the capability is exactly true', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true);
expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined();
});
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', () => {
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', async () => {
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(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
expect((await new PluginsFeedController(new DatabaseService(dbConn)).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', () => {
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', async () => {
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(new DatabaseService(dbConn)).list();
const out = await new PluginsFeedController(new DatabaseService(dbConn)).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();
@@ -262,9 +262,9 @@ describe('PluginsFeedController (client feed)', () => {
describe('PluginsController M2 endpoints', () => {
const svc = {
getInstanceConfig: vi.fn(() => ({ a: 1 })),
instanceSettingsFields: vi.fn(() => [{ key: 'a' }]),
updateInstanceConfig: vi.fn(() => ({ a: 2 })),
getInstanceConfig: vi.fn(async () => ({ a: 1 })),
instanceSettingsFields: vi.fn(async () => [{ key: 'a' }]),
updateInstanceConfig: vi.fn(async () => ({ a: 2 })),
} as unknown as PluginsService;
// None of the endpoints below carry the marker, so the ordinary install is the
// whole story here; the refusals have their own tests.
@@ -277,9 +277,9 @@ describe('PluginsController M2 endpoints', () => {
});
it('get/update config delegate to the service (get carries the form fields, update the restart)', async () => {
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn(), respawnIfActive: vi.fn(async () => false), actionsOf: vi.fn(() => []) } as never;
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn(), respawnIfActive: vi.fn(async () => false), actionsOf: vi.fn(async () => []) } as never;
const c = new PluginsController(svc, rt, {} as never, envStub);
expect(c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 }, actions: [] });
expect(await c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 }, actions: [] });
expect(await c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 }, restarted: false });
});
@@ -309,13 +309,13 @@ describe('PluginsController M2 endpoints', () => {
});
describe('PluginsService instance config', () => {
it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', () => {
it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', async () => {
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();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','server','instance',0)").run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
const masked = await 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');
@@ -327,19 +327,19 @@ describe('PluginsService instance config', () => {
expect(stored.server).toBe('https://h');
// an unchanged mask does not overwrite the stored secret
svc.updateInstanceConfig('x', { api_key: '••••••••' });
await 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('••••••••');
expect((await svc.getInstanceConfig('x')).api_key).toBe('••••••••');
});
it('drops a key the plugin never declared, like the user-scope sibling does', () => {
it('drops a key the plugin never declared, like the user-scope sibling does', async () => {
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('y','Y','inactive','{}')").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('y','server','instance',0)").run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
const masked = svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' });
const masked = await svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' });
expect(masked.server).toBe('https://h');
expect(masked.smuggled).toBeUndefined();
@@ -347,19 +347,19 @@ describe('PluginsService instance config', () => {
expect(stored).toEqual({ server: 'https://h' });
});
it('throws for an unknown plugin', () => {
expect(() => new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).updateInstanceConfig('nope', {})).toThrow(/not found/);
expect(() => new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).getInstanceConfig('nope')).toThrow(/not found/);
it('throws for an unknown plugin', async () => {
await expect(new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).updateInstanceConfig('nope', {})).rejects.toThrow(/not found/);
await expect(new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).getInstanceConfig('nope')).rejects.toThrow(/not found/);
});
});
describe('PluginsService error log', () => {
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
it('lists and clears a plugin error log', () => {
it('lists and clears a plugin error log', async () => {
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
svc.clearErrors('p');
expect(svc.errors('p')).toEqual([]);
expect(await svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
await svc.clearErrors('p');
expect(await svc.errors('p')).toEqual([]);
});
});
+1 -1
View File
@@ -121,7 +121,7 @@ describe('PluginRpcHost — capability enforcement', () => {
expect(seen?.data).toBe(deps.data);
await seen?.plugins.call('other', 'sum', [1], 42);
expect(deps.callPlugin).toHaveBeenCalledWith('other', 'sum', [1], 42);
seen?.plugins.emit('ping', { a: 1 });
await seen?.plugins.emit('ping', { a: 1 });
expect(deps.emitPluginEvent).toHaveBeenCalledWith('ping', { a: 1 });
});
@@ -53,7 +53,7 @@ beforeEach(() => {
});
describe('plugin settings are isolated from core and from each other', () => {
it('PSET-001 — a plugin declaring "webhook_url" cannot touch the CORE settings row', () => {
it('PSET-001 — a plugin declaring "webhook_url" cannot touch the CORE settings row', async () => {
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://core.example.com/real')").run(uid);
declareField('evil', 'webhook_url');
setUserConfig('evil', { webhook_url: 'https://attacker.example.com' });
@@ -62,43 +62,43 @@ describe('plugin settings are isolated from core and from each other', () => {
// its own blob, in its own table. The namespacing is structural, not by key naming.
const core = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(uid) as { value: string };
expect(core.value).toBe('https://core.example.com/real');
expect(userSettings().readAll('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' });
expect(await userSettings().readAll('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' });
});
it('PSET-002 — plugin A cannot read plugin B’s config, even with the same key name', () => {
it('PSET-002 — plugin A cannot read plugin B’s config, even with the same key name', async () => {
declareField('a', 'token', { secret: true });
declareField('b', 'token', { secret: true });
setUserConfig('b', { token: 'B-SECRET' });
expect(userSettings().readAll('a', uid)).toEqual({});
expect(userSettings().readOne('a', uid, 'token')).toBeUndefined();
expect(userSettings().readOne('b', uid, 'token')).toBe('B-SECRET');
expect(await userSettings().readAll('a', uid)).toEqual({});
expect(await userSettings().readOne('a', uid, 'token')).toBeUndefined();
expect(await userSettings().readOne('b', uid, 'token')).toBe('B-SECRET');
});
it('PSET-003 — a plugin only ever sees its own DECLARED keys', () => {
it('PSET-003 — a plugin only ever sees its own DECLARED keys', async () => {
declareField('p', 'declared');
// An undeclared key that somehow reached the blob is not handed to the plugin.
setUserConfig('p', { declared: 'yes', sneaked: 'no' });
expect(userSettings().readAll('p', uid)).toEqual({ declared: 'yes' });
expect(await userSettings().readAll('p', uid)).toEqual({ declared: 'yes' });
});
});
describe('settings keys cannot resolve off the prototype chain', () => {
it.each(['__proto__', 'constructor', 'prototype'])(
'PSET-004 — a REQUIRED field named "%s" is NOT reported as configured',
(key) => {
async (key) => {
declareField('evil', key, { required: true });
// The user has configured nothing at all.
expect(userSettings().hasRequired('evil', uid)).toBe(false);
expect(userSettings().readAll('evil', uid)).toEqual({});
expect(await userSettings().hasRequired('evil', uid)).toBe(false);
expect(await userSettings().readAll('evil', uid)).toEqual({});
},
);
it('PSET-005 — a genuinely configured required field still reports configured', () => {
it('PSET-005 — a genuinely configured required field still reports configured', async () => {
declareField('good', 'appToken', { required: true, secret: true });
expect(userSettings().hasRequired('good', uid)).toBe(false);
expect(await userSettings().hasRequired('good', uid)).toBe(false);
setUserConfig('good', { appToken: 'T' });
expect(userSettings().hasRequired('good', uid)).toBe(true);
expect(await userSettings().hasRequired('good', uid)).toBe(true);
});
it('PSET-006 — the manifest rejects such a key at install', () => {
@@ -132,7 +132,7 @@ describe('a plugin channel label is bounded by the host', () => {
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),
});
const [channel] = rt.notificationChannels();
const [channel] = await rt.notificationChannels();
expect(channel.id).toBe('plugin:loud');
expect(channel.label!.length).toBeLessThanOrEqual(40);
expect(channel.label).not.toMatch(/\p{Extended_Pictographic}/u);