mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
394201cd32
* chore: bump version to 4.1.0 [skip ci] * fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone Three places answered "is this a place the trip already has?", not two: PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and kept its own hand-rolled copy of the pre-#2130 order. Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of it — savePlace calls it directly — so the coordinate fallback for a NAMED candidate was live, not latent: two distinct places sharing an address (a restaurant and the bar upstairs) would report each other as duplicates the moment their coordinates round to the same ~11 m box, regardless of name. It also never read google_place_id/google_ftid/osm_id at all, even though every collection_places row stores them, so a place renamed since it was saved could be saved again instead of being recognised by its provider id. Now walks placeMatchStrategies() from @trek/shared, same as findMatchingPlaceId: provider id, then name, then coordinates only when there is no name. savePlace passes the provider id fields through since they are already on the request body; the two bulk call sites (importablePlaces, saveFromTripPlaces) are unchanged, since their source rows don't carry provider ids to pass. Closes #2137 * fix(collections): ask the same question at all three call sites savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy already carries google_place_id/google_ftid/osm_id into the row it writes but asked without them, and the import picker never selected them at all, so a renamed place was recognised on one path and not on the other two. The picker answering differently from the import is the drift the method exists to prevent: a row shown as new would come back refused. COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker. * fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom specified", and so could the map settings tab, which left no screen from which to pick a different basemap. Leaflet answers getMaxZoom() from the map options first and otherwise from a layer that brought one, and only a GridLayer ever contributes via its beforeAdd hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd refuses an infinite one by throwing, before any clustering, so an empty trip hit it too. That path opened when the default basemap became a MapLibre style. It reaches further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless CARTO template to the app default, which is now a vector style, so every instance still carrying the old CARTO basemap fell into it on upgrade. The ceiling belongs on the map rather than on the base layer, because it has to hold whichever of the three branches renders. 19 matches what the raster and satellite layers already carried, so nothing changes for the maps that worked. SharedTripPage gets the same treatment: it has no cluster to throw, but it draws the same vector basemap and its fitBounds asks for a ceiling. mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library rather than a mock, since a mocked react-leaflet would have kept passing through all of this. * fix(map): put the CARTO key back on the template the settings preview draws The map settings fields hold what the user is editing rather than what useTileUrl already resolved, so the preview received a bare template with no key on it. resolveTileUrl then read it as a keyless CARTO url and did what it is meant to do with one: fall back to the app default. The preview drew OpenFreeMap while the fields said CARTO, whether or not a key was saved. Same reason the tab crashed for anyone on a CARTO basemap even after entering a key, which is the loop that made this hard to escape: the key is entered on the page the fallback had taken down. FE-COMP-MAP-033b pins the template the preview is handed, with and without a key. * fix(admin): put the CARTO key back on the default-settings map preview too Same shape as the user-facing map tab, and reported alongside it: the field holds what the admin is editing, so the preview resolved a keyless CARTO template and drew the app default instead of the basemap being set. * fix(mobile): put the CARTO key back on the phone map settings preview too The third preview with the same shape, and the one that matters most while the crash is live: the phone layout is where someone would go to change the basemap when the desktop tab will not open. * fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar Closes #2136 A stay was always exported as an all-day block across its whole range, and the check-in and check-out markers came on top of it. When the stay records both ends of its clock the markers already say everything the block does, and the block is the half nobody can act on: it takes a week of calendar to repeat what two one-hour events state precisely. Same shape as the parking and rental windows in #2068. Only where the markers actually stand in for the booking, though. They are emitted once per stay and titled from its lowest-id reservation, so a second room on the same stay would otherwise be left with nothing at all. A stay that knows only one end keeps its block too, since nothing else carries the other end's date. CAL-025b covers the drop, -025c the one-sided stay, -025d the second room. * fix(mobile): the phone admin map preview needs the CARTO key too The fourth preview with the same shape, missed when the other three were fixed. Sonar pointed at it: the desktop and phone admin pages are near-identical, so the duplication report named the file the change had not reached. * chore: bump version to 4.1.1 [skip ci] * feat(plugins): admin editor for instance-wide plugin settings The scope:'instance' settings a plugin declares had API endpoints but no UI. - shared: plugins.schema.ts — the settings-field descriptor and the admin instance-config GET/PUT wire contracts, imported by both sides - server: GET /api/admin/plugins/:id/config now returns the declared fields alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child reads config once, at init) and reports it via 'restarted'; the admin list carries instanceSettingsCount so the UI gates its menu item without a fetch - client: 'Instance settings' row action opening a form modal (desktop) / MSheet (phone) in both admin shells — checkbox/select/secret rendering as in the user settings form; an untouched secret mask is never sent back - i18n: three new admin.plugins.* keys in every locale * refactor(plugins): move the admin body contracts to @trek/shared The remaining plugin DTO schemas (install, link, activate, uninstall, retrust, update, egress-hosts, user-settings update, route) now live in shared/src/plugins/plugins.schema.ts with their inferred types, alongside the instance-config contracts. The journey-contract doctrine notes (#1842) moved with them — each body stays deliberately loose so the handlers keep the rejections and fallbacks they already own, and new spec tests pin that looseness so a well-meant tightening breaks a test that explains itself. plugins.dto.ts is now thin createZodDto wrappers over the shared schemas (same class names, no controller changes), and the client's pluginInstall types its options and body against PluginInstallRequest. * fix(admin): offer Allowed hosts only to plugins that declared operatorEgress The row's egress chip was already gated on the declaration, but the kebab menu (desktop) and row action sheet (phone) offered Allowed hosts on every installed plugin — inviting the admin to widen egress for plugins that never asked for it. Both entries now render only when the plugin declares operatorEgress; the dialog's unsupported-notice fallback stays as the truth-teller for stale list data. * test(admin): close the Sonar gaps on the instance-settings form The PR gate flagged new-code coverage (76.4%) and duplication (10.9%). - The two identical 39-line handler blocks were logic, not markup — they move into one shared useInstanceSettings hook; each shell keeps its own markup (the deliberate desktop/phone divergence stays) - New tests per shell cover the untested branches: every field type renders and round-trips into the save payload (select, checkbox/MToggle, number, hint, no-label key fallback), a failed fetch is a toast, a rejected save shows the server reason and keeps the edits, and the dialog/sheet closes without saving - INS-009 covers the controller's no-body '|| {}' fallback --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com> Co-authored-by: Maurice <mauriceboe@icloud.com>
325 lines
19 KiB
TypeScript
325 lines
19 KiB
TypeScript
/**
|
|
* 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 '[]', granted_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, update_hold INTEGER NOT NULL 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 { db as dbConn } from '../../../src/db/database';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
|
|
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
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';
|
|
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
|
|
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(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
|
expect(out.enabled).toBe(true);
|
|
expect(out.plugins).toHaveLength(1);
|
|
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
|
|
});
|
|
|
|
it('surfaces updateHold as a boolean (held plugins leave the update banner)', () => {
|
|
testDb
|
|
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
|
|
.run();
|
|
testDb
|
|
.prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')")
|
|
.run();
|
|
|
|
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
|
expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true });
|
|
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
|
|
});
|
|
|
|
it('resumeUpdates clears the hold and reports whether the plugin existed', () => {
|
|
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(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
|
|
expect(svc.resumeUpdates('ghost')).toBe(false);
|
|
});
|
|
|
|
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(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).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(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).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(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).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(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).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(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).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(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).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');
|
|
// PluginListItem is an interface, so it has no implicit index signature and cannot
|
|
// be narrowed to a record directly. Widening through unknown is what lets this case
|
|
// probe for a key the contract deliberately does not declare.
|
|
const p = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()
|
|
.plugins[0] as unknown as Record<string, unknown>;
|
|
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, { 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', () => {
|
|
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();
|
|
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(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)', () => {
|
|
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');
|
|
});
|
|
|
|
it('exposes settingsUi only when the capability is exactly true', () => {
|
|
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();
|
|
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)', () => {
|
|
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');
|
|
});
|
|
|
|
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(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();
|
|
});
|
|
});
|
|
|
|
describe('PluginsController M2 endpoints', () => {
|
|
const svc = {
|
|
getInstanceConfig: vi.fn(() => ({ a: 1 })),
|
|
instanceSettingsFields: vi.fn(() => [{ key: 'a' }]),
|
|
updateInstanceConfig: vi.fn(() => ({ 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.
|
|
const envStub = { isManaged: () => false } as unknown as RuntimeEnvService;
|
|
|
|
beforeEach(() => {
|
|
(svc.getInstanceConfig as ReturnType<typeof vi.fn>).mockClear();
|
|
(svc.updateInstanceConfig as ReturnType<typeof vi.fn>).mockClear();
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
});
|
|
|
|
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) } as never;
|
|
const c = new PluginsController(svc, rt, {} as never, envStub);
|
|
expect(c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 } });
|
|
expect(await c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 }, restarted: false });
|
|
});
|
|
|
|
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, envStub).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, envStub).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, envStub).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, envStub).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();
|
|
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' });
|
|
// 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('drops a key the plugin never declared, like the user-scope sibling does', () => {
|
|
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' });
|
|
|
|
expect(masked.server).toBe('https://h');
|
|
expect(masked.smuggled).toBeUndefined();
|
|
const stored = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='y'").get() as { config: string }).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/);
|
|
});
|
|
});
|
|
|
|
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(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([]);
|
|
});
|
|
});
|