/** * The read-side plugin service + controller (#plugins, M0). Lists installed * plugins and reports whether the runtime is enabled (TREK_PLUGINS_ENABLED). */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const { testDb } = vi.hoisted(() => { const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.exec(`CREATE TABLE plugins ( id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, trek_range TEXT, sort_order INTEGER DEFAULT 0); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`); return { testDb: db }; }); vi.mock('../../../src/db/database', () => ({ db: testDb })); import { PluginsService } from '../../../src/nest/plugins/plugins.service'; import { PluginsController } from '../../../src/nest/plugins/plugins.controller'; import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller'; beforeEach(() => { testDb.exec('DELETE FROM plugins'); testDb.exec('DELETE FROM plugin_settings_fields'); delete process.env.TREK_PLUGINS_ENABLED; }); afterEach(() => { delete process.env.TREK_PLUGINS_ENABLED; }); describe('PluginsService.list', () => { it('returns the installed plugins and the runtime-enabled flag', () => { testDb .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); process.env.TREK_PLUGINS_ENABLED = 'true'; const out = new PluginsService().list(); expect(out.enabled).toBe(true); expect(out.plugins).toHaveLength(1); expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' }); }); it('reports enabled by default (no kill switch set)', () => { testDb .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); const out = new PluginsService().list(); expect(out.enabled).toBe(true); expect(out.plugins).toHaveLength(1); }); it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', () => { process.env.TREK_PLUGINS_ENABLED = 'false'; const out = new PluginsService().list(); expect(out.enabled).toBe(false); expect(out.plugins).toEqual([]); }); // The four trust states an admin can be in. `signed` derives from the TOFU-pinned // author key; sideloaded/dev-linked derive from source_repo — so they are NOT // mutually exclusive in the data, and a sideloaded plugin legitimately reports // signed:false. The UI's precedence rule (source badge wins) depends on that being // reported honestly rather than papered over here. describe('signature status', () => { const insert = (id: string, sourceRepo: string | null, pubkey: string | null) => testDb .prepare('INSERT INTO plugins (id, name, type, status, version, source_repo, author_pubkey) VALUES (?,?,?,?,?,?,?)') .run(id, id, 'widget', 'inactive', '1.0.0', sourceRepo, pubkey); it('reports signed + a display fingerprint for a registry plugin with a pinned key', () => { const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'; insert('signed-one', 'acme/signed-one', key); const p = new PluginsService().list().plugins[0]; expect(p.signed).toBe(true); // Short head…tail, for eyeballing against what the author reads out over the // phone. NOT a confidentiality measure — the key is public, and the re-trust // round-trip deliberately carries it in full. expect(p.keyFingerprint).toBe(`${key.slice(0, 8)}…${key.slice(-8)}`); }); it('reports unsigned for a registry plugin with no pinned key', () => { insert('plain', 'acme/plain', null); const p = new PluginsService().list().plugins[0]; expect(p.signed).toBe(false); expect(p.keyFingerprint).toBeNull(); }); it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', () => { insert('uploaded', 'local:upload', null); insert('linked', 'local:link', null); const plugins = new PluginsService().list().plugins; expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([ ['linked', false, 'local:link'], ['uploaded', false, 'local:upload'], ]); }); it('surfaces a recorded update block, and reports none when there is none', () => { insert('blocked', 'acme/blocked', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'); insert('fine', 'acme/fine', null); testDb .prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?') .run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked'); const byId = Object.fromEntries(new PluginsService().list().plugins.map((p) => [p.id, p])); expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' }); expect(byId.fine.updateBlock).toBeNull(); }); it('never leaks the raw pinned key into the list response (only the fingerprint)', () => { insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'); const p = new PluginsService().list().plugins[0] as Record; expect(p.author_pubkey).toBeUndefined(); }); }); it('controller delegates to the service', () => { const svc = { list: vi.fn(() => ({ 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).list(); expect(svc.list).toHaveBeenCalled(); expect(res).toEqual({ enabled: false, plugins: [] }); }); }); describe('PluginsFeedController (client feed)', () => { it('returns active plugins when enabled, nothing when disabled', () => { 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(); process.env.TREK_PLUGINS_ENABLED = 'true'; const active = 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([]); }); it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', () => { 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().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)', () => { 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().list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail'); }); it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', () => { 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().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', () => { 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().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(); }); }); describe('PluginsController M2 endpoints', () => { const svc = { getInstanceConfig: vi.fn(() => ({ a: 1 })), updateInstanceConfig: vi.fn(() => ({ a: 2 })), } as unknown as PluginsService; beforeEach(() => { (svc.getInstanceConfig as ReturnType).mockClear(); (svc.updateInstanceConfig as ReturnType).mockClear(); process.env.TREK_PLUGINS_ENABLED = 'true'; }); it('get/update config delegate to the service', () => { const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never; const c = new PluginsController(svc, rt, {} as never); expect(c.getConfig('x')).toEqual({ config: { a: 1 } }); expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } }); }); it('activate spawns via the runtime when enabled', async () => { const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never; const out = await new PluginsController(svc, rt, {} as never).activate('x'); expect(out).toEqual({ status: 'active' }); }); it('activate is 503 when the runtime is disabled', async () => { process.env.TREK_PLUGINS_ENABLED = 'false'; const rt = { activate: vi.fn(), isActive: vi.fn() } as never; await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 }); }); it('activate surfaces an activation error as 400', async () => { const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never; await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 }); }); it('deactivate stops the plugin (and cascades to dependents)', async () => { const deactivateWithDependents = vi.fn(async () => ['x']); const rt = { deactivateWithDependents } as never; expect(await new PluginsController(svc, rt, {} as never).deactivate('x')).toEqual({ status: 'inactive' }); expect(deactivateWithDependents).toHaveBeenCalledWith('x'); }); }); describe('PluginsService instance config', () => { it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', () => { 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(); const svc = new PluginsService(); const masked = 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'); // stored value is encrypted, not plaintext const stored = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config); expect(stored.api_key).not.toBe('super-secret'); expect(String(stored.api_key)).toMatch(/^enc:/); expect(stored.server).toBe('https://h'); // an unchanged mask does not overwrite the stored secret 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('••••••••'); }); it('throws for an unknown plugin', () => { expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/); expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/); }); }); describe('PluginsService error log', () => { beforeEach(() => testDb.exec('DELETE FROM plugin_error_log')); it('lists and clears a plugin error log', () => { testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run(); const svc = new PluginsService(); expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]); svc.clearErrors('p'); expect(svc.errors('p')).toEqual([]); }); });