140 lines
8.2 KiB
JavaScript
140 lines
8.2 KiB
JavaScript
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}]));
|
|
});
|