mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Boot-order regression (#plugins): the RPC registry must be scanned before the
|
||||
* runtime auto-activates the plugins an admin had enabled.
|
||||
*
|
||||
* The bug this pins: PluginRuntimeService booted enabled plugins from
|
||||
* onModuleInit, and Nest fires same-module onModuleInit hooks in providers-array
|
||||
* declaration order — where the runtime service is listed BEFORE
|
||||
* PluginRpcRegistryService. Each boot activation builds its PluginRpcHost
|
||||
* synchronously (bindInto snapshots the registry at construction), so every host
|
||||
* was bound from a STILL-EMPTY registry: the plugin's first RPC on activation
|
||||
* (typically ctx.db.migrate in onLoad) came back PERMISSION_DENIED and the plugin
|
||||
* landed in error state on every cold boot, until an admin deactivated and
|
||||
* reactivated it by hand. The fix moves the boot reconcile to
|
||||
* onApplicationBootstrap, which Nest guarantees runs after EVERY module's
|
||||
* onModuleInit — so this suite assembles a real Nest module mirroring the
|
||||
* production declaration order (runtime provider first, registry scan later) and
|
||||
* asserts the plugin still comes up clean.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
|
||||
const { testDb } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, api_version INTEGER DEFAULT 1, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
|
||||
config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT,
|
||||
trek_range TEXT DEFAULT '>=3.0.0',
|
||||
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
|
||||
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
|
||||
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key));
|
||||
CREATE TABLE plugin_meta_migrations (plugin_id TEXT, migration_id TEXT, PRIMARY KEY (plugin_id, migration_id));
|
||||
CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT);
|
||||
CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name));
|
||||
CREATE TABLE plugin_user_erasure_queue (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, user_id INTEGER NOT NULL, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, user_id));
|
||||
CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER DEFAULT 0);
|
||||
CREATE TABLE audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`);
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
||||
import { PluginRpcHostFactory } from '../../../src/nest/plugins/host/plugin-rpc-host.factory';
|
||||
import { PluginRpcRegistry } from '../../../src/nest/plugins/host/rpc-kit/registry';
|
||||
import type { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
|
||||
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
|
||||
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
let mod: TestingModule;
|
||||
|
||||
beforeAll(() => {
|
||||
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-code-'));
|
||||
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-data-'));
|
||||
process.env.TREK_PLUGINS_DIR = codeRoot;
|
||||
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
|
||||
// The exact shape the bug bit: a plugin that runs its own-db migration as the
|
||||
// first thing its onLoad does — what every db-backed plugin's activation looks like.
|
||||
const dir = path.join(codeRoot, 'migrator', 'server');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'index.js'),
|
||||
`module.exports = { async onLoad(ctx) { await ctx.db.migrate('001', 'CREATE TABLE t (n INTEGER)'); } };`,
|
||||
);
|
||||
// The row a container recreate leaves behind: enabled, already consented to
|
||||
// db:own. Seeded 'inactive' (not the 'active' a real shutdown leaves) so the
|
||||
// poll below only terminates on a status the THIS-boot supervisor wrote.
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config) VALUES ('migrator','inactive',1,'[\"db:own\"]','[\"db:own\"]','{}')")
|
||||
.run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mod?.close();
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
delete process.env.TREK_PLUGINS_DATA_DIR;
|
||||
delete process.env.TREK_PLUGINS_ENABLED;
|
||||
fs.rmSync(codeRoot, { recursive: true, force: true });
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('plugin boot vs registry scan ordering', () => {
|
||||
it('BOOT-REG-001 a plugin enabled before a restart activates cleanly even though the registry scan runs in a LATER provider\'s onModuleInit', async () => {
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
const userSettings = new PluginUserSettingsService(dbs);
|
||||
// Empty at construction — exactly what PluginRpcRegistryService is before its
|
||||
// own onModuleInit scan has run.
|
||||
const registry = new PluginRpcRegistry();
|
||||
const hostFactory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService);
|
||||
|
||||
mod = await Test.createTestingModule({
|
||||
providers: [
|
||||
// Mirrors plugins-runtime.module.ts: the runtime service is DECLARED BEFORE
|
||||
// the provider that populates the registry, so Nest fires its onModuleInit
|
||||
// (if it had one) first. The boot reconcile must therefore not live there.
|
||||
{
|
||||
provide: PluginRuntimeService,
|
||||
useFactory: () => new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), userSettings, undefined, hostFactory),
|
||||
},
|
||||
{
|
||||
provide: 'REGISTRY_SCAN',
|
||||
useFactory: () => ({
|
||||
onModuleInit() {
|
||||
registry.register(new DbRpc(userSettings));
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await mod.init();
|
||||
|
||||
// Boot activation is fire-and-forget; wait for the supervisor to settle the row.
|
||||
const runtime = mod.get(PluginRuntimeService);
|
||||
let row = { status: 'starting', last_error: null as string | null };
|
||||
for (let i = 0; i < 100; i++) {
|
||||
row = testDb.prepare("SELECT status, last_error FROM plugins WHERE id='migrator'").get() as typeof row;
|
||||
if (row.status === 'active' || row.status === 'error') break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
expect(row.last_error).toBeNull();
|
||||
expect(row.status).toBe('active');
|
||||
expect(runtime.isActive('migrator')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -27,9 +27,12 @@ const { testDb } = vi.hoisted(() => {
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
@@ -58,7 +61,7 @@ beforeAll(() => {
|
||||
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
process.env.TREK_PLUGINS_DEV_LINK = '1';
|
||||
runtime = new PluginRuntimeService();
|
||||
runtime = createPluginRuntime(new DatabaseService(dbConn));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ const { testDb } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
|
||||
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, api_version INTEGER DEFAULT 1, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
|
||||
config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT,
|
||||
-- Activation refuses a plugin that declares no TREK range, so the fixtures default to a
|
||||
-- satisfied one: these tests are about permissions/dependencies, and every row would
|
||||
@@ -43,9 +43,12 @@ const { testDb } = vi.hoisted(() => {
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { DependencyCycleError } from '../../../src/nest/plugins/dependencies';
|
||||
|
||||
let codeRoot: string;
|
||||
@@ -77,7 +80,7 @@ beforeAll(() => {
|
||||
);
|
||||
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run();
|
||||
runtime = new PluginRuntimeService();
|
||||
runtime = createPluginRuntime(new DatabaseService(dbConn));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -145,9 +148,9 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
// first activate is covered by the 'counter' happy-path test above.)
|
||||
});
|
||||
|
||||
it('onModuleInit is a no-op when the runtime is disabled', () => {
|
||||
it('onApplicationBootstrap is a no-op when the runtime is disabled', () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
expect(() => new PluginRuntimeService().onModuleInit()).not.toThrow();
|
||||
expect(() => createPluginRuntime(new DatabaseService(dbConn)).onApplicationBootstrap()).not.toThrow();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
});
|
||||
|
||||
@@ -170,7 +173,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('messy','inactive','not-json','not-json')").run();
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
await rt.activate('messy'); // must not throw despite the garbage JSON
|
||||
expect(rt.isActive('messy')).toBe(true);
|
||||
await rt.deactivate('messy');
|
||||
@@ -243,33 +246,33 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='counter' AND user_id=9").get()).toMatchObject({ c: 0 });
|
||||
});
|
||||
|
||||
it('onModuleInit boots every ENABLED plugin — even one left in error state', async () => {
|
||||
it('onApplicationBootstrap boots every ENABLED plugin — even one left in error state', async () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'booter', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'booter', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
// status='error' from a previous crash, but enabled=1 → must still boot
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')").run();
|
||||
|
||||
const rt = new PluginRuntimeService();
|
||||
rt.onModuleInit(); // fire-and-forget spawn
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
rt.onApplicationBootstrap(); // fire-and-forget spawn
|
||||
for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50));
|
||||
expect(rt.isActive('booter')).toBe(true);
|
||||
await rt.deactivate('booter');
|
||||
});
|
||||
|
||||
it('onModuleInit does NOT boot a disabled plugin', async () => {
|
||||
it('onApplicationBootstrap does NOT boot a disabled plugin', async () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'sleeper', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'sleeper', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')").run();
|
||||
|
||||
const rt = new PluginRuntimeService();
|
||||
rt.onModuleInit();
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
rt.onApplicationBootstrap();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(rt.isActive('sleeper')).toBe(false);
|
||||
});
|
||||
|
||||
it('outboundHostsOf extracts declared http:outbound hosts', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run();
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
|
||||
expect(rt.outboundHostsOf('missing')).toEqual([]);
|
||||
});
|
||||
@@ -291,7 +294,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES ('gone', 'poll', 0)").run();
|
||||
testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('gone', 7)").run();
|
||||
|
||||
await new PluginRuntimeService().uninstall('gone', true);
|
||||
await createPluginRuntime(new DatabaseService(dbConn)).uninstall('gone', true);
|
||||
|
||||
expect(fs.existsSync(path.join(codeRoot, 'gone'))).toBe(false);
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='gone'").get()).toMatchObject({ c: 0 });
|
||||
@@ -310,7 +313,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
// a deleted user's erasure is still pending for this plugin
|
||||
testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('keepdata', 42)").run();
|
||||
|
||||
await new PluginRuntimeService().uninstall('keepdata', false); // keep the plugin's data
|
||||
await createPluginRuntime(new DatabaseService(dbConn)).uninstall('keepdata', false); // keep the plugin's data
|
||||
|
||||
// the plugin row is gone, but the erasure obligation is retained — a reinstall of the
|
||||
// same id will drain it and finally honour the deletion, instead of silently keeping
|
||||
@@ -325,12 +328,12 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
"module.exports = { async onLoad() { await fetch('https://blocked.example/x'); } };");
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('nettry','inactive','[]','{}')").run();
|
||||
// no http:outbound granted -> egress guard blocks all outbound -> onLoad throws
|
||||
await expect(new PluginRuntimeService().activate('nettry')).rejects.toThrow(/egress/);
|
||||
await new PluginRuntimeService().deactivate('nettry').catch(() => {});
|
||||
await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/);
|
||||
await createPluginRuntime(new DatabaseService(dbConn)).deactivate('nettry').catch(() => {});
|
||||
});
|
||||
|
||||
it('onModuleDestroy tears down cleanly', async () => {
|
||||
await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined();
|
||||
await expect(createPluginRuntime(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,7 +355,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
|
||||
|
||||
it('restarts transparently when the new version requests no new permissions', async () => {
|
||||
seed('upd-same', 1, ['db:own'], ['db:own']);
|
||||
const rt = new PluginRuntimeService(fakeRegistry(() => {})); // declared perms unchanged
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})); // declared perms unchanged
|
||||
await rt.activate('upd-same');
|
||||
const res = await rt.update('upd-same');
|
||||
expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] });
|
||||
@@ -362,7 +365,8 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
|
||||
|
||||
it('leaves the plugin inactive and reports the delta when new rights are requested', async () => {
|
||||
seed('upd-wider', 1, ['db:own'], ['db:own']);
|
||||
const rt = new PluginRuntimeService(
|
||||
const rt = createPluginRuntime(
|
||||
new DatabaseService(dbConn),
|
||||
fakeRegistry(() => {
|
||||
testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'")
|
||||
.run(JSON.stringify(['db:own', 'db:read:trips', 'http:outbound:api.new.com']));
|
||||
@@ -379,17 +383,17 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
|
||||
|
||||
it('a disabled plugin stays disabled even with no new permissions', async () => {
|
||||
seed('upd-off', 0, ['db:own'], ['db:own']);
|
||||
const res = await new PluginRuntimeService(fakeRegistry(() => {})).update('upd-off');
|
||||
const res = await createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})).update('upd-off');
|
||||
expect(res).toMatchObject({ activated: false, newPermissions: [], newEgress: [] });
|
||||
});
|
||||
|
||||
it('throws for an unknown plugin id', async () => {
|
||||
await expect(new PluginRuntimeService(fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/);
|
||||
await expect(createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('throws if no registry service is wired', async () => {
|
||||
seed('upd-noreg', 0, ['db:own'], ['db:own']);
|
||||
await expect(new PluginRuntimeService().update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
|
||||
await expect(createPluginRuntime(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -556,7 +560,7 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
it('re-pins AND installs in one call: the new key is pinned, the version moved, the block gone', async () => {
|
||||
seed('rt-ok', 'OLDKEY');
|
||||
const registry = registryFor('rt-ok');
|
||||
const rt = new PluginRuntimeService(registry);
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn), registry);
|
||||
await rt.activate('rt-ok');
|
||||
|
||||
await rt.retrust('rt-ok', '2.0.0', 'NEWKEY', { userId: 1, ip: '10.0.0.1' });
|
||||
@@ -601,7 +605,7 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
seed('rt-no', 'OLDKEY');
|
||||
const registry = registryFor('rt-no');
|
||||
vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust'));
|
||||
const rt = new PluginRuntimeService(registry);
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn), registry);
|
||||
|
||||
await expect(rt.retrust('rt-no', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
|
||||
|
||||
@@ -627,7 +631,7 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
// exactly the "quietly stops updating" failure the block exists to prevent.
|
||||
it('activate does NOT clear a recorded update block', async () => {
|
||||
seed('rt-act', 'OLDKEY');
|
||||
const rt = new PluginRuntimeService(registryFor('rt-act'));
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn), registryFor('rt-act'));
|
||||
|
||||
await rt.deactivate('rt-act');
|
||||
await rt.activate('rt-act');
|
||||
@@ -663,7 +667,7 @@ describe('TREK host-version gating', () => {
|
||||
it('refuses to activate a plugin this TREK has outgrown, and says so with a code', async () => {
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
seedRanged('outgrown', '>=3.2.0 <4.0.0');
|
||||
const err = await new PluginRuntimeService().activate('outgrown').catch((e) => e);
|
||||
const err = await createPluginRuntime(new DatabaseService(dbConn)).activate('outgrown').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(PluginDependencyError);
|
||||
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
expect(err.detail).toMatchObject({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' });
|
||||
@@ -675,14 +679,14 @@ describe('TREK host-version gating', () => {
|
||||
it('refuses a plugin that never declared a range at all', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
seedRanged('undeclared', null);
|
||||
await expect(new PluginRuntimeService().activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
|
||||
await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
|
||||
cleanup('undeclared');
|
||||
});
|
||||
|
||||
it('activates normally when the range admits the host', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
seedRanged('fits', '>=3.2.0 <4.0.0');
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
await rt.activate('fits');
|
||||
expect(rt.isActive('fits')).toBe(true);
|
||||
await rt.deactivate('fits');
|
||||
@@ -696,8 +700,8 @@ describe('TREK host-version gating', () => {
|
||||
// panel showing a green, running plugin that does not exist.
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
seedRanged('boot-broken', '>=3.2.0 <4.0.0', 1);
|
||||
const rt = new PluginRuntimeService();
|
||||
expect(() => rt.onModuleInit()).not.toThrow();
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
expect(() => rt.onApplicationBootstrap()).not.toThrow();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const row = testDb.prepare("SELECT enabled FROM plugins WHERE id='boot-broken'").get() as { enabled: number };
|
||||
if (row.enabled === 0) break;
|
||||
@@ -714,7 +718,43 @@ describe('TREK host-version gating', () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'both-wrong', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'both-wrong', 'server', 'index.js'), 'module.exports = {};');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('both-wrong','inactive',0,'[\"db:own\"]','[]','{}','>=3.2.0 <4.0.0')").run();
|
||||
await expect(new PluginRuntimeService().activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
cleanup('both-wrong');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The activation gate also refuses a plugin whose manifest `apiVersion` is newer than
|
||||
* what this TREK's plugin-API surface supports (`PLUGIN_API_VERSION`) — mirrors the
|
||||
* TREK-version gate above, just for the plugin-API surface instead of the host version.
|
||||
*/
|
||||
describe('plugin-API version gating', () => {
|
||||
const seedApi = (id: string, apiVersion: number) => {
|
||||
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range, api_version) VALUES (?, 'inactive', 0, '[]', '[]', '{}', '>=3.0.0', ?)",
|
||||
)
|
||||
.run(id, apiVersion);
|
||||
};
|
||||
const cleanup = (...ids: string[]) => { for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); };
|
||||
|
||||
it('refuses to activate a plugin whose apiVersion this TREK does not support', async () => {
|
||||
seedApi('future-api', 2);
|
||||
const err = await createPluginRuntime(new DatabaseService(dbConn)).activate('future-api').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(PluginDependencyError);
|
||||
expect(err).toMatchObject({ code: 'API_VERSION_INCOMPATIBLE' });
|
||||
expect(err.message).toBe('plugin requires plugin-API v2; this TREK supports v1');
|
||||
cleanup('future-api');
|
||||
});
|
||||
|
||||
it('activates normally when apiVersion is within what this TREK supports', async () => {
|
||||
seedApi('current-api', 1);
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
await rt.activate('current-api');
|
||||
expect(rt.isActive('current-api')).toBe(true);
|
||||
await rt.deactivate('current-api');
|
||||
cleanup('current-api');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,14 +23,17 @@ const { testDb } = vi.hoisted(() => {
|
||||
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT,
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT,
|
||||
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
||||
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, update_hold INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service';
|
||||
import type { ManifestPreview } from '../../../src/nest/plugins/registry/registry.service';
|
||||
|
||||
// ── tiny tar.gz builder (wraps the plugin in a codeload-style top dir) ────────
|
||||
function tarHeader(name: string, size: number, typeflag = '0'): Buffer {
|
||||
@@ -86,7 +89,7 @@ beforeEach(() => {
|
||||
testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log');
|
||||
__clearRegistryCacheForTests();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response));
|
||||
svc = new PluginRegistryService();
|
||||
svc = new PluginRegistryService(new DatabaseService(dbConn));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -179,6 +182,27 @@ describe('PluginRegistryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('detail carries the UI-facing capabilities, so the review sheet can chip a tab takeover', async () => {
|
||||
safeDownload.mockResolvedValue({
|
||||
bytes: Buffer.from(JSON.stringify({
|
||||
id: 'flight-tracker', permissions: [], egress: [],
|
||||
capabilities: { widget: { slot: 'hero' }, tripPage: { replaces: ['places'], position: 3 }, settingsUi: true },
|
||||
})),
|
||||
sha256: 'unused',
|
||||
});
|
||||
const d = (await svc.detail('flight-tracker')) as { manifest: ManifestPreview };
|
||||
expect(d.manifest.capabilities).toEqual({ widget: { slot: 'hero' }, tripPage: { replaces: ['places'] } });
|
||||
});
|
||||
|
||||
it('detail leaves capabilities empty when the manifest declares none', async () => {
|
||||
safeDownload.mockResolvedValue({
|
||||
bytes: Buffer.from(JSON.stringify({ id: 'flight-tracker', permissions: [], capabilities: { widget: {} } })),
|
||||
sha256: 'unused',
|
||||
});
|
||||
const d = (await svc.detail('flight-tracker')) as { manifest: ManifestPreview };
|
||||
expect(d.manifest.capabilities).toEqual({ widget: {} });
|
||||
});
|
||||
|
||||
it('detail soft-fails the manifest fetch (registry metadata still renders)', async () => {
|
||||
safeDownload.mockRejectedValue(new Error('offline'));
|
||||
const d = await svc.detail('flight-tracker');
|
||||
@@ -233,10 +257,127 @@ describe('PluginRegistryService', () => {
|
||||
await expect(svc.detail('ghost')).rejects.toThrow(RegistryError);
|
||||
});
|
||||
|
||||
// The per-plugin update hold: a DELIBERATE non-latest install excludes the plugin from
|
||||
// the update banner until the admin resumes updates or lands back on the newest
|
||||
// compatible version. Dependency resolution pins versions too, but never deliberately —
|
||||
// the `explicit` flag is what separates the two.
|
||||
describe('recomputeUpdateHold', () => {
|
||||
const twoVersions = {
|
||||
schemaVersion: 1,
|
||||
plugins: [
|
||||
{
|
||||
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight', type: 'widget',
|
||||
versions: [
|
||||
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64) },
|
||||
{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const seedRow = (hold = 0) =>
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, update_hold) VALUES ('flight-tracker','inactive',0,?)").run(hold);
|
||||
const holdInDb = () =>
|
||||
(testDb.prepare("SELECT update_hold FROM plugins WHERE id='flight-tracker'").get() as { update_hold: number }).update_hold;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => twoVersions }) as unknown as Response));
|
||||
__clearRegistryCacheForTests();
|
||||
});
|
||||
|
||||
it('holds when the admin explicitly picked an older version', async () => {
|
||||
seedRow();
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(true);
|
||||
expect(holdInDb()).toBe(1);
|
||||
});
|
||||
|
||||
it('does not hold when the explicit pick IS the newest compatible version', async () => {
|
||||
seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
});
|
||||
|
||||
// The comparison is against the newest version THIS TREK can run, not the newest
|
||||
// published one. Both fixtures above leave `trek` unset, so every version is
|
||||
// compatible and the two readings agree; only a version this host must skip tells
|
||||
// them apart. Getting it wrong stamps a hold on an admin who picked the best
|
||||
// available version, and the row silently leaves the banner and "Update all".
|
||||
it('does not hold when a newer version exists but this TREK cannot run it', async () => {
|
||||
const withIncompatibleNewest = {
|
||||
schemaVersion: 1,
|
||||
plugins: [
|
||||
{
|
||||
...twoVersions.plugins[0],
|
||||
versions: [
|
||||
{ version: '3.0.0', gitTag: 'v3.0.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/3', sha256: '3'.repeat(64), trek: '>=4.0.0' },
|
||||
...twoVersions.plugins[0].versions,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => withIncompatibleNewest }) as unknown as Response));
|
||||
__clearRegistryCacheForTests();
|
||||
try {
|
||||
seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
} finally {
|
||||
delete process.env.APP_VERSION;
|
||||
}
|
||||
});
|
||||
|
||||
it('a non-deliberate update clears a stale hold', async () => {
|
||||
seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', false)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
});
|
||||
|
||||
it('an unresolvable registry never sets a hold', async () => {
|
||||
seedRow();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
__clearRegistryCacheForTests();
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The version picker's data: every published version, each with its OWN server-computed
|
||||
// compat verdict — the client has no semver and must never re-derive range logic.
|
||||
it('detail lists every published version with its own compat verdict', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
const multi = {
|
||||
schemaVersion: 1,
|
||||
plugins: [
|
||||
{
|
||||
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
|
||||
type: 'widget', authorPublicKey: 'K'.repeat(44),
|
||||
versions: [
|
||||
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64), trek: '>=4.0.0', publishedAt: '2026-08-01', size: 2048, signature: 'sig2' },
|
||||
{ version: '1.5.0', gitTag: 'v1.5.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/15', sha256: '1'.repeat(64), trek: '>=3.0.0 <4.0.0', publishedAt: '2026-07-01', size: 1024 },
|
||||
{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64), minTrekVersion: '3.0.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => multi }) as unknown as Response));
|
||||
__clearRegistryCacheForTests();
|
||||
try {
|
||||
const d = (await svc.detail('flight-tracker')) as { versions: unknown };
|
||||
expect(d.versions).toEqual([
|
||||
{ version: '2.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
|
||||
{ version: '1.5.0', publishedAt: '2026-07-01', size: 1024, signed: false, trek: '>=3.0.0 <4.0.0', compatible: true },
|
||||
// A legacy entry composes its requirement from the min/max bounds it has.
|
||||
{ version: '1.0.0', publishedAt: null, size: null, signed: false, trek: '>=3.0.0', compatible: true },
|
||||
]);
|
||||
} finally {
|
||||
delete process.env.APP_VERSION;
|
||||
}
|
||||
});
|
||||
|
||||
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
|
||||
__clearRegistryCacheForTests();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
expect((await new PluginRegistryService().fetchRegistry()).plugins).toEqual([]);
|
||||
expect((await new PluginRegistryService(new DatabaseService(dbConn)).fetchRegistry()).plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => {
|
||||
@@ -800,6 +941,14 @@ describe('TREK-version gating on install', () => {
|
||||
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined });
|
||||
expect(() => svc.stageUpload(bytes)).toThrow(/missing "trek"/);
|
||||
});
|
||||
|
||||
it('refuses an uploaded archive declaring apiVersion 2 (this TREK only speaks plugin-API v1), and writes nothing to the plugin code dir', () => {
|
||||
// trek range is satisfiable on its own — parseManifest's apiVersion check must
|
||||
// fire (and stop the upload) before assertHostCompatible is ever reached.
|
||||
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', apiVersion: 2 });
|
||||
expect(() => svc.stageUpload(bytes)).toThrow('plugin requires plugin-API v2; this TREK supports v1');
|
||||
expect(fs.existsSync(path.join(codeRoot, 'my-upload'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('installWithDependencies installs the version it RESOLVED, not versions[0]', async () => {
|
||||
|
||||
@@ -14,6 +14,17 @@ import path from 'node:path';
|
||||
import { PluginSupervisor, type SupervisorHooks, type SupervisorTuning } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
||||
import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
|
||||
import { PluginDataDb } from '../../../src/nest/plugins/host/plugin-data.service';
|
||||
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
||||
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
|
||||
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
||||
|
||||
/**
|
||||
* DbRpc also carries `settings.get`, which needs the per-user settings store. No
|
||||
* fixture below calls it, and none of these plugins has a stored setting anyway, so
|
||||
* the stub is the one read DbRpc makes, typed off the real signature and answering
|
||||
* "nothing stored" exactly as the real service would.
|
||||
*/
|
||||
const stubUserSettings: Pick<PluginUserSettingsService, 'readOne'> = { readOne: () => undefined };
|
||||
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
@@ -31,12 +42,12 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning:
|
||||
const createRpcHost = (id: string, granted: ReadonlySet<string>): PluginRpcHost => {
|
||||
const deps: HostDeps = {
|
||||
data: new PluginDataDb(id),
|
||||
db: { prepare: () => ({ all: () => [], get: () => null }) },
|
||||
canAccessTrip: () => undefined,
|
||||
broadcastToTrip: (tripId, event, payload) => broadcasts.push({ tripId, event, payload }),
|
||||
broadcastToUser: () => {},
|
||||
callPlugin: async () => undefined,
|
||||
emitPluginEvent: (event, payload) => broadcasts.push({ id, event, payload }),
|
||||
};
|
||||
return new PluginRpcHost(id, granted, deps);
|
||||
// Only db.* is exercised from a child here; the rest of the surface has its own
|
||||
// unit suites and would drag every domain service into this integration test.
|
||||
return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc(stubUserSettings as PluginUserSettingsService)]));
|
||||
};
|
||||
const hooks: SupervisorHooks = {
|
||||
onEvent: (_id, topic, data) => events.push({ topic, data }),
|
||||
|
||||
Reference in New Issue
Block a user