mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
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,11 +48,6 @@ 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 { 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;
|
||||
let dataRoot: string;
|
||||
@@ -77,7 +77,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);
|
||||
});
|
||||
|
||||
@@ -121,12 +123,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/);
|
||||
});
|
||||
|
||||
@@ -136,11 +143,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.)
|
||||
@@ -162,7 +177,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);
|
||||
});
|
||||
@@ -177,17 +195,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');
|
||||
@@ -195,11 +219,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' }] }]);
|
||||
@@ -209,7 +241,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: [] }]);
|
||||
|
||||
@@ -230,32 +264,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
|
||||
@@ -267,7 +325,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();
|
||||
@@ -276,7 +338,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([]);
|
||||
@@ -286,16 +352,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();
|
||||
|
||||
@@ -303,10 +387,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 });
|
||||
}
|
||||
});
|
||||
@@ -314,7 +410,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();
|
||||
|
||||
@@ -324,14 +422,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(() => {});
|
||||
@@ -348,19 +454,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: [] });
|
||||
@@ -370,9 +485,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']));
|
||||
}),
|
||||
);
|
||||
@@ -387,17 +504,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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -405,15 +536,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');
|
||||
});
|
||||
|
||||
@@ -454,8 +592,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');
|
||||
});
|
||||
|
||||
@@ -467,9 +609,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');
|
||||
});
|
||||
});
|
||||
@@ -496,9 +642,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');
|
||||
@@ -518,7 +670,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/);
|
||||
});
|
||||
|
||||
@@ -555,7 +709,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' };
|
||||
}),
|
||||
@@ -572,7 +728,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;
|
||||
@@ -640,7 +798,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;
|
||||
};
|
||||
@@ -661,12 +821,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';
|
||||
@@ -683,7 +850,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');
|
||||
});
|
||||
|
||||
@@ -717,8 +886,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';
|
||||
@@ -751,8 +924,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');
|
||||
});
|
||||
});
|
||||
@@ -772,7 +951,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);
|
||||
@@ -787,7 +968,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