diff --git a/server/tests/unit/plugins/plugin-audit.test.ts b/server/tests/unit/plugins/plugin-audit.test.ts index dd1686452..bb8870e16 100644 --- a/server/tests/unit/plugins/plugin-audit.test.ts +++ b/server/tests/unit/plugins/plugin-audit.test.ts @@ -18,6 +18,8 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity'; import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository'; import { appendAudit, readAudit, readAuditForUser, auditResource, isAuditable, pruneAudit, verifyChain } from '../../../src/nest/plugins/host/plugin-audit'; +import { countRows, deleteRows, findRow, findRows, insertRow, insertRows } from '../../helpers/factories/rows'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -44,16 +46,25 @@ function legacyAppendAudit(prevHash: string, e: LegacyEntry): { ts: string; hash const row = JSON.stringify([e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts]); const hash = crypto.createHash('sha256').update(prevHash + row).digest('hex'); testDb + // test-sql-allow: the pre-conversion appendAudit wrote through this exact raw statement; reproducing it verbatim is the legacy side of the parity check. .prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)') .run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prevHash || null, hash); return { ts, hash }; } /** Every row for a plugin, oldest first — the shape `verifyChain` needs. */ -function chainRows(pluginId: string) { - return testDb - .prepare('SELECT plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id ASC') - .all(pluginId) as Array<{ plugin_id: string; acting_user_id: number | null; method: string; resource: string | null; code: string; ts: string; prev_hash: string | null; hash: string }>; +async function chainRows(pluginId: string) { + const rows = await findRows(t, PluginCapabilityAudit, { plugin_id: pluginId }, { id: 'asc' }); + return rows.map((r) => ({ + plugin_id: r.plugin_id, + acting_user_id: r.acting_user_id ?? null, + method: r.method, + resource: r.resource ?? null, + code: r.code, + ts: r.ts, + prev_hash: r.prev_hash ?? null, + hash: r.hash, + })); } describe('auditResource + isAuditable', () => { @@ -85,7 +96,7 @@ describe('appendAudit hash chain (converted, through PluginCapabilityAuditReposi it('chains each entry off the previous hash (per plugin)', async () => { await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); - const rows = testDb.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>; + const rows = await findRows(t, PluginCapabilityAudit, {}, { id: 'asc' }); expect(rows).toHaveLength(2); expect(rows[0].prev_hash).toBeNull(); expect(rows[1].prev_hash).toBe(rows[0].hash); // chain links @@ -95,7 +106,7 @@ describe('appendAudit hash chain (converted, through PluginCapabilityAuditReposi it('keeps separate chains per plugin', async () => { await appendAudit(audit, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' }); await appendAudit(audit, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' }); - const b = testDb.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null }; + const b = (await findRow(t, PluginCapabilityAudit, { plugin_id: 'b' }))!; expect(b.prev_hash).toBeNull(); // b's first entry doesn't chain off a's }); @@ -106,7 +117,7 @@ describe('appendAudit hash chain (converted, through PluginCapabilityAuditReposi }); it("readAuditForUser returns one user's actions across ALL plugins, newest first, with the plugin name", async () => { - testDb.prepare("INSERT INTO plugins (id, name) VALUES ('koffi','Koffi'), ('flight','Flight Tracker')").run(); + await insertRows(t, Plugins, [{ id: 'koffi', name: 'Koffi' }, { id: 'flight', name: 'Flight Tracker' }]); await appendAudit(audit, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); await appendAudit(audit, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' }); await appendAudit(audit, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user @@ -128,17 +139,17 @@ describe('appendAudit hash chain (converted, through PluginCapabilityAuditReposi for (let i = 0; i < 50; i++) await appendAudit(audit, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' }); await appendAudit(audit, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' }); await pruneAudit(audit, 'p', 10); - const rows = testDb.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 }>; + const rows = await findRows(t, PluginCapabilityAudit, { plugin_id: 'p' }, { id: 'asc' }); 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) await appendAudit(audit, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' }); - expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 }); + expect(await countRows(t, PluginCapabilityAudit, { plugin_id: 'p' })).toBe(11); // pruning one plugin never touches another's rows - expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='other'").get()).toMatchObject({ c: 1 }); + expect(await countRows(t, PluginCapabilityAudit, { plugin_id: 'other' })).toBe(1); await expect(pruneAudit(audit, 'p', 0)).resolves.toBeUndefined(); // 0 = disabled, no-op - expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 }); + expect(await countRows(t, PluginCapabilityAudit, { plugin_id: 'p' })).toBe(11); }); }); @@ -153,7 +164,7 @@ describe('R-hash-chain: concurrency (Plan 3j Task 7 fix wave, must-land 1)', () appendAudit(audit, { pluginId: 'burst', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' }), ), ); - const rows = chainRows('burst'); + const rows = await chainRows('burst'); expect(rows).toHaveLength(10); expect(verifyChain(rows)).toBe(true); // every row's prev_hash === the previous row's hash // No two rows share a prev_hash — the exact shape a forked chain (two racers reading @@ -174,14 +185,14 @@ describe('R-hash-chain: replay, extension, mutation', () => { prev = hash; } // 2. REPLAY: the converted verification logic (verifyChain) accepts the legacy-written rows. - expect(verifyChain(chainRows('legacy-p'))).toBe(true); + expect(verifyChain(await chainRows('legacy-p'))).toBe(true); // 3. EXTENSION: append two more rows through the CONVERTED appendAudit, on top of the // legacy tip — the repository reads the legacy row's hash as its own prev_hash. await appendAudit(audit, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: 'trip:3', code: 'ok' }); await appendAudit(audit, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: 'trip:4', code: 'ok' }); - const rows = chainRows('legacy-p'); + const rows = await chainRows('legacy-p'); expect(rows).toHaveLength(5); expect(rows[3].prev_hash).toBe(rows[2].hash); // the converted append linked onto the legacy tip // 4. The WHOLE chain (3 legacy rows + 2 converted rows) verifies end to end. @@ -200,15 +211,15 @@ describe('R-hash-chain: replay, extension, mutation', () => { const ts = new Date().toISOString(); const mutatedRow = JSON.stringify([pluginId, actingUserId, method, code, ts]); // resource OMITTED const mutatedHash = crypto.createHash('sha256').update('' + mutatedRow).digest('hex'); - testDb - .prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)') - .run(pluginId, actingUserId, method, resource, code, ts, null, mutatedHash); + await insertRow(t, PluginCapabilityAudit, { + plugin_id: pluginId, acting_user_id: actingUserId, method, resource, code, ts, prev_hash: null, hash: mutatedHash, + }); - expect(verifyChain(chainRows(pluginId))).toBe(false); + expect(verifyChain(await chainRows(pluginId))).toBe(false); // Control: the same row, hashed with the REAL (resource-included) construction, verifies. - testDb.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(pluginId); + await deleteRows(t, PluginCapabilityAudit, { plugin_id: pluginId }); legacyAppendAudit('', { pluginId, actingUserId, method, resource, code }); - expect(verifyChain(chainRows(pluginId))).toBe(true); + expect(verifyChain(await chainRows(pluginId))).toBe(true); }); }); diff --git a/server/tests/unit/plugins/plugin-host-state.test.ts b/server/tests/unit/plugins/plugin-host-state.test.ts index 2009ad3ba..0b7fe307f 100644 --- a/server/tests/unit/plugins/plugin-host-state.test.ts +++ b/server/tests/unit/plugins/plugin-host-state.test.ts @@ -10,6 +10,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity'; +import { updateRows } from '../../helpers/factories/rows'; import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository'; import { appendAudit } from '../../../src/nest/plugins/host/plugin-audit'; import { budgetFor, pluginBudgetUsage } from '../../../src/nest/plugins/host/plugin-host-state'; @@ -61,7 +62,7 @@ describe('budgetFor / pluginBudgetUsage seed read', () => { // Backdate the row to yesterday, bypassing the repository (simulating a // real prior day's audit history already on disk). const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - testDb.prepare("UPDATE plugin_capability_audit SET ts = ? WHERE plugin_id = 'hs3'").run(yesterday); + await updateRows(t, PluginCapabilityAudit, { plugin_id: 'hs3' }, { ts: yesterday }); const usage = await pluginBudgetUsage('hs3', audit); expect(usage.ai).toBe(0); diff --git a/server/tests/unit/plugins/plugin-instance-settings.test.ts b/server/tests/unit/plugins/plugin-instance-settings.test.ts index 6d0034acd..75cbc403a 100644 --- a/server/tests/unit/plugins/plugin-instance-settings.test.ts +++ b/server/tests/unit/plugins/plugin-instance-settings.test.ts @@ -45,23 +45,37 @@ import type { PluginActionsRepository } from '../../../src/db/repositories/Plugi import type { PluginUserConfigRepository } from '../../../src/db/repositories/PluginUserConfig.repository'; import type { PluginErrorLogRepository } from '../../../src/db/repositories/PluginErrorLog.repository'; import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository'; +import type { TestOrm } from '../../helpers/test-orm'; +import { deleteRows, findRow, insertRow, updateRows, upsertRow } from '../../helpers/factories/rows'; -function install(id: string) { - testDb - .prepare( - `INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config) - VALUES (?, ?, 'inactive', 0, '1.0.0', '[]', '[]', '{}', '{}')`, - ) - .run(id, id); +let orm: TestOrm; + +/** Plugins the test has switched to active, read by the stubbed runtime's synchronous isActive. */ +const activeIds = new Set(); + +/** Flips the installed plugin to active and enabled, the way an activation leaves the row. */ +async function markActive(id: string) { + await updateRows(orm, Plugins, { id }, { status: 'active', enabled: 1 }); + activeIds.add(id); } -function declareField(pluginId: string, key: string, scope: 'instance' | 'user', opts: { secret?: boolean; required?: boolean; sortOrder?: number; options?: string } = {}) { - testDb - .prepare( - `INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, required, secret, scope, options, sort_order) - VALUES (?, ?, ?, 'text', NULL, NULL, ?, ?, ?, ?, ?)`, - ) - .run(pluginId, key, key, opts.required ? 1 : 0, opts.secret ? 1 : 0, scope, opts.options ?? null, opts.sortOrder ?? 0); +async function install(id: string) { + await upsertRow(orm, Plugins, { + id, name: id, status: 'inactive', enabled: 0, version: '1.0.0', permissions: '[]', granted_permissions: '[]', + capabilities: '{}', config: '{}', + }); + activeIds.delete(id); +} + +async function declareField(pluginId: string, key: string, scope: 'instance' | 'user', opts: { secret?: boolean; required?: boolean; sortOrder?: number; options?: string } = {}) { + await insertRow(orm, PluginSettingsFields, { + plugin_id: pluginId, field_key: key, label: key, input_type: 'text', placeholder: null, hint: null, + required: opts.required ? 1 : 0, secret: opts.secret ? 1 : 0, scope, options: opts.options ?? null, sort_order: opts.sortOrder ?? 0, + }); +} + +function declareActionRow(pluginId: string, key: string, label: string, scope: 'user' | 'instance') { + return insertRow(orm, PluginActions, { plugin_id: pluginId, action_key: key, label, hint: null, danger: 0, scope, sort_order: 0 }); } let addonsService: AddonsService; @@ -109,7 +123,7 @@ async function installFixturePlugin(opts: { settings: Array { addonsService = await createTestAddonsService(testDb); - const orm = await sharedTestOrm(testDb); + orm = await sharedTestOrm(testDb); pluginsRepo = orm.repo(Plugins); pluginEgressHostsRepo = orm.repo(PluginEgressHosts); pluginSettingsFieldsRepo = orm.repo(PluginSettingsFields); @@ -118,9 +132,10 @@ beforeAll(async () => { pluginErrorLogRepo = orm.repo(PluginErrorLog); pluginCapabilityAuditRepo = orm.repo(PluginCapabilityAudit); }); -beforeEach(() => { - testDb.prepare('DELETE FROM plugins').run(); - testDb.prepare('DELETE FROM plugin_settings_fields').run(); +beforeEach(async () => { + await deleteRows(orm, Plugins); + await deleteRows(orm, PluginSettingsFields); + activeIds.clear(); process.env.TREK_PLUGINS_ENABLED = 'true'; codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ins-settings-')); process.env.TREK_PLUGINS_DIR = codeRoot; @@ -132,10 +147,10 @@ afterEach(() => { describe('instance settings fields', () => { 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 + await install('p'); + await declareField('p', 'apiUrl', 'instance', { required: true, sortOrder: 1 }); + await declareField('p', 'apiKey', 'instance', { secret: true, sortOrder: 0 }); + await declareField('p', 'units', 'user', { sortOrder: 2 }); // must never leak into the admin form const fields = await svc().instanceSettingsFields('p'); expect(fields.map((f) => f.key)).toEqual(['apiKey', 'apiUrl']); @@ -144,17 +159,17 @@ describe('instance settings fields', () => { }); it('INS-002 — parses select options like the user form does', async () => { - install('p'); - declareField('p', 'mode', 'instance', { options: '["fast","slow"]' }); + await install('p'); + await declareField('p', 'mode', 'instance', { options: '["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 () => { - install('with-fields'); - install('plain'); - declareField('with-fields', 'apiKey', 'instance', { secret: true }); - declareField('with-fields', 'apiUrl', 'instance'); - declareField('with-fields', 'units', 'user'); // user fields must not count + await install('with-fields'); + await install('plain'); + await declareField('with-fields', 'apiKey', 'instance', { secret: true }); + await declareField('with-fields', 'apiUrl', 'instance'); + await declareField('with-fields', 'units', 'user'); // user fields must not count const plugins = (await svc().list()).plugins; expect(plugins.find((p) => p.id === 'with-fields')).toMatchObject({ instanceSettingsCount: 2 }); @@ -162,19 +177,15 @@ describe('instance settings fields', () => { }); it('INS-004 — the admin list carries the instance-action count (gates the menu item even with no settings fields)', async () => { - install('with-action'); - install('plain'); - testDb - .prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)') - .run('with-action', 'purge', 'Purge', 'instance'); - testDb - .prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)') - .run('with-action', 'notify', 'Notify', 'user'); // user-scope actions must not count + await install('with-action'); + await install('plain'); + await declareActionRow('with-action', 'purge', 'Purge', 'instance'); + await declareActionRow('with-action', 'notify', 'Notify', 'user'); // user-scope actions must not count const plugins = (await svc().list()).plugins; expect(plugins.find((p) => p.id === 'with-action')).toMatchObject({ instanceSettingsCount: 0, instanceActionsCount: 1 }); expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0, instanceActionsCount: 0 }); - testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id = 'with-action'").run(); + await deleteRows(orm, PluginActions, { plugin_id: 'with-action' }); }); it('INS-010 — persists a settings-field default and serves it on the fields list', async () => { @@ -251,13 +262,13 @@ describe('required settings are enforced on save', () => { describe('respawn on save (runtime)', () => { it('INS-004 — an inactive plugin is left alone (no respawn, reports false)', async () => { - install('p'); + await install('p'); const rt = await createPluginRuntime(testDb); await expect(rt.respawnIfActive('p')).resolves.toBe(false); }); it('INS-005 — an active plugin is stopped and re-activated so the child re-reads config', async () => { - install('p'); + await install('p'); const rt = await createPluginRuntime(testDb); const calls: string[] = []; vi.spyOn(rt, 'isActive').mockReturnValue(true); @@ -280,9 +291,9 @@ describe('admin config endpoints (controller)', () => { ); 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(); + await install('p'); + await declareField('p', 'apiUrl', 'instance'); + await updateRows(orm, Plugins, { id: 'p' }, { config: '{"apiUrl":"https://x.example"}' }); const out = await controllerWith({ actionsOf: async () => [] }).getConfig('p'); expect(out.config).toEqual({ apiUrl: 'https://x.example' }); @@ -290,8 +301,8 @@ describe('admin config endpoints (controller)', () => { }); it('INS-007 — PUT :id/config saves, respawns an active plugin, and reports it', async () => { - install('p'); - declareField('p', 'apiUrl', 'instance'); + await install('p'); + await declareField('p', 'apiUrl', 'instance'); const respawnIfActive = vi.fn(async () => true); const out = await controllerWith({ respawnIfActive }).updateConfig('p', { apiUrl: 'https://y.example' }); @@ -301,8 +312,8 @@ describe('admin config endpoints (controller)', () => { }); it('INS-008 — PUT :id/config on an inactive plugin saves without a restart', async () => { - install('p'); - declareField('p', 'apiUrl', 'instance'); + await install('p'); + await declareField('p', 'apiUrl', 'instance'); const respawnIfActive = vi.fn(async () => false); const out = await controllerWith({ respawnIfActive }).updateConfig('p', { apiUrl: 'https://y.example' }); @@ -310,8 +321,8 @@ describe('admin config endpoints (controller)', () => { }); it('INS-009 — PUT :id/config with no body is an empty patch, not a crash', async () => { - install('p'); - declareField('p', 'apiUrl', 'instance'); + await install('p'); + await declareField('p', 'apiUrl', 'instance'); const respawnIfActive = vi.fn(async () => false); const out = await controllerWith({ respawnIfActive }).updateConfig('p', undefined as never); @@ -320,8 +331,8 @@ describe('admin config endpoints (controller)', () => { }); it('INS-010: with the kill switch off the save is refused before anything is written', async () => { - install('p'); - declareField('p', 'apiUrl', 'instance'); + await install('p'); + await declareField('p', 'apiUrl', 'instance'); process.env.TREK_PLUGINS_ENABLED = 'false'; const respawnIfActive = vi.fn(async () => false); @@ -336,8 +347,8 @@ describe('admin config endpoints (controller)', () => { }); it('INS-011: a respawn that fails reports the save that DID happen, and stops claiming the plugin runs', async () => { - install('p'); - declareField('p', 'apiUrl', 'instance'); + await install('p'); + await declareField('p', 'apiUrl', 'instance'); const deactivate = vi.fn(async () => {}); const respawnIfActive = vi.fn(async () => { throw new PluginConsentRequired('plugin p requires consent for db:read:trips', ['db:read:trips']); @@ -387,60 +398,62 @@ describe('defaults reach the child at spawn', () => { }); describe('plugin_actions.scope migration', () => { - it('MIG-ACT-001 — the column exists on a migrated DB and defaults to user', () => { + it('MIG-ACT-001 — the column exists on a migrated DB and defaults to user', async () => { + // test-sql-allow: the column list comes from PRAGMA table_info, which no entity or repository maps. const cols = testDb.prepare("SELECT name FROM pragma_table_info('plugin_actions')").all() as Array<{ name: string }>; expect(cols.some((c) => c.name === 'scope')).toBe(true); - testDb.prepare("INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES ('m', 'k', 'K', NULL, 0, 0)").run(); - expect((testDb.prepare("SELECT scope FROM plugin_actions WHERE plugin_id='m'").get() as { scope: string }).scope).toBe('user'); - testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id='m'").run(); + await insertRow(orm, PluginActions, { plugin_id: 'm', action_key: 'k', label: 'K', hint: null, danger: 0, sort_order: 0 }); + expect((await findRow(orm, PluginActions, { plugin_id: 'm' }))!.scope).toBe('user'); + await deleteRows(orm, PluginActions, { plugin_id: 'm' }); }); }); describe('instance-scope actions (admin)', () => { function declareAction(pluginId: string, key: string, scope: 'user' | 'instance') { - testDb.prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)') - .run(pluginId, key, key, scope); + return declareActionRow(pluginId, key, key, scope); } const adminReq = { user: { id: 42 } } as unknown as Request; async function controller(invoke = vi.fn(async () => ({ ok: true, message: 'pong' }))) { const rt = await createPluginRuntime(testDb); // isActive normally reflects the supervisor's live child map, which nothing here - // spawns — so it's stubbed to read the same DB status the test itself flips, - // mirroring what an actually-activated plugin would report. - const isActive = (id: string) => !!testDb.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id); + // spawns — so it's stubbed to follow the status the test itself flips + // (markActive), mirroring what an actually-activated plugin would report. + const isActive = (id: string) => activeIds.has(id); const runtime = Object.assign(rt, { invokeAction: invoke, isActive }) as unknown as PluginRuntimeService; const c = new PluginsController(svc(), runtime, {} as unknown as PluginRegistryService, { isManaged: () => false } as unknown as RuntimeEnvService); return { c, invoke }; } - beforeEach(() => { testDb.prepare('DELETE FROM plugin_actions').run(); }); + beforeEach(async () => { + await deleteRows(orm, PluginActions); + }); it('ACT-ADM-001 — GET config lists the instance actions and none of the user ones', async () => { - install('p'); - declareAction('p', 'purge', 'instance'); - declareAction('p', 'testConnection', 'user'); + await install('p'); + await declareAction('p', 'purge', 'instance'); + await declareAction('p', 'testConnection', 'user'); const { c } = await controller(); 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 () => { - install('p'); - testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'p'").run(); + await install('p'); + await markActive('p'); const { c, invoke } = await controller(); expect(await c.runAction('p', 'purge', adminReq)).toEqual({ ok: true, message: 'pong' }); expect(invoke).toHaveBeenCalledWith('p', 'purge', 42, 'instance'); }); it('ACT-ADM-003 — an inactive plugin answers 404 like the user route', async () => { - install('p'); + await install('p'); const { c, invoke } = await controller(); await expect(c.runAction('p', 'purge', adminReq)).rejects.toMatchObject({ status: 404, response: { error: 'Plugin is not active' } }); expect(invoke).not.toHaveBeenCalled(); }); it('ACT-ADM-004 — a refused key is a failed RESULT, not a server error', async () => { - install('p'); - testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'p'").run(); + await install('p'); + await markActive('p'); const { c } = await controller(vi.fn(async () => { throw new Error('plugin p did not declare action "x" in scope instance'); })); expect(await c.runAction('p', 'x', adminReq)).toEqual({ ok: false, message: 'plugin p did not declare action "x" in scope instance' }); }); diff --git a/server/tests/unit/plugins/plugin-oauth.service.test.ts b/server/tests/unit/plugins/plugin-oauth.service.test.ts index db807e306..1d8961550 100644 --- a/server/tests/unit/plugins/plugin-oauth.service.test.ts +++ b/server/tests/unit/plugins/plugin-oauth.service.test.ts @@ -40,6 +40,7 @@ import { Plugins } from '../../../src/db/entities/Plugins.entity'; import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity'; import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity'; import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity'; +import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; const CFG = { oauth_authorize_url: 'https://provider.example/authorize', @@ -49,12 +50,22 @@ const CFG = { oauth_client_secret: 'enc:secret-abc', }; -function freshDb(cfg: Record = CFG) { +async function freshDb(cfg: Record = CFG) { const d = createSnapshotTestDb(); - d.prepare("INSERT INTO plugins (id, name, config, status) VALUES ('p', 'p', ?, 'active')").run(JSON.stringify(cfg)); + await insertRow(await sharedTestOrm(d), Plugins, { id: 'p', name: 'p', config: JSON.stringify(cfg), status: 'active' }); return d; } +/** The ORM over whichever fresh db the current case set (memoised per handle). */ +function currentOrm() { + return sharedTestOrm(getDb.current as Database.Database); +} + +/** The pending OAuth state row, or null once it has been consumed. */ +async function stateRow(state: string) { + return findRow(await currentOrm(), PluginOauthState, { state }); +} + const NOW = 1_700_000_000_000; /** @@ -70,11 +81,11 @@ async function makeOauthService(): Promise { describe('PluginOAuthService', () => { let svc: PluginOAuthService; - beforeEach(async () => { getDb.current = freshDb(); svc = await makeOauthService(); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; }); + beforeEach(async () => { getDb.current = await freshDb(); svc = await makeOauthService(); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; }); 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: '' }); + getDb.current = await freshDb({ ...CFG, oauth_client_secret: '' }); expect(await (await makeOauthService()).providerConfig('p')).toBeNull(); }); @@ -82,12 +93,13 @@ describe('PluginOAuthService', () => { // 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; - getDb.current = freshDb(stored); - const d = getDb.current as import('better-sqlite3').Database; - const ins = d.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret, default_value) VALUES ('p', ?, 'instance', 0, ?)"); - ins.run('oauth_authorize_url', JSON.stringify('https://provider.example/authorize')); - ins.run('oauth_token_url', JSON.stringify('https://provider.example/token')); - ins.run('oauth_scopes', JSON.stringify('read')); + getDb.current = await freshDb(stored); + const orm = await currentOrm(); + const declareDefault = (key: string, value: string) => + insertRow(orm, PluginSettingsFields, { plugin_id: 'p', field_key: key, scope: 'instance', secret: 0, default_value: JSON.stringify(value) }); + await declareDefault('oauth_authorize_url', 'https://provider.example/authorize'); + await declareDefault('oauth_token_url', 'https://provider.example/token'); + await declareDefault('oauth_scopes', 'read'); svc = (await makeOauthService()); expect(await svc.providerConfig('p')).toEqual({ authorizeUrl: 'https://provider.example/authorize', @@ -106,12 +118,11 @@ describe('PluginOAuthService', () => { expect(url.searchParams.get('redirect_uri')).toBe('https://trek.example/api/plugin-oauth/p/callback'); expect(url.searchParams.get('code_challenge')).toBeTruthy(); const state = url.searchParams.get('state')!; - const rows = getDb.current as unknown as InstanceType; - const stored = rows.prepare('SELECT verifier, user_id FROM plugin_oauth_state WHERE state = ?').get(state) as { verifier: string; user_id: number }; + const stored = (await stateRow(state))!; expect(stored.user_id).toBe(42); // a second connect replaces the first (one live state per user) 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); + expect(await countRows(await currentOrm(), PluginOauthState, { user_id: 42 })).toBe(1); }); it('startConnect drops the old state and stores the new one together: a failed store keeps the old one', async () => { @@ -124,18 +135,18 @@ describe('PluginOAuthService', () => { }); it('rejects a non-https / loopback / metadata / internal authorize endpoint', async () => { - getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' }); + getDb.current = await freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' }); await expect((await makeOauthService()).startConnect('p', 42, NOW)).rejects.toThrow(/https/); - getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' }); + getDb.current = await freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' }); await expect((await makeOauthService()).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' }); + getDb.current = await freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' }); await expect((await makeOauthService()).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' }); + getDb.current = await freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' }); await expect((await makeOauthService()).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' }); + getDb.current = await freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' }); await expect((await makeOauthService()).startConnect('p', 42, NOW)).rejects.toThrow(/local/); }); @@ -153,8 +164,7 @@ describe('PluginOAuthService', () => { expect(body).toContain('code_verifier='); expect(body).toContain('client_secret=secret-abc'); - const rows = getDb.current as unknown as InstanceType; - 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 }; + const tok = (await findRow(await currentOrm(), PluginOauthTokens, { plugin_id: 'p', user_id: 42 }))!; expect(tok.access_token).toBe('enc:AT'); // encrypted at rest expect(tok.refresh_token).toBe('enc:RT'); expect(await svc.status('p', 42)).toMatchObject({ configured: true, connected: true }); @@ -170,18 +180,18 @@ describe('PluginOAuthService', () => { }); it('getAccessToken returns the token, refreshes an expiring one, and hands the plugin only the access token', async () => { - const rows = getDb.current as unknown as InstanceType; + const orm = await currentOrm(); // a live token → returned decrypted, no network - rows.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token, expires_at) VALUES (?,?,?,?,?)').run('p', 42, 'enc:LIVE', 'enc:RT', NOW + 3600_000); + await insertRow(orm, PluginOauthTokens, { plugin_id: 'p', user_id: 42, access_token: 'enc:LIVE', refresh_token: 'enc:RT', expires_at: NOW + 3600_000 }); expect(await svc.getAccessToken('p', 42, NOW)).toBe('LIVE'); // an expired token → refreshed via the refresh_token grant - rows.prepare('UPDATE plugin_oauth_tokens SET access_token = ?, expires_at = ? WHERE user_id = 42').run('enc:OLD', NOW - 1000); + await updateRows(orm, PluginOauthTokens, { user_id: 42 }, { access_token: 'enc:OLD', expires_at: NOW - 1000 }); const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ access_token: 'NEW', expires_in: 3600 }) } as Response); expect(await svc.getAccessToken('p', 42, NOW)).toBe('NEW'); expect((fetchMock.mock.calls[0][1] as { body: string }).body).toContain('grant_type=refresh_token'); // the provider omitted a new refresh_token → the old one is kept - const tok = rows.prepare('SELECT refresh_token FROM plugin_oauth_tokens WHERE user_id = 42').get() as { refresh_token: string }; + const tok = (await findRow(orm, PluginOauthTokens, { user_id: 42 }))!; expect(tok.refresh_token).toBe('enc:RT'); // a user who never connected → null @@ -198,8 +208,7 @@ describe('PluginOAuthService', () => { }); it('disconnect drops the user\'s tokens', async () => { - const rows = getDb.current as unknown as InstanceType; - rows.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token) VALUES (?,?,?)').run('p', 42, 'enc:X'); + await insertRow(await currentOrm(), PluginOauthTokens, { plugin_id: 'p', user_id: 42, access_token: 'enc:X' }); await svc.disconnect('p', 42); expect((await svc.status('p', 42)).connected).toBe(false); }); @@ -211,19 +220,17 @@ describe('PluginOAuthService', () => { * success one. */ it('single-use: the state row is gone after EITHER the refused path or the success path', async () => { - const rows = getDb.current as unknown as InstanceType; - // Refused path: wrong user — the row is still consumed. const stateA = new URL(await svc.startConnect('p', 42, NOW)).searchParams.get('state')!; - expect(rows.prepare('SELECT 1 FROM plugin_oauth_state WHERE state = ?').get(stateA)).toBeTruthy(); + expect(await stateRow(stateA)).toBeTruthy(); await expect(svc.completeCallback('p', 99, 'code', stateA, NOW + 1000)).rejects.toThrow(/state/); - expect(rows.prepare('SELECT 1 FROM plugin_oauth_state WHERE state = ?').get(stateA)).toBeUndefined(); + expect(await stateRow(stateA)).toBeNull(); // Success path. const stateB = new URL(await svc.startConnect('p', 42, NOW)).searchParams.get('state')!; vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ access_token: 'AT' }) } as Response); await svc.completeCallback('p', 42, 'code', stateB, NOW + 1000); - expect(rows.prepare('SELECT 1 FROM plugin_oauth_state WHERE state = ?').get(stateB)).toBeUndefined(); + expect(await stateRow(stateB)).toBeNull(); }); /** @@ -251,7 +258,6 @@ describe('PluginOAuthService', () => { expect((rejected[0] as PromiseRejectedResult).reason as Error).toMatchObject({ message: expect.stringMatching(/state/) }); // The token exchange fired exactly once — the loser never reached it. expect(fetchMock).toHaveBeenCalledTimes(1); - const rows = getDb.current as unknown as InstanceType; - expect(rows.prepare('SELECT 1 FROM plugin_oauth_state WHERE state = ?').get(state)).toBeUndefined(); + expect(await stateRow(state)).toBeNull(); }); }); diff --git a/server/tests/unit/plugins/plugin-route-normalize.test.ts b/server/tests/unit/plugins/plugin-route-normalize.test.ts index 27e32ff28..5c0baf93e 100644 --- a/server/tests/unit/plugins/plugin-route-normalize.test.ts +++ b/server/tests/unit/plugins/plugin-route-normalize.test.ts @@ -11,6 +11,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; +import { insertRow } from '../../helpers/factories/rows'; import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository'; import { declaredProfiles } from '../../../src/nest/plugins/contributions/plugin-route-normalize'; @@ -25,18 +26,18 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function seedPlugin(id: string, capabilities: unknown): void { - testDb.prepare('INSERT INTO plugins (id, name, capabilities) VALUES (?, ?, ?)').run(id, id, JSON.stringify(capabilities)); +async function seedPlugin(id: string, capabilities: unknown): Promise { + await insertRow(t, Plugins, { id, name: id, capabilities: JSON.stringify(capabilities) }); } describe('declaredProfiles', () => { it('RN-001: returns the declared routeProfiles ids, filtered to the safe id pattern', async () => { - seedPlugin('roadtrip-plugin', { routeProfiles: [{ id: 'eco' }, { id: 'fast-ev' }, { id: 'BAD ID' }, { id: '' }] }); + await seedPlugin('roadtrip-plugin', { routeProfiles: [{ id: 'eco' }, { id: 'fast-ev' }, { id: 'BAD ID' }, { id: '' }] }); expect(await declaredProfiles(plugins, 'roadtrip-plugin')).toEqual(['eco', 'fast-ev']); }); it('RN-002: no routeProfiles capability → empty list', async () => { - seedPlugin('no-routes', { widget: { slot: 'sidebar' } }); + await seedPlugin('no-routes', { widget: { slot: 'sidebar' } }); expect(await declaredProfiles(plugins, 'no-routes')).toEqual([]); }); @@ -45,7 +46,7 @@ describe('declaredProfiles', () => { }); it('RN-004: malformed capabilities JSON → empty list, never throws', async () => { - testDb.prepare('INSERT INTO plugins (id, name, capabilities) VALUES (?, ?, ?)').run('bad-json', 'bad-json', 'not json'); + await insertRow(t, Plugins, { id: 'bad-json', name: 'bad-json', capabilities: 'not json' }); expect(await declaredProfiles(plugins, 'bad-json')).toEqual([]); }); }); diff --git a/server/tests/unit/plugins/plugin-user-settings.test.ts b/server/tests/unit/plugins/plugin-user-settings.test.ts index a8899065a..a1e73b192 100644 --- a/server/tests/unit/plugins/plugin-user-settings.test.ts +++ b/server/tests/unit/plugins/plugin-user-settings.test.ts @@ -29,6 +29,7 @@ import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFie import { PluginActions } from '../../../src/db/entities/PluginActions.entity'; import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity'; import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity'; +import { insertRow, insertRows } from '../../helpers/factories/rows'; /** Plan 3j Task 2 — PluginsService's own six repositories, over whichever fresh `getDb.current` the caller just set. */ async function makePluginsService(): Promise { @@ -54,22 +55,28 @@ async function userSettings(): Promise { return new PluginUserSettingsService(orm.repo(PluginSettingsFields), orm.repo(PluginUserConfig)); } -function freshDb() { +async function freshDb() { const d = createSnapshotTestDb(); // p: a user-scope api key (secret) + a user-scope pref (not secret) + an INSTANCE field. - const ins = d.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order) VALUES (?,?,?,?,?,?,?)'); - ins.run('p', 'apiKey', 'text', 1, 1, 'user', 0); - ins.run('p', 'units', 'select', 0, 0, 'user', 1); - ins.run('p', 'adminOnly', 'text', 0, 1, 'instance', 2); + await insertRows(await sharedTestOrm(d), PluginSettingsFields, [ + { plugin_id: 'p', field_key: 'apiKey', input_type: 'text', required: 1, secret: 1, scope: 'user', sort_order: 0 }, + { plugin_id: 'p', field_key: 'units', input_type: 'select', required: 0, secret: 0, scope: 'user', sort_order: 1 }, + { plugin_id: 'p', field_key: 'adminOnly', input_type: 'text', required: 0, secret: 1, scope: 'instance', sort_order: 2 }, + ]); return d; } +/** The ORM over whichever fresh db the current case set (memoised per handle). */ +function currentOrm() { + return sharedTestOrm(getDb.current as Database.Database); +} + describe('per-user plugin settings', () => { let svc: PluginsService; // AddonsService only feeds PluginsService.list(), which no case here calls, but it is a // real collaborator on the same connection rather than a stand-in. beforeEach(async () => { - getDb.current = freshDb(); + getDb.current = await freshDb(); svc = await makePluginsService(); }); @@ -120,14 +127,13 @@ describe('manifest defaults reach the runtime reads', () => { // that ships a sensible default serves nobody until every user opens the form. let svc: PluginsService; beforeEach(async () => { - getDb.current = freshDb(); + getDb.current = await freshDb(); svc = await makePluginsService(); - const ins = (getDb.current as import('better-sqlite3').Database).prepare( - 'INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order, default_value) VALUES (?,?,?,?,?,?,?,?)', - ); - ins.run('p', 'region', 'select', 0, 0, 'user', 3, JSON.stringify('eu')); - ins.run('p', 'retries', 'number', 0, 0, 'user', 4, JSON.stringify(3)); - ins.run('p', 'endpoint', 'text', 1, 0, 'user', 5, JSON.stringify('https://api.example')); + await insertRows(await currentOrm(), PluginSettingsFields, [ + { plugin_id: 'p', field_key: 'region', input_type: 'select', required: 0, secret: 0, scope: 'user', sort_order: 3, default_value: JSON.stringify('eu') }, + { plugin_id: 'p', field_key: 'retries', input_type: 'number', required: 0, secret: 0, scope: 'user', sort_order: 4, default_value: JSON.stringify(3) }, + { plugin_id: 'p', field_key: 'endpoint', input_type: 'text', required: 1, secret: 0, scope: 'user', sort_order: 5, default_value: JSON.stringify('https://api.example') }, + ]); }); it('readOne falls back to the declared default when nothing is stored', async () => { @@ -162,11 +168,11 @@ describe('hasRequired applies the same "filled" rule as the save gate', () => { // the user "not configured" (or the reverse). let svc: PluginsService; beforeEach(async () => { - getDb.current = freshDb(); + getDb.current = await freshDb(); svc = await makePluginsService(); - (getDb.current as import('better-sqlite3').Database) - .prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order) VALUES (?,?,?,?,?,?,?)') - .run('p', 'consent', 'checkbox', 1, 0, 'user', 9); + await insertRow(await currentOrm(), PluginSettingsFields, { + plugin_id: 'p', field_key: 'consent', input_type: 'checkbox', required: 1, secret: 0, scope: 'user', sort_order: 9, + }); }); it('exempts a required checkbox (consent, not a settings field)', async () => { @@ -175,9 +181,9 @@ describe('hasRequired applies the same "filled" rule as the save gate', () => { }); 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 })); + await insertRow(await currentOrm(), PluginUserConfig, { + plugin_id: 'p', user_id: 42, config: JSON.stringify({ apiKey: ' ', consent: true }), updated_at: '', + }); expect(await (await userSettings()).hasRequired('p', 42)).toBe(false); }); }); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index fe0db26f4..9b91f323d 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -14,6 +14,8 @@ import { db as testDb } from '../../../src/db/database'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; import { createTestAddonsService } from '../../helpers/test-addons'; import { sharedTestOrm } from '../../helpers/test-uow'; +import type { TestOrm } from '../../helpers/test-orm'; +import { deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity'; import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity'; @@ -30,10 +32,17 @@ import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env // resolution; none of the fixtures below declare any, so it is built once // (over the same connection) rather than reconstructed at every call site. let addonsService: AddonsService; +let orm: TestOrm; beforeAll(async () => { addonsService = await createTestAddonsService(testDb); + orm = await sharedTestOrm(testDb); }); +/** The plugin's stored instance config, parsed. */ +async function storedConfig(id: string): Promise> { + return JSON.parse((await findRow(orm, Plugins, { id }))!.config ?? ''); +} + /** * Plan 3j Task 2 — PluginsService's own repository-backed constructor. One * factory, reused by every call site below (`sharedTestOrm` memoises the ORM @@ -65,9 +74,9 @@ async function makeFeedController(): Promise { return new PluginsFeedController(orm.repo(Plugins)); } -beforeEach(() => { - testDb.exec('DELETE FROM plugins'); - testDb.exec('DELETE FROM plugin_settings_fields'); +beforeEach(async () => { + await deleteRows(orm, Plugins); + await deleteRows(orm, PluginSettingsFields); delete process.env.TREK_PLUGINS_ENABLED; }); afterEach(() => { @@ -76,9 +85,7 @@ afterEach(() => { describe('PluginsService.list', () => { it('returns the installed plugins and the runtime-enabled flag', async () => { - testDb - .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') - .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); + await insertRow(orm, Plugins, { id: 'flight', name: 'Flight', description: 'desc', type: 'widget', status: 'inactive', version: '1.0.0' }); process.env.TREK_PLUGINS_ENABLED = 'true'; const out = await (await makeService()).list(); @@ -95,13 +102,11 @@ describe('PluginsService.list', () => { else process.env.APP_VERSION = APP_VERSION; }); const seed = () => - testDb - .prepare("INSERT INTO plugins (id, name, type, status, version, trek_range) VALUES ('old','Old','widget','inactive','1.0.0','>=3.0.0 <4.0.0')") - .run(); + insertRow(orm, Plugins, { id: 'old', name: 'Old', type: 'widget', status: 'inactive', version: '1.0.0', trek_range: '>=3.0.0 <4.0.0' }); it('reports the switch off and an outgrown plugin as hostIncompatible by default', async () => { process.env.APP_VERSION = '4.1.0'; - seed(); + await seed(); const out = await (await makeService()).list(); expect(out.ignoreTrekRange).toBe(false); expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'hostIncompatible', trekRangeBypassed: null }); @@ -110,7 +115,7 @@ describe('PluginsService.list', () => { it('with the switch on, the plugin may activate but the row still says it is outside its range', async () => { process.env.APP_VERSION = '4.1.0'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; - seed(); + await seed(); const out = await (await makeService()).list(); expect(out.ignoreTrekRange).toBe(true); expect(out.plugins[0]).toMatchObject({ @@ -122,19 +127,15 @@ describe('PluginsService.list', () => { it('a plugin inside its range carries no marker even with the switch on', async () => { process.env.APP_VERSION = '3.5.0'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; - seed(); + await seed(); const out = await (await makeService()).list(); expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', trekRangeBypassed: null }); }); }); it('surfaces updateHold as a boolean (held plugins leave the update banner)', async () => { - 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(); + await insertRow(orm, Plugins, { id: 'held', name: 'Held', type: 'widget', status: 'inactive', version: '1.0.0', update_hold: 1 }); + await insertRow(orm, Plugins, { id: 'free', name: 'Free', type: 'widget', status: 'inactive', version: '1.0.0' }); const out = await (await makeService()).list(); expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true }); @@ -142,20 +143,16 @@ describe('PluginsService.list', () => { }); 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(); + await insertRow(orm, Plugins, { id: 'held', name: 'Held', type: 'widget', status: 'inactive', version: '1.0.0', update_hold: 1 }); const svc = await makeService(); expect(await svc.resumeUpdates('held')).toBe(true); - expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 }); + expect(await findRow(orm, Plugins, { id: 'held' })).toMatchObject({ update_hold: 0 }); expect(await svc.resumeUpdates('ghost')).toBe(false); }); it('reports enabled by default (no kill switch set)', async () => { - testDb - .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') - .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); + await insertRow(orm, Plugins, { id: 'flight', name: 'Flight', description: 'desc', type: 'widget', status: 'inactive', version: '1.0.0' }); const out = await (await makeService()).list(); expect(out.enabled).toBe(true); @@ -176,13 +173,13 @@ describe('PluginsService.list', () => { // 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); + insertRow(orm, Plugins, { + id, name: id, type: 'widget', status: 'inactive', version: '1.0.0', source_repo: sourceRepo, author_pubkey: pubkey, + }); it('reports signed + a display fingerprint for a registry plugin with a pinned key', async () => { const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'; - insert('signed-one', 'acme/signed-one', key); + await insert('signed-one', 'acme/signed-one', key); const p = (await (await makeService()).list()).plugins[0]; expect(p.signed).toBe(true); @@ -193,15 +190,15 @@ describe('PluginsService.list', () => { }); it('reports unsigned for a registry plugin with no pinned key', async () => { - insert('plain', 'acme/plain', null); + await insert('plain', 'acme/plain', null); const p = (await (await makeService()).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)', async () => { - insert('uploaded', 'local:upload', null); - insert('linked', 'local:link', null); + await insert('uploaded', 'local:upload', null); + await insert('linked', 'local:link', null); const plugins = (await (await makeService()).list()).plugins; expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([ ['linked', false, 'local:link'], @@ -210,11 +207,11 @@ describe('PluginsService.list', () => { }); it('surfaces a recorded update block, and reports none when there is none', async () => { - 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'); + await insert('blocked', 'acme/blocked', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'); + await insert('fine', 'acme/fine', null); + await updateRows(orm, Plugins, { id: 'blocked' }, { + update_block_code: 'SIGNATURE_KEY_CHANGED', update_block_detail: 'the key changed', update_block_version: '2.0.0', + }); const byId = Object.fromEntries((await (await makeService()).list()).plugins.map((p) => [p.id, p])); expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' }); @@ -222,7 +219,7 @@ describe('PluginsService.list', () => { }); it('never leaks the raw pinned key into the list response (only the fingerprint)', async () => { - insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'); + await 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. @@ -243,8 +240,8 @@ describe('PluginsService.list', () => { describe('PluginsFeedController (client feed)', () => { 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(); + await insertRow(orm, Plugins, { id: 'w', name: 'W', type: 'widget', icon: 'Box', status: 'active' }); + await insertRow(orm, Plugins, { id: 'i', name: 'I', type: 'integration', icon: 'Plug', status: 'inactive' }); const feed = await makeFeedController(); process.env.TREK_PLUGINS_ENABLED = 'true'; @@ -256,8 +253,8 @@ describe('PluginsFeedController (client feed)', () => { }); 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(); + await insertRow(orm, Plugins, { id: 'h', name: 'H', type: 'widget', icon: 'Box', status: 'active', capabilities: '{"widget":{"slot":"hero"}}' }); + await insertRow(orm, Plugins, { id: 'b', name: 'B', type: 'widget', icon: 'Box', status: 'active', capabilities: 'not-json' }); process.env.TREK_PLUGINS_ENABLED = 'true'; const out = await (await makeFeedController()).list(); expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero'); @@ -265,14 +262,14 @@ describe('PluginsFeedController (client feed)', () => { }); 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(); + await insertRow(orm, Plugins, { id: 'd', name: 'D', type: 'widget', icon: 'Box', status: 'active', capabilities: '{"widget":{"slot":"day-detail"}}' }); process.env.TREK_PLUGINS_ENABLED = 'true'; expect((await (await makeFeedController()).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail'); }); 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(); + await insertRow(orm, Plugins, { id: 'su', name: 'S', type: 'widget', icon: 'Box', status: 'active', capabilities: '{"settingsUi":true}' }); + await insertRow(orm, Plugins, { id: 'no', name: 'N', type: 'widget', icon: 'Box', status: 'active', capabilities: '{"settingsUi":"yes"}' }); process.env.TREK_PLUGINS_ENABLED = 'true'; const out = await (await makeFeedController()).list(); expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true); @@ -280,17 +277,17 @@ describe('PluginsFeedController (client feed)', () => { }); 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(); + await insertRow(orm, Plugins, { id: 'r', name: 'R', type: 'widget', icon: 'Box', status: 'active', capabilities: '{"widget":{"slot":"reservation-detail"}}' }); process.env.TREK_PLUGINS_ENABLED = 'true'; expect((await (await makeFeedController()).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', 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(); + await insertRow(orm, Plugins, { id: 't', name: 'T', type: 'trip-page', icon: 'Box', status: 'active', capabilities: '{"tripPage":{"replaces":["transports","buchungen"],"position":1}}' }); // 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(); + await insertRow(orm, Plugins, { id: 'evil', name: 'E', type: 'trip-page', icon: 'Box', status: 'active', capabilities: '{"tripPage":{"replaces":["plan","nope"],"position":-3}}' }); // 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(); + await insertRow(orm, Plugins, { id: 'w2', name: 'W2', type: 'widget', icon: 'Box', status: 'active', capabilities: '{"tripPage":{"replaces":["transports"]}}' }); process.env.TREK_PLUGINS_ENABLED = 'true'; const out = await (await makeFeedController()).list(); expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 }); @@ -349,9 +346,9 @@ describe('PluginsController M2 endpoints', () => { describe('PluginsService instance config', () => { 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(); + await insertRow(orm, Plugins, { id: 'x', name: 'X', status: 'inactive', config: '{}' }); + await insertRow(orm, PluginSettingsFields, { plugin_id: 'x', field_key: 'api_key', scope: 'instance', secret: 1 }); + await insertRow(orm, PluginSettingsFields, { plugin_id: 'x', field_key: 'server', scope: 'instance', secret: 0 }); const svc = await makeService(); const masked = await svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' }); @@ -360,29 +357,29 @@ describe('PluginsService instance config', () => { 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); + const stored = await storedConfig('x'); 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 await svc.updateInstanceConfig('x', { api_key: '••••••••' }); - const still = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config); + const still = await storedConfig('x'); expect(still.api_key).toBe(stored.api_key); expect((await svc.getInstanceConfig('x')).api_key).toBe('••••••••'); }); 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(); + await insertRow(orm, Plugins, { id: 'y', name: 'Y', status: 'inactive', config: '{}' }); + await insertRow(orm, PluginSettingsFields, { plugin_id: 'y', field_key: 'server', scope: 'instance', secret: 0 }); const svc = await makeService(); const masked = await 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); + const stored = await storedConfig('y'); expect(stored).toEqual({ server: 'https://h' }); }); @@ -393,9 +390,11 @@ describe('PluginsService instance config', () => { }); describe('PluginsService error log', () => { - beforeEach(() => testDb.exec('DELETE FROM plugin_error_log')); + beforeEach(async () => { + await deleteRows(orm, PluginErrorLog); + }); it('lists and clears a plugin error log', async () => { - testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message, ts) VALUES ('p','error','boom','2026-01-01')").run(); + await insertRow(orm, PluginErrorLog, { plugin_id: 'p', level: 'error', message: 'boom', ts: '2026-01-01' }); const svc = await makeService(); expect(await svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]); await svc.clearErrors('p'); diff --git a/server/tests/unit/plugins/request-context.test.ts b/server/tests/unit/plugins/request-context.test.ts index f0a00b5cc..34fd795de 100644 --- a/server/tests/unit/plugins/request-context.test.ts +++ b/server/tests/unit/plugins/request-context.test.ts @@ -39,6 +39,7 @@ import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-su import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; import { RpcRateLimiter, DEFAULT_RPC_LIMIT } from '../../../src/nest/plugins/host/rate-limit'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { setAppSetting } from '../../helpers/factories/settings'; import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; @@ -80,7 +81,7 @@ beforeAll(async () => { guards = new PluginGuards(t.repo(Trips), permissions, await createTestAddonsService(testDb), t.repo(Users)); userId = createUser(testDb, { role: 'user' }).user.id; // An admin has tightened trip_create from its 'everybody' default. - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run(); + await setAppSetting(t, 'perm_trip_create', 'admin'); }); afterAll(async () => {