mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(nest): inject DatabaseService in db-using services
This commit is contained in:
@@ -20,6 +20,8 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
@@ -42,7 +44,7 @@ beforeEach(() => {
|
||||
testDb.prepare('DELETE FROM plugins').run();
|
||||
testDb.prepare('DELETE FROM plugin_egress_hosts').run();
|
||||
testDb.prepare('DELETE FROM plugin_actions').run();
|
||||
rt = new PluginRuntimeService();
|
||||
rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
});
|
||||
|
||||
describe('operator-supplied egress hosts', () => {
|
||||
@@ -156,13 +158,13 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', (
|
||||
install('gotify', true);
|
||||
install('plain', false);
|
||||
|
||||
const before = new PluginsService().list().plugins;
|
||||
const before = new PluginsService(new DatabaseService(dbConn)).list().plugins;
|
||||
expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 });
|
||||
// A plugin that never asked for it must never invite the admin to add hosts.
|
||||
expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 });
|
||||
|
||||
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
|
||||
const after = new PluginsService().list().plugins;
|
||||
const after = new PluginsService(new DatabaseService(dbConn)).list().plugins;
|
||||
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
|
||||
delete process.env.TREK_PLUGINS_ENABLED;
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ vi.mock('../../../src/services/notifications', () => ({ getAppUrl: () => 'https:
|
||||
|
||||
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
|
||||
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
// The token POST now runs through the SSRF guard (ssrfGuard.safeFetchLlm), which
|
||||
// resolves the host before fetching. Stub DNS so the fake provider.example host
|
||||
@@ -50,12 +52,12 @@ const NOW = 1_700_000_000_000;
|
||||
|
||||
describe('PluginOAuthService', () => {
|
||||
let svc: PluginOAuthService;
|
||||
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
|
||||
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' });
|
||||
getDb.current = freshDb({ ...CFG, oauth_client_secret: '' });
|
||||
expect(new PluginOAuthService().providerConfig('p')).toBeNull();
|
||||
expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
|
||||
});
|
||||
|
||||
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
|
||||
@@ -76,18 +78,18 @@ describe('PluginOAuthService', () => {
|
||||
|
||||
it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => {
|
||||
getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' });
|
||||
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/https/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/);
|
||||
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' });
|
||||
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|private/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).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().startConnect('p', 42, NOW)).toThrow(/loopback/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).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().startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
|
||||
// an internal name suffix is refused
|
||||
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' });
|
||||
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/local/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/);
|
||||
});
|
||||
|
||||
it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
|
||||
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
|
||||
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { PluginsService, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service';
|
||||
@@ -35,7 +37,7 @@ function freshDb() {
|
||||
|
||||
describe('per-user plugin settings', () => {
|
||||
let svc: PluginsService;
|
||||
beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(); });
|
||||
beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(new DatabaseService(dbConn)); });
|
||||
|
||||
it('lists only the user-scope fields, in order', () => {
|
||||
const fields = svc.userSettingsFields('p');
|
||||
|
||||
@@ -17,6 +17,8 @@ const { testDb } = vi.hoisted(() => {
|
||||
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 { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
|
||||
@@ -38,7 +40,7 @@ describe('PluginsService.list', () => {
|
||||
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
|
||||
const out = new PluginsService().list();
|
||||
const out = new PluginsService(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' });
|
||||
@@ -49,14 +51,14 @@ describe('PluginsService.list', () => {
|
||||
.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();
|
||||
const out = new PluginsService(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().list();
|
||||
const out = new PluginsService(new DatabaseService(dbConn)).list();
|
||||
expect(out.enabled).toBe(false);
|
||||
expect(out.plugins).toEqual([]);
|
||||
});
|
||||
@@ -76,7 +78,7 @@ describe('PluginsService.list', () => {
|
||||
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
|
||||
insert('signed-one', 'acme/signed-one', key);
|
||||
|
||||
const p = new PluginsService().list().plugins[0];
|
||||
const p = new PluginsService(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
|
||||
@@ -86,7 +88,7 @@ describe('PluginsService.list', () => {
|
||||
|
||||
it('reports unsigned for a registry plugin with no pinned key', () => {
|
||||
insert('plain', 'acme/plain', null);
|
||||
const p = new PluginsService().list().plugins[0];
|
||||
const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0];
|
||||
expect(p.signed).toBe(false);
|
||||
expect(p.keyFingerprint).toBeNull();
|
||||
});
|
||||
@@ -94,7 +96,7 @@ describe('PluginsService.list', () => {
|
||||
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;
|
||||
const plugins = new PluginsService(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'],
|
||||
@@ -108,14 +110,14 @@ describe('PluginsService.list', () => {
|
||||
.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]));
|
||||
const byId = Object.fromEntries(new PluginsService(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');
|
||||
const p = new PluginsService().list().plugins[0] as Record<string, unknown>;
|
||||
const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0] as Record<string, unknown>;
|
||||
expect(p.author_pubkey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -133,7 +135,7 @@ 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();
|
||||
const feed = new PluginsFeedController(new DatabaseService(dbConn));
|
||||
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const active = feed.list();
|
||||
@@ -147,7 +149,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
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();
|
||||
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');
|
||||
});
|
||||
@@ -155,14 +157,14 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
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');
|
||||
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().list();
|
||||
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();
|
||||
});
|
||||
@@ -170,7 +172,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
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');
|
||||
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', () => {
|
||||
@@ -180,7 +182,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
// 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();
|
||||
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();
|
||||
@@ -236,7 +238,7 @@ describe('PluginsService instance config', () => {
|
||||
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 svc = new PluginsService(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('••••••••');
|
||||
@@ -257,8 +259,8 @@ describe('PluginsService instance config', () => {
|
||||
});
|
||||
|
||||
it('throws for an unknown plugin', () => {
|
||||
expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/);
|
||||
expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/);
|
||||
expect(() => new PluginsService(new DatabaseService(dbConn)).updateInstanceConfig('nope', {})).toThrow(/not found/);
|
||||
expect(() => new PluginsService(new DatabaseService(dbConn)).getInstanceConfig('nope')).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,7 +268,7 @@ 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();
|
||||
const svc = new PluginsService(new DatabaseService(dbConn));
|
||||
expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
|
||||
svc.clearErrors('p');
|
||||
expect(svc.errors('p')).toEqual([]);
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
||||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { db } from '../../../src/db/database';
|
||||
|
||||
function makeSupervisor(): PluginSupervisor {
|
||||
// createRpcHost is never called on the providersOf path (no spawn).
|
||||
@@ -85,7 +87,7 @@ describe('providersOf enforces the hook:* grant', () => {
|
||||
|
||||
describe('runtime.invokeHook defense-in-depth', () => {
|
||||
it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => {
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(db));
|
||||
// one legitimate granted provider exists, so providersOf('placeDetailProvider') = ['ok']
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', hooks: ['placeDetailProvider'], events: [], granted: new Set(['hook:place-detail-provider']) });
|
||||
|
||||
@@ -17,6 +17,8 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
@@ -120,7 +122,7 @@ describe('a plugin channel label is bounded by the host', () => {
|
||||
VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`,
|
||||
).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } }));
|
||||
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
// Stand the plugin up as a granted, active notificationChannel provider.
|
||||
(rt as unknown as { supervisor: { running: Map<string, unknown> } }).supervisor.running.set('loud', {
|
||||
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),
|
||||
|
||||
Reference in New Issue
Block a user