From 57404dee17484a643d02f34d75edd16cc9853130 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 16:03:41 +0200 Subject: [PATCH] test(server): seed the plugin specs through the repositories The plugin service, instance and user settings, OAuth, audit, route profile, host state and request context suites seeded plugin rows, settings fields, actions, tokens and audit entries with raw statements and read them back the same way. They now go through the ORM factories and readers over the suite's own test ORM. Two raw statements stay, marked: the PRAGMA table_info read that proves the actions scope column exists, and the audit insert that reproduces the pre-conversion appendAudit byte for byte for the hash chain parity check. The instance settings suite's stubbed isActive now follows the status the test flips through markActive instead of a synchronous read. --- .../tests/unit/plugins/plugin-audit.test.ts | 51 +++--- .../unit/plugins/plugin-host-state.test.ts | 3 +- .../plugins/plugin-instance-settings.test.ts | 155 ++++++++++-------- .../unit/plugins/plugin-oauth.service.test.ts | 72 ++++---- .../plugins/plugin-route-normalize.test.ts | 11 +- .../unit/plugins/plugin-user-settings.test.ts | 46 +++--- .../unit/plugins/plugins-service.test.ts | 117 +++++++------ .../unit/plugins/request-context.test.ts | 3 +- 8 files changed, 248 insertions(+), 210 deletions(-) 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 () => {