mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
lint + prettier
This commit is contained in:
@@ -3,10 +3,15 @@
|
||||
* HTTP route works through the host→child invoke path, using its own isolated
|
||||
* db. Proves the full activate → route → deactivate loop.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest';
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DependencyCycleError } from '../../../src/nest/plugins/dependencies';
|
||||
import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { testDb } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -43,12 +48,8 @@ const { testDb } = vi.hoisted(() => {
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
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';
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
@@ -78,7 +79,9 @@ beforeAll(async () => {
|
||||
};`,
|
||||
);
|
||||
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run();
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')")
|
||||
.run();
|
||||
runtime = await createPluginRuntime(dbConn);
|
||||
});
|
||||
|
||||
@@ -122,12 +125,17 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('invoke on a plugin that is not running rejects', async () => {
|
||||
await expect(runtime.invoke('never-activated', 'invoke.route', { routeId: 0, req: {} })).rejects.toThrow(/not active/);
|
||||
await expect(runtime.invoke('never-activated', 'invoke.route', { routeId: 0, req: {} })).rejects.toThrow(
|
||||
/not active/,
|
||||
);
|
||||
});
|
||||
|
||||
it('a route that throws surfaces as a rejected invoke', async () => {
|
||||
await expect(
|
||||
runtime.invoke('counter', 'invoke.route', { routeId: 1, req: { method: 'GET', path: '/boom', query: {}, body: null, user: null } }),
|
||||
runtime.invoke('counter', 'invoke.route', {
|
||||
routeId: 1,
|
||||
req: { method: 'GET', path: '/boom', query: {}, body: null, user: null },
|
||||
}),
|
||||
).rejects.toThrow(/route fail/);
|
||||
});
|
||||
|
||||
@@ -137,11 +145,19 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
|
||||
it('re-consent gate: activating a consented plugin with WIDER permissions is refused without consent', async () => {
|
||||
// consented to db:own; a version now declaring db:read:users too must not auto-grant
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, granted_permissions, config) VALUES ('widener','inactive','[\"db:own\",\"db:read:users\"]','[\"db:own\"]','{}')").run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, permissions, granted_permissions, config) VALUES ('widener','inactive','[\"db:own\",\"db:read:users\"]','[\"db:own\"]','{}')",
|
||||
)
|
||||
.run();
|
||||
await expect(runtime.activate('widener')).rejects.toThrow(/re-consent|new permissions/);
|
||||
// the key case: a plugin consented to ZERO perms ('[]') is still "consented" —
|
||||
// a later widening to db:own is blocked, not silently granted.
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, granted_permissions, config) VALUES ('zeroperm','inactive','[\"db:own\"]','[]','{}')").run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, permissions, granted_permissions, config) VALUES ('zeroperm','inactive','[\"db:own\"]','[]','{}')",
|
||||
)
|
||||
.run();
|
||||
await expect(runtime.activate('zeroperm')).rejects.toThrow(/re-consent|new permissions/);
|
||||
// (a NEVER-consented plugin — granted '' — consenting to its declared set on
|
||||
// first activate is covered by the 'counter' happy-path test above.)
|
||||
@@ -163,7 +179,10 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
it('deactivate stops the plugin and clears the enabled intent', async () => {
|
||||
await runtime.deactivate('counter');
|
||||
expect(runtime.isActive('counter')).toBe(false);
|
||||
const row = testDb.prepare("SELECT status, enabled FROM plugins WHERE id = 'counter'").get() as { status: string; enabled: number };
|
||||
const row = testDb.prepare("SELECT status, enabled FROM plugins WHERE id = 'counter'").get() as {
|
||||
status: string;
|
||||
enabled: number;
|
||||
};
|
||||
expect(row.status).toBe('inactive');
|
||||
expect(row.enabled).toBe(0);
|
||||
});
|
||||
@@ -178,17 +197,23 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
it('tolerates malformed granted_permissions / config JSON on activate', async () => {
|
||||
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();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, permissions, config) VALUES ('messy','inactive','not-json','not-json')",
|
||||
)
|
||||
.run();
|
||||
const rt = await createPluginRuntime(dbConn);
|
||||
await rt.activate('messy'); // must not throw despite the garbage JSON
|
||||
expect(rt.isActive('messy')).toBe(true);
|
||||
await rt.deactivate('messy');
|
||||
});
|
||||
|
||||
it('erases and exports a user\'s own-db data through the GDPR hooks', async () => {
|
||||
it("erases and exports a user's own-db data through the GDPR hooks", async () => {
|
||||
const gdir = path.join(codeRoot, 'gdpr', 'server');
|
||||
fs.mkdirSync(gdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(gdir, 'index.js'), `module.exports = {
|
||||
fs.writeFileSync(
|
||||
path.join(gdir, 'index.js'),
|
||||
`module.exports = {
|
||||
async onLoad(ctx) { await ctx.db.migrate('001', 'CREATE TABLE prefs (user_id INTEGER, v TEXT)'); },
|
||||
routes: [{ method: 'POST', path: '/seed', auth: true, async handler(req, ctx) {
|
||||
await ctx.db.exec('INSERT INTO prefs (user_id, v) VALUES (?, ?)', req.user.id, 'x');
|
||||
@@ -196,11 +221,19 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
}}],
|
||||
async exportUserData({ userId }, ctx) { return await ctx.db.query('SELECT v FROM prefs WHERE user_id = ?', userId); },
|
||||
async deleteUserData({ userId }, ctx) { await ctx.db.exec('DELETE FROM prefs WHERE user_id = ?', userId); },
|
||||
};`);
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('gdpr','active','[\"db:own\",\"hook:user-data\"]','{}')").run();
|
||||
};`,
|
||||
);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, permissions, config) VALUES ('gdpr','active','[\"db:own\",\"hook:user-data\"]','{}')",
|
||||
)
|
||||
.run();
|
||||
await runtime.activate('gdpr');
|
||||
// seed a row for user 5
|
||||
await runtime.invoke('gdpr', 'invoke.route', { routeId: 0, req: { method: 'POST', path: '/seed', query: {}, body: null, user: { id: 5, username: 'a', isAdmin: false } } });
|
||||
await runtime.invoke('gdpr', 'invoke.route', {
|
||||
routeId: 0,
|
||||
req: { method: 'POST', path: '/seed', query: {}, body: null, user: { id: 5, username: 'a', isAdmin: false } },
|
||||
});
|
||||
|
||||
// export sees the user's data, aggregated under the plugin id
|
||||
expect(await runtime.exportUserData(5)).toEqual([{ pluginId: 'gdpr', data: [{ v: 'x' }] }]);
|
||||
@@ -210,7 +243,9 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
await (runtime as any).enqueueUserErasure(5);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (runtime as any).drainUserErasures();
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='gdpr'").get()).toMatchObject({ c: 0 });
|
||||
expect(
|
||||
testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='gdpr'").get(),
|
||||
).toMatchObject({ c: 0 });
|
||||
// the export is now empty (rows gone), proving the handler ran end-to-end
|
||||
expect(await runtime.exportUserData(5)).toEqual([{ pluginId: 'gdpr', data: [] }]);
|
||||
|
||||
@@ -231,32 +266,56 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (runtime as any).drainUserErasures();
|
||||
expect((testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='reaptest'").get() as { c: number }).c).toBe(1);
|
||||
expect(
|
||||
(
|
||||
testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='reaptest'").get() as {
|
||||
c: number;
|
||||
}
|
||||
).c,
|
||||
).toBe(1);
|
||||
|
||||
// now the data is truly gone (deleteData=true / manual removal) → the row is reaped
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (runtime as any).drainUserErasures();
|
||||
expect((testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='reaptest'").get() as { c: number }).c).toBe(0);
|
||||
expect(
|
||||
(
|
||||
testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='reaptest'").get() as {
|
||||
c: number;
|
||||
}
|
||||
).c,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves an erasure queued for a plugin that is offline, to run when it is back', async () => {
|
||||
// granted the hook but NOT active → enqueue keeps the row, drain leaves it
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('offliner','inactive','[\"db:own\",\"hook:user-data\"]','{}')").run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, permissions, config) VALUES ('offliner','inactive','[\"db:own\",\"hook:user-data\"]','{}')",
|
||||
)
|
||||
.run();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (runtime as any).enqueueUserErasure(9);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (runtime as any).drainUserErasures();
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='offliner' AND user_id=9").get()).toMatchObject({ c: 1 });
|
||||
expect(
|
||||
testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='offliner' AND user_id=9").get(),
|
||||
).toMatchObject({ c: 1 });
|
||||
// a plugin WITHOUT the grant is never enqueued in the first place
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='counter' AND user_id=9").get()).toMatchObject({ c: 0 });
|
||||
expect(
|
||||
testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='counter' AND user_id=9").get(),
|
||||
).toMatchObject({ c: 0 });
|
||||
});
|
||||
|
||||
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();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')",
|
||||
)
|
||||
.run();
|
||||
|
||||
const rt = await createPluginRuntime(dbConn);
|
||||
await rt.onApplicationBootstrap(); // fire-and-forget spawn
|
||||
@@ -268,7 +327,11 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
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();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')",
|
||||
)
|
||||
.run();
|
||||
|
||||
const rt = await createPluginRuntime(dbConn);
|
||||
await rt.onApplicationBootstrap();
|
||||
@@ -277,7 +340,11 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('outboundHostsOf extracts declared http:outbound hosts', async () => {
|
||||
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();
|
||||
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 = await createPluginRuntime(dbConn);
|
||||
expect(await rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
|
||||
expect(await rt.outboundHostsOf('missing')).toEqual([]);
|
||||
@@ -287,16 +354,34 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'gone', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'gone', 'server', 'index.js'), 'module.exports={}');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('gone','inactive','[]','{}')").run();
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('gone','k','instance',0)").run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('gone','k','instance',0)",
|
||||
)
|
||||
.run();
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'plugin:gone:units', 'metric')").run();
|
||||
// Per-user secrets + OAuth tokens live in their OWN tables, not under settings —
|
||||
// a "delete all data" that leaves these behind leaks encrypted keys/tokens that a
|
||||
// same-id reinstall silently re-adopts (audit finding).
|
||||
testDb.prepare("INSERT INTO plugin_user_config (plugin_id, user_id, field_key, value) VALUES ('gone', 1, 'apiKey', 'enc:secret')").run();
|
||||
testDb.prepare("INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token) VALUES ('gone', 1, 'at', 'rt')").run();
|
||||
testDb.prepare("INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier) VALUES ('s1', 'gone', 1, 'v')").run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugin_user_config (plugin_id, user_id, field_key, value) VALUES ('gone', 1, 'apiKey', 'enc:secret')",
|
||||
)
|
||||
.run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token) VALUES ('gone', 1, 'at', 'rt')",
|
||||
)
|
||||
.run();
|
||||
testDb
|
||||
.prepare("INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier) VALUES ('s1', 'gone', 1, 'v')")
|
||||
.run();
|
||||
testDb.prepare("INSERT INTO plugin_meta_migrations (plugin_id, migration_id) VALUES ('gone', '001')").run();
|
||||
testDb.prepare("INSERT INTO plugin_capability_audit (plugin_id, method, code, ts, hash) VALUES ('gone', 'trips.getById', 'OK', 't', 'h')").run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugin_capability_audit (plugin_id, method, code, ts, hash) VALUES ('gone', 'trips.getById', 'OK', 't', 'h')",
|
||||
)
|
||||
.run();
|
||||
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();
|
||||
|
||||
@@ -304,10 +389,22 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
|
||||
expect(fs.existsSync(path.join(codeRoot, 'gone'))).toBe(false);
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='gone'").get()).toMatchObject({ c: 0 });
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_settings_fields WHERE plugin_id='gone'").get()).toMatchObject({ c: 0 });
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM settings WHERE key LIKE 'plugin:gone:%'").get()).toMatchObject({ c: 0 });
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_settings_fields WHERE plugin_id='gone'").get()).toMatchObject({
|
||||
c: 0,
|
||||
});
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM settings WHERE key LIKE 'plugin:gone:%'").get()).toMatchObject({
|
||||
c: 0,
|
||||
});
|
||||
// the secret-bearing tables + the erasure queue must be purged too (data is gone)
|
||||
for (const t of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state', 'plugin_meta_migrations', 'plugin_capability_audit', 'plugin_scheduled_tasks', 'plugin_user_erasure_queue']) {
|
||||
for (const t of [
|
||||
'plugin_user_config',
|
||||
'plugin_oauth_tokens',
|
||||
'plugin_oauth_state',
|
||||
'plugin_meta_migrations',
|
||||
'plugin_capability_audit',
|
||||
'plugin_scheduled_tasks',
|
||||
'plugin_user_erasure_queue',
|
||||
]) {
|
||||
expect(testDb.prepare(`SELECT COUNT(*) c FROM ${t} WHERE plugin_id='gone'`).get()).toMatchObject({ c: 0 });
|
||||
}
|
||||
});
|
||||
@@ -315,7 +412,9 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
it('uninstall WITHOUT deleteData keeps pending GDPR erasures (the data survives, so must the obligation)', async () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'keepdata', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'keepdata', 'server', 'index.js'), 'module.exports={}');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('keepdata','inactive','[]','{}')").run();
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('keepdata','inactive','[]','{}')")
|
||||
.run();
|
||||
// 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();
|
||||
|
||||
@@ -325,14 +424,22 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
// same id will drain it and finally honour the deletion, instead of silently keeping
|
||||
// the user's data forever.
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='keepdata'").get()).toMatchObject({ c: 0 });
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='keepdata' AND user_id=42").get()).toMatchObject({ c: 1 });
|
||||
expect(
|
||||
testDb
|
||||
.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='keepdata' AND user_id=42")
|
||||
.get(),
|
||||
).toMatchObject({ c: 1 });
|
||||
});
|
||||
|
||||
it('the egress guard blocks a fetch to an undeclared host', async () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'nettry', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'nettry', 'server', 'index.js'),
|
||||
"module.exports = { async onLoad() { await fetch('https://blocked.example/x'); } };");
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('nettry','inactive','[]','{}')").run();
|
||||
fs.writeFileSync(
|
||||
path.join(codeRoot, 'nettry', 'server', 'index.js'),
|
||||
"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((await createPluginRuntime(dbConn)).activate('nettry')).rejects.toThrow(/egress/);
|
||||
await (await createPluginRuntime(dbConn)).deactivate('nettry').catch(() => {});
|
||||
@@ -349,19 +456,28 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
|
||||
const fakeRegistry = (impl: () => void, latest = '2.0.0') =>
|
||||
({
|
||||
resolveVersion: vi.fn(async () => ({ version: latest })),
|
||||
install: vi.fn(async () => { impl(); return { id: 'x', version: latest }; }),
|
||||
install: vi.fn(async () => {
|
||||
impl();
|
||||
return { id: 'x', version: latest };
|
||||
}),
|
||||
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
|
||||
|
||||
const seed = (id: string, enabled: number, permissions: string[], granted: string[]) => {
|
||||
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) VALUES (?,?,?,?,?,?)')
|
||||
testDb
|
||||
.prepare(
|
||||
'INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config) VALUES (?,?,?,?,?,?)',
|
||||
)
|
||||
.run(id, enabled ? 'active' : 'inactive', enabled, JSON.stringify(permissions), JSON.stringify(granted), '{}');
|
||||
};
|
||||
|
||||
it('restarts transparently when the new version requests no new permissions', async () => {
|
||||
seed('upd-same', 1, ['db:own'], ['db:own']);
|
||||
const rt = await createPluginRuntime(dbConn, fakeRegistry(() => {})); // declared perms unchanged
|
||||
const rt = await createPluginRuntime(
|
||||
dbConn,
|
||||
fakeRegistry(() => {}),
|
||||
); // declared perms unchanged
|
||||
await rt.activate('upd-same');
|
||||
const res = await rt.update('upd-same');
|
||||
expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] });
|
||||
@@ -371,9 +487,11 @@ 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 = await createPluginRuntime(dbConn,
|
||||
const rt = await createPluginRuntime(
|
||||
dbConn,
|
||||
fakeRegistry(() => {
|
||||
testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'")
|
||||
testDb
|
||||
.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'")
|
||||
.run(JSON.stringify(['db:own', 'db:read:trips', 'http:outbound:api.new.com']));
|
||||
}),
|
||||
);
|
||||
@@ -388,17 +506,31 @@ 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 (await createPluginRuntime(dbConn, fakeRegistry(() => {}))).update('upd-off');
|
||||
const res = await (
|
||||
await createPluginRuntime(
|
||||
dbConn,
|
||||
fakeRegistry(() => {}),
|
||||
)
|
||||
).update('upd-off');
|
||||
expect(res).toMatchObject({ activated: false, newPermissions: [], newEgress: [] });
|
||||
});
|
||||
|
||||
it('throws for an unknown plugin id', async () => {
|
||||
await expect((await createPluginRuntime(dbConn, fakeRegistry(() => {}))).update('ghost-upd')).rejects.toThrow(/not found/);
|
||||
await expect(
|
||||
(
|
||||
await createPluginRuntime(
|
||||
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((await createPluginRuntime(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
|
||||
await expect((await createPluginRuntime(dbConn)).update('upd-noreg')).rejects.toThrow(
|
||||
/registry service unavailable/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -406,15 +538,22 @@ describe('PluginRuntimeService dependency gating', () => {
|
||||
const deps = (d: { requiredAddons?: string[]; pluginDependencies?: { id: string; version: string }[] }) =>
|
||||
JSON.stringify({ requiredAddons: d.requiredAddons ?? [], pluginDependencies: d.pluginDependencies ?? [] });
|
||||
const insertPlugin = (id: string, dependencies: string, enabled = 0, version = '1.0.0') =>
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, version, permissions, granted_permissions, config, dependencies) VALUES (?, 'inactive', ?, ?, '[]', '[]', '{}', ?)")
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, enabled, version, permissions, granted_permissions, config, dependencies) VALUES (?, 'inactive', ?, ?, '[]', '[]', '{}', ?)",
|
||||
)
|
||||
.run(id, enabled, version, dependencies);
|
||||
const cleanup = (...ids: string[]) => { for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); };
|
||||
const cleanup = (...ids: string[]) => {
|
||||
for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id);
|
||||
};
|
||||
|
||||
it('blocks activation when a required addon is disabled', async () => {
|
||||
testDb.prepare("INSERT OR REPLACE INTO addons (id, enabled) VALUES ('budget', 0)").run();
|
||||
insertPlugin('needs-budget', deps({ requiredAddons: ['budget'] }));
|
||||
await expect(runtime.activate('needs-budget')).rejects.toMatchObject({ code: 'ADDON_DISABLED' });
|
||||
expect((testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get('needs-budget') as { enabled: number }).enabled).toBe(0);
|
||||
expect(
|
||||
(testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get('needs-budget') as { enabled: number }).enabled,
|
||||
).toBe(0);
|
||||
cleanup('needs-budget');
|
||||
});
|
||||
|
||||
@@ -455,8 +594,12 @@ describe('PluginRuntimeService dependency gating', () => {
|
||||
insertPlugin('unrelated-b', deps({}), 1);
|
||||
const disabled = await runtime.deactivateForDisabledAddon('budget');
|
||||
expect(disabled.sort()).toEqual(['base-b', 'dependent-b']);
|
||||
expect((testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get('base-b') as { enabled: number }).enabled).toBe(0);
|
||||
expect((testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get('unrelated-b') as { enabled: number }).enabled).toBe(1);
|
||||
expect(
|
||||
(testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get('base-b') as { enabled: number }).enabled,
|
||||
).toBe(0);
|
||||
expect(
|
||||
(testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get('unrelated-b') as { enabled: number }).enabled,
|
||||
).toBe(1);
|
||||
cleanup('base-b', 'dependent-b', 'unrelated-b');
|
||||
});
|
||||
|
||||
@@ -468,9 +611,13 @@ describe('PluginRuntimeService dependency gating', () => {
|
||||
const disabled = await runtime.deactivateWithDependents('lib-x');
|
||||
expect(disabled).toEqual(['top-x', 'mid-x', 'lib-x']); // deepest dependent first, root last
|
||||
for (const id of ['lib-x', 'mid-x', 'top-x']) {
|
||||
expect((testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get(id) as { enabled: number }).enabled).toBe(0);
|
||||
expect((testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get(id) as { enabled: number }).enabled).toBe(
|
||||
0,
|
||||
);
|
||||
}
|
||||
expect((testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get('other-x') as { enabled: number }).enabled).toBe(1);
|
||||
expect(
|
||||
(testDb.prepare('SELECT enabled FROM plugins WHERE id = ?').get('other-x') as { enabled: number }).enabled,
|
||||
).toBe(1);
|
||||
cleanup('lib-x', 'mid-x', 'top-x', 'other-x');
|
||||
});
|
||||
});
|
||||
@@ -497,9 +644,15 @@ describe('PluginRuntimeService inter-plugin (exports + events)', () => {
|
||||
} }],
|
||||
};`,
|
||||
);
|
||||
testDb.prepare("INSERT INTO plugins (id, status, version, permissions, config, capabilities, dependencies) VALUES ('lib','inactive','1.5.0','[]','{}',?,'{}')")
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, version, permissions, config, capabilities, dependencies) VALUES ('lib','inactive','1.5.0','[]','{}',?,'{}')",
|
||||
)
|
||||
.run(JSON.stringify({ provides: ['greet'], emits: ['ping'] }));
|
||||
testDb.prepare("INSERT INTO plugins (id, status, version, permissions, config, capabilities, dependencies) VALUES ('consumer','inactive','1.0.0','[]','{}','{}',?)")
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, version, permissions, config, capabilities, dependencies) VALUES ('consumer','inactive','1.0.0','[]','{}','{}',?)",
|
||||
)
|
||||
.run(JSON.stringify({ requiredAddons: [], pluginDependencies: [{ id: 'lib', version: '>=1.0.0 <2.0.0' }] }));
|
||||
await runtime.activate('lib');
|
||||
await runtime.activate('consumer');
|
||||
@@ -519,7 +672,9 @@ describe('PluginRuntimeService inter-plugin (exports + events)', () => {
|
||||
});
|
||||
|
||||
it('refuses a call from a non-dependent and to an undeclared export', async () => {
|
||||
await expect(runtime.callPlugin('lib', 'consumer', 'anything', {}, 7)).rejects.toThrow(/does not declare|not active|does not export/);
|
||||
await expect(runtime.callPlugin('lib', 'consumer', 'anything', {}, 7)).rejects.toThrow(
|
||||
/does not declare|not active|does not export/,
|
||||
);
|
||||
await expect(runtime.callPlugin('consumer', 'lib', 'nope', {}, 7)).rejects.toThrow(/does not export/);
|
||||
});
|
||||
|
||||
@@ -556,7 +711,9 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
assertRetrustable: vi.fn(async (_id: string, key: string) => ({ authorPublicKey: key })),
|
||||
install: vi.fn(async (_id: string, opts?: { retrustKey?: string }) => {
|
||||
testDb
|
||||
.prepare("UPDATE plugins SET author_pubkey = ?, version = '2.0.0', update_block_code = NULL, update_block_version = NULL WHERE id = ?")
|
||||
.prepare(
|
||||
"UPDATE plugins SET author_pubkey = ?, version = '2.0.0', update_block_code = NULL, update_block_version = NULL WHERE id = ?",
|
||||
)
|
||||
.run(opts?.retrustKey, id);
|
||||
return { id, version: '2.0.0' };
|
||||
}),
|
||||
@@ -573,7 +730,9 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
// The condition is re-derived SERVER-side before anything moves — the UI hiding the
|
||||
// button is a convenience, not the control.
|
||||
expect(registry.assertRetrustable).toHaveBeenCalledWith('rt-ok', 'NEWKEY');
|
||||
const row = testDb.prepare("SELECT author_pubkey, version, update_block_code FROM plugins WHERE id='rt-ok'").get() as {
|
||||
const row = testDb
|
||||
.prepare("SELECT author_pubkey, version, update_block_code FROM plugins WHERE id='rt-ok'")
|
||||
.get() as {
|
||||
author_pubkey: string | null;
|
||||
version: string;
|
||||
update_block_code: string | null;
|
||||
@@ -641,7 +800,9 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
await rt.deactivate('rt-act');
|
||||
await rt.activate('rt-act');
|
||||
|
||||
const row = testDb.prepare("SELECT update_block_code, update_block_version FROM plugins WHERE id='rt-act'").get() as {
|
||||
const row = testDb
|
||||
.prepare("SELECT update_block_code, update_block_version FROM plugins WHERE id='rt-act'")
|
||||
.get() as {
|
||||
update_block_code: string;
|
||||
update_block_version: string;
|
||||
};
|
||||
@@ -662,12 +823,19 @@ describe('TREK host-version gating', () => {
|
||||
const seedRanged = (id: string, trekRange: string | null, enabled = 0) => {
|
||||
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) VALUES (?, ?, ?, '[]', '[]', '{}', ?)")
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES (?, ?, ?, '[]', '[]', '{}', ?)",
|
||||
)
|
||||
.run(id, enabled ? 'active' : 'inactive', enabled, trekRange);
|
||||
};
|
||||
const cleanup = (...ids: string[]) => { for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); };
|
||||
const cleanup = (...ids: string[]) => {
|
||||
for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id);
|
||||
};
|
||||
|
||||
afterEach(() => { delete process.env.APP_VERSION; });
|
||||
afterEach(() => {
|
||||
delete process.env.APP_VERSION;
|
||||
});
|
||||
|
||||
it('refuses to activate a plugin this TREK has outgrown, and says so with a code', async () => {
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
@@ -684,7 +852,9 @@ 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((await createPluginRuntime(dbConn)).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
|
||||
await expect((await createPluginRuntime(dbConn)).activate('undeclared')).rejects.toMatchObject({
|
||||
code: 'TREK_VERSION_UNKNOWN',
|
||||
});
|
||||
cleanup('undeclared');
|
||||
});
|
||||
|
||||
@@ -718,8 +888,12 @@ describe('TREK host-version gating', () => {
|
||||
});
|
||||
|
||||
describe('with TREK_PLUGINS_IGNORE_TREK_RANGE set', () => {
|
||||
beforeEach(() => { process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; });
|
||||
afterEach(() => { delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE; });
|
||||
beforeEach(() => {
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
|
||||
});
|
||||
|
||||
it('activates a plugin this TREK has outgrown, warning in the log instead of refusing', async () => {
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
@@ -752,8 +926,14 @@ describe('TREK host-version gating', () => {
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
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((await createPluginRuntime(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
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((await createPluginRuntime(dbConn)).activate('both-wrong')).rejects.toMatchObject({
|
||||
code: 'TREK_VERSION_INCOMPATIBLE',
|
||||
});
|
||||
cleanup('both-wrong');
|
||||
});
|
||||
});
|
||||
@@ -773,7 +953,9 @@ describe('plugin-API version gating', () => {
|
||||
)
|
||||
.run(id, apiVersion);
|
||||
};
|
||||
const cleanup = (...ids: string[]) => { for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); };
|
||||
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);
|
||||
@@ -788,7 +970,9 @@ describe('plugin-API version gating', () => {
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
try {
|
||||
seedApi('future-api-bypass', 2);
|
||||
await expect((await createPluginRuntime(dbConn)).activate('future-api-bypass')).rejects.toMatchObject({ code: 'API_VERSION_INCOMPATIBLE' });
|
||||
await expect((await createPluginRuntime(dbConn)).activate('future-api-bypass')).rejects.toMatchObject({
|
||||
code: 'API_VERSION_INCOMPATIBLE',
|
||||
});
|
||||
} finally {
|
||||
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
|
||||
cleanup('future-api-bypass');
|
||||
|
||||
Reference in New Issue
Block a user