Files
TREK/server/tests/unit/plugins/discovery.test.ts
T
jubnl 1853156da3 fix(server): plan 4 — plugin discovery's delete+reinsert pairs are atomic
install/discovery.ts#upsert refreshed a plugin's actions and settings-field
descriptors as two independent delete-then-reinsert pairs (DI5-DI8) with no
transaction spanning either pair, let alone both: a crash between a delete
and its own reinsert left that plugin with an EMPTY actions or
settings-field set until the next discovery run, instead of its previous
still-valid rows. Wraps both pairs in one uow.transactional, so a discovery
refresh for a plugin's descriptors is now all-or-nothing.

Deliberate behaviour change, named for the user per the task brief.

DiscoveryRepos grows a UnitOfWork; PluginRegistryService (the other
production caller, registry.service.ts) picks it up the same @Optional()
way PluginRuntimeService already does, with the same guard-throw. Updates
the two hand-constructed test call sites that actually exercise discovery
(discovery.test.ts, registry.test.ts) to pass a real UnitOfWork.
2026-09-24 06:17:23 +02:00

180 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Plugin discovery (#plugins, M4, install-from-disk): scans the volume, upserts
* rows as inactive, refreshes settings fields, keeps an existing plugin's status,
* and skips invalid or native-carrying plugins (logging the reason).
*
* Plan 3j Task 3 — converted onto `PluginsRepository`/`PluginActionsRepository`/
* `PluginSettingsFieldsRepository`/`PluginErrorLogRepository` (a `DiscoveryRepos`
* bundle), via a real MikroORM over the full migrated schema (`createSnapshotTestDb`
* + `createTestOrm`) rather than a hand-rolled `:memory:` table set.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
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 { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { discoverPlugins, type DiscoveryRepos } from '../../../src/nest/plugins/install/discovery';
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repos: DiscoveryRepos;
let codeRoot: string;
function writePlugin(id: string, manifest: Record<string, unknown>, extra?: () => void) {
const dir = path.join(codeRoot, id, 'server');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(codeRoot, id, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', ...manifest }));
fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports={}');
extra?.()
}
beforeAll(async () => {
t = await createTestOrm(testDb);
repos = { plugins: t.repo(Plugins), actions: t.repo(PluginActions), settingsFields: t.repo(PluginSettingsFields), errorLog: t.repo(PluginErrorLog), uow: new UnitOfWork(t.em) };
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'disc-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
});
afterEach(() => {
delete process.env.TREK_PLUGINS_DIR;
fs.rmSync(codeRoot, { recursive: true, force: true });
});
afterAll(async () => { await t.close(); testDb.close(); });
describe('discoverPlugins', () => {
it('registers a new plugin inactive with its settings fields', async () => {
writePlugin('flight-tracker', {
name: 'Flight',
type: 'widget',
permissions: ['db:own'],
settings: [
{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true },
{ key: 'units', input_type: 'select', scope: 'user', options: [{ value: 'm', label: 'Metric' }] },
{ key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } },
],
});
const res = await discoverPlugins(repos);
expect(res.discovered).toEqual(['flight-tracker']);
const row = testDb.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string };
expect(row.status).toBe('inactive');
expect(row.type).toBe('widget');
expect(JSON.parse(row.permissions)).toEqual(['db:own']);
const field = testDb.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id='flight-tracker'").get() as { field_key: string; secret: number };
expect(field).toMatchObject({ field_key: 'api_key', secret: 1 });
});
it('persists each action with its scope', async () => {
writePlugin('acts', {
name: 'Acts', type: 'integration', permissions: [],
actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }],
});
await discoverPlugins(repos);
const rows = testDb.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all();
expect(rows).toEqual([
{ action_key: 'ping', scope: 'user', danger: 0 },
{ action_key: 'purge', scope: 'instance', danger: 1 },
]);
});
it('keeps an existing plugin status + granted permissions on re-discovery', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run();
writePlugin('keep', { name: 'Keep v2', type: 'page', version: '2.0.0' });
await discoverPlugins(repos);
const row = testDb.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string };
expect(row.status).toBe('active'); // not downgraded
expect(row.version).toBe('2.0.0'); // metadata refreshed
expect(JSON.parse(row.granted_permissions)).toEqual(['db:own']); // grants preserved
});
it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', async () => {
writePlugin('bom-plug', { type: 'integration' });
const mp = path.join(codeRoot, 'bom-plug', 'trek-plugin.json');
fs.writeFileSync(mp, '' + fs.readFileSync(mp, 'utf8'));
expect((await discoverPlugins(repos)).discovered).toEqual(['bom-plug']);
});
it('skips an invalid manifest and logs the reason', async () => {
writePlugin('bad', { type: 'not-a-type' });
const res = await discoverPlugins(repos);
expect(res.skipped).toEqual(['bad']);
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 });
expect((testDb.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery');
});
it('skips a plugin that ships native binaries', async () => {
writePlugin('native', { type: 'integration' }, () => {
fs.writeFileSync(path.join(codeRoot, 'native', 'server', 'addon.node'), '\0');
});
expect((await discoverPlugins(repos)).skipped).toEqual(['native']);
});
it('follows a symlinked dev-link plugin only when dev-link mode is on', async () => {
const srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'disc-src-'));
const prev = process.env.TREK_PLUGINS_DEV_LINK;
try {
const src = path.join(srcRoot, 'linked');
fs.mkdirSync(path.join(src, 'server'), { recursive: true });
fs.writeFileSync(path.join(src, 'trek-plugin.json'), JSON.stringify({ id: 'linked', name: 'Linked', version: '1.0.0', type: 'integration', permissions: ['db:own'] }));
fs.writeFileSync(path.join(src, 'server', 'index.js'), 'module.exports={}');
fs.symlinkSync(src, path.join(codeRoot, 'linked'), 'junction'); // junction on Windows, symlink on POSIX
// Off (default): a stale dev-link symlink is not discovered or registered.
delete process.env.TREK_PLUGINS_DEV_LINK;
expect((await discoverPlugins(repos)).discovered).toEqual([]);
expect(testDb.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toBeUndefined();
// On: the dev-link is followed and registered inactive.
process.env.TREK_PLUGINS_DEV_LINK = '1';
expect((await discoverPlugins(repos)).discovered).toEqual(['linked']);
expect(testDb.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toMatchObject({ status: 'inactive' });
} finally {
if (prev === undefined) delete process.env.TREK_PLUGINS_DEV_LINK; else process.env.TREK_PLUGINS_DEV_LINK = prev;
fs.rmSync(srcRoot, { recursive: true, force: true });
}
});
it('is a no-op when the plugins dir is absent', async () => {
process.env.TREK_PLUGINS_DIR = path.join(codeRoot, 'does-not-exist');
expect(await discoverPlugins(repos)).toEqual({ discovered: [], skipped: [] });
});
describe('the TREK range', () => {
it('persists the range and its lower bound', async () => {
writePlugin('ranged', { trek: '>=3.2.0 <4.0.0' });
await discoverPlugins(repos);
expect(testDb.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get())
.toMatchObject({ trek_range: '>=3.2.0 <4.0.0', min_trek_version: '3.2.0' });
});
it('still registers a plugin that declares NO range — it must not vanish', async () => {
// Discovery is a reconciler, not a gate: it only logs and skips on a throw and never
// touches the plugins row, so refusing here would leave an existing plugin's stale
// enabled=1 row to be spawned by the next boot — invisible AND running. The row is
// registered with a null range and the activation gate refuses it (TREK_VERSION_UNKNOWN).
writePlugin('rangeless', {});
expect((await discoverPlugins(repos)).discovered).toEqual(['rangeless']);
expect(testDb.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null });
});
it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', async () => {
writePlugin('shrink', { trek: '>=3.0.0' });
await discoverPlugins(repos);
writePlugin('shrink', { trek: '>=3.0.0 <3.1.0' });
await discoverPlugins(repos);
expect(testDb.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' });
});
});
});