import assert from 'node:assert/strict'; import test from 'node:test'; import vm from 'node:vm'; import {createRequire} from 'node:module'; import {fileURLToPath} from 'node:url'; import {build} from 'esbuild'; import {tsImport} from 'tsx/esm/api'; const root = fileURLToPath(new URL('../', import.meta.url)); const compiled = await build({ stdin: {contents: `export {handler as inject} from './event_handler/bot/inject_plugin.ts'; export {handler as uninject} from './event_handler/bot/uninject_plugin.ts'; export {handler as update} from './event_handler/bot/update_plugin_config.ts'; export {PluginManager, PluginError} from './bot/plugins/init.ts'; export {validate_code_map as codes} from './utils/validate_code_map.ts'; export * from './event_handler/init.ts';`, resolveDir: root, loader: 'ts'}, bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, define: {'import.meta.url': JSON.stringify(new URL('../event_handler/init.ts', import.meta.url).href)}, plugins: [{name: 'context', setup(build) { build.onResolve({filter: /bot\/instance(?:\/index\.ts)?$/}, () => ({path: 'context', namespace: 'mock'})); build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({contents: 'export const get_bot_context = id => globalThis.lookup(id);'})); }}], }); function setup() { let context; const calls = [], emitted = []; const module = {exports: {}}; vm.runInNewContext(compiled.outputFiles[0].text, { module, exports: module.exports, require: createRequire(import.meta.url), URL, process, structuredClone, lookup(id) { calls.push(['lookup', id]); return context; }, console: {log() {}, error() { throw new Error('Unexpected logging'); }}, }); const api = module.exports; const manager = Object.fromEntries(['inject', 'uninject', 'update_config'].map(name => [name, async (...args) => { calls.push([name, ...args]); }])); context = {bot_status: 'offline', plugin_manager: manager}; return {...api, calls, manager, socket: {emit: (...args) => emitted.push(args)}, emitted, context(value) { context = value; }, async run(name, data) { const acks = []; await api[name](this.socket, data, result => acks.push(structuredClone(result))); assert.equal(acks.length, 1); return acks[0]; }}; } const dto = operation => operation === 'inject' ? {bot_id: 1, path: 'runtime', name: 'auto_reconnect', config: {}} : operation === 'update' ? {bot_id: 1, name: 'auto_reconnect', config: {}} : {bot_id: 1, name: 'auto_reconnect'}; test('missing ACK emits method_not_allowed and never inspects DTO or context', async () => { const s = setup(); const data = new Proxy({}, {get() { throw new Error('must not read'); }}); for (const name of ['inject', 'uninject', 'update']) { for (const ack of [undefined, null, {}, 1]) await s[name](s.socket, data, ack); } assert.equal(s.calls.length, 0); assert.equal(s.emitted.length, 12); for (const [event, payload] of s.emitted) { assert.equal(event, 'error'); assert.equal(payload.error, s.codes.web.method_not_allowed); } }); test('exact ordinary DTO validation rejects malformed requests without getters or side effects', async () => { const s = setup(); for (const name of ['inject', 'uninject', 'update']) { const good = dto(name); const getter = Object.defineProperty({...good}, 'bot_id', {enumerable: true, get() { throw new Error('getter secret'); }}); const malformed = [null, [], {}, {...good, extra: true}, {...good, bot_id: 0}, {...good, bot_id: 1.5}, {...good, bot_id: Number.MAX_SAFE_INTEGER + 1}, {...good, name: '../bad'}, Object.assign(Object.create({inherited: true}), good), Object.assign(Object.create(null), good), getter, new Proxy(good, {}), {...good, [Symbol('extra')]: 1}, Object.assign(Object.create(Object.assign(Object.create(null), {constructor: Object})), good), Object.assign(Object.create(new Proxy({}, {getOwnPropertyDescriptor() { throw new Error('prototype trap'); }})), good)]; if (name !== 'uninject') for (const config of [null, [], new Date(), {bad: undefined}, {bad: Infinity}, Object.defineProperty({}, 'secret', {enumerable: true, get() { throw new Error('secret'); }}), {bad: Object.create({})}, {bad: Array(2)}, {bad: new Proxy([], {})}]) malformed.push({...good, config}); if (name === 'inject') for (const path of ['', '../runtime', '/runtime', 'a//b', 'a\\b', 'a.ts', 'a/%2e']) malformed.push({...good, path}); for (const data of malformed) assert.deepEqual(await s.run(name, data), {status: 'error', data: s.codes.web.invalid_parameter}); } assert.deepEqual(s.calls, []); }); test('offline operations route correct arguments and callback throws never retry', async () => { const s = setup(); for (const name of ['inject', 'uninject', 'update']) { assert.deepEqual(await s.run(name, dto(name)), {status: 'success', data: 0}); let count = 0; await s[name](s.socket, dto(name), () => { count++; throw new Error('callback secret'); }); assert.equal(count, 1); } assert.deepEqual(structuredClone(s.calls[1]), ['inject', {path: 'runtime', name: 'auto_reconnect', config: {}}]); assert.deepEqual(structuredClone(s.calls[5]), ['uninject', 'auto_reconnect']); assert.deepEqual(structuredClone(s.calls[9]), ['update_config', 'auto_reconnect', {}]); }); test('missing context, manager and typed or unexpected failures produce safe numeric errors', async () => { const s = setup(); s.context(undefined); assert.equal((await s.run('inject', dto('inject'))).data, s.codes.plugin.context_missing); s.context({}); assert.equal((await s.run('inject', dto('inject'))).data, s.codes.plugin.manager_missing); s.context({plugin_manager: s.manager}); for (const reason of ['INVALID_CONFIG', 'ALREADY_INSTALLED', 'NOT_INSTALLED', 'MANAGER_DISPOSED', 'LOAD_FAILED', 'INIT_FAILED', 'DISPOSE_FAILED']) { for (const [name, method] of [['inject', 'inject'], ['uninject', 'uninject'], ['update', 'update_config']]) { s.manager[method] = async () => { throw new s.PluginError(reason); }; assert.deepEqual(await s.run(name, dto(name)), {status: 'error', data: s.codes.plugin[reason.toLowerCase()]}); } } s.manager.inject = async () => { throw new Error('password=secret'); }; assert.deepEqual(await s.run('inject', dto('inject')), {status: 'error', data: s.codes.plugin.error}); }); test('handlers replace real offline plugin config using defaults and allow idempotent removal', async () => { const s = setup(); const {PluginManager} = await tsImport('../bot/plugins/init.ts', import.meta.url); const listeners = new Map(); const context = {bot_status: 'offline', on(event, fn) { listeners.set(event, fn); }, off(event) { listeners.delete(event); }, cancel_reconnect() {}}; context.plugin_manager = new PluginManager(context); s.context(context); assert.equal((await s.run('inject', {...dto('inject'), config: {reconnect_delay: 20, reconnect_max_attempts: 2}})).status, 'success'); assert.equal((await s.run('update', {...dto('update'), config: {reconnect_delay: 50}})).status, 'success'); assert.deepEqual(context.plugin_manager.get_plugin('auto_reconnect').config, {reconnect_delay: 50, reconnect_max_attempts: 10}); assert.equal(listeners.size, 4); for (let i = 0; i < 2; i++) assert.equal((await s.run('uninject', dto('uninject'))).status, 'success'); assert.equal(listeners.size, 0); await context.plugin_manager.dispose(); }); test('dynamic dispatcher loads and invokes all three plugin handler exports', async () => { const {init_event_handler_list, event_handler} = await tsImport('../event_handler/init.ts', import.meta.url); await init_event_handler_list(); const emitted = []; for (const name of ['inject_plugin', 'uninject_plugin', 'update_plugin_config']) { await event_handler({emit: (...args) => emitted.push(args)}, name, null); } assert.equal(emitted.length, 3); assert.deepEqual(emitted, Array.from({length: 3}, () => ['error', {error: 481}])); });