import assert from 'node:assert/strict'; import test from 'node:test'; import vm from 'node:vm'; import {randomUUID} from 'node:crypto'; import {createRequire} from 'node:module'; import {build} from 'esbuild'; const compiled = await build({ entryPoints: [new URL('../utils/bot_config_apply_handler.ts', import.meta.url).pathname], bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, plugins: [{name: 'isolation', setup(build) { build.onResolve({filter: /bot\/instance\/index\.ts$/}, () => ({path: 'context', namespace: 'mock'})); build.onResolve({filter: /console_print\.ts$/}, () => ({path: 'logger', namespace: 'mock'})); build.onResolve({filter: /runtime\/compile\.ts$/}, () => ({path: 'compile', namespace: 'mock'})); build.onLoad({filter: /.*/, namespace: 'mock'}, ({path}) => ({contents: path === 'context' ? 'export const get_bot_context = id => globalThis.lookup(id);' : path === 'compile' ? 'export const compile_workflow = value => globalThis.compile(value);' : 'export const console_print={log:value=>globalThis.logged(value),success:value=>value,error:value=>value};'})); }}], }); const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; }; const flush = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); }; const dto = (revision = 1, extras = {}) => ({command_id: randomUUID(), payload_version: 1, node_session_id: 'session', bot_id: 1, config_revision: revision, plugins: [], workflows: [], ...extras}); const success = (request, state) => ({status: 'success', data: {command_id: request.command_id, bot_id: request.bot_id, config_revision: request.config_revision, state}}); function setup() { const calls = [], logs = []; const contexts = new Map(); const options = {}; const module = {exports: {}}; vm.runInNewContext(compiled.outputFiles[0].text, {module, exports: module.exports, require: createRequire(import.meta.url), Buffer, structuredClone, lookup: id => contexts.get(id), logged: value => logs.push(value), compile: value => { calls.push(['compile', structuredClone(value)]); if (options.compile_error || !value?.id) throw new Error('secret workflow code'); return {definition: value}; }}); const context = {bot_config: {config_revision: 0, plugins: [], workflow: []}, bot_status: 'offline', is_disposed: false, plugin_manager: {async apply_desired(plugins) { calls.push(['plugins', structuredClone(plugins)]); if (options.plugin_gate) await options.plugin_gate; if (options.plugin_error) throw options.plugin_error; }}, workflow_manager: {async apply(workflows, revision) { calls.push(['workflows', structuredClone(workflows), revision]); if (options.workflow_gate) await options.workflow_gate; if (options.workflow_error) throw options.workflow_error; }}, logger: {info: value => value, error: value => value}, log: value => logs.push(value)}; contexts.set(1, context); const handler = module.exports.create_bot_config_apply_handler(); const socket = {connected: true, id: 'session'}; return {calls, logs, contexts, options, context, handler, socket, async run(request, ack) { const replies = []; await handler(socket, request, value => { replies.push(structuredClone(value)); ack?.(); }); assert.equal(replies.length, 1); return replies[0]; }}; } test('processing ACK is immediate, duplicate jobs install once and completed ACK has exact fields', async () => { const s = setup(), request = dto(1, {plugins: [{path: 'runtime', name: 'a', config: {value: 1}}]}); const gate = deferred(); s.options.plugin_gate = gate.promise; assert.deepEqual(await s.run(request), success(request, 'processing')); for (let i = 0; i < 3; i++) assert.deepEqual(await s.run(request), success(request, 'processing')); assert.deepEqual(s.calls.map(call => call[0]), ['plugins']); gate.resolve(); await flush(); assert.deepEqual(await s.run(request), success(request, 'completed')); assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows']); assert.equal(s.context.bot_config.config_revision, 1); assert.deepEqual(s.context.bot_config.plugins, request.plugins); assert.equal(s.logs.length, 2); }); test('strict bounded JSON validation rejects malformed input before lookup or mutation', async () => { const s = setup(), request = dto(); const unsafe = Object.defineProperty({...request}, 'plugins', {enumerable: true, get() { throw new Error('getter secret'); }}); const cyclic = dto(); cyclic.plugins.push(cyclic); const bad = [null, [], {}, {...request, extra: true}, {...request, payload_version: 2}, {...request, command_id: 'bad'}, {...request, bot_id: 0}, {...request, config_revision: 0}, {...request, workflows: {}}, unsafe, cyclic, new Proxy(request, {}), {...request, plugins: [{path: '../evil', name: 'a', config: {}}]}, {...request, plugins: [{path: 'runtime', name: 'a', config: {bad: NaN}}]}, {...request, plugins: [{path: 'runtime', name: 'a', config: {}}, {path: 'other', name: 'a', config: {}}]}, {...request, plugins: [{path: 'runtime', name: 'a', config: {huge: 'x'.repeat(256 * 1024)}}]}]; for (const data of bad) assert.equal((await s.run(data)).data.code, 'INVALID_PAYLOAD'); assert.deepEqual(s.calls, []); }); test('exact 256KiB serialized limit accepts boundary and rejects next byte', async () => { const s = setup(); const request = dto(1, {plugins: [{path: 'runtime', name: 'a', config: {text: ''}}]}); request.plugins[0].config.text = 'x'.repeat(256 * 1024 - Buffer.byteLength(JSON.stringify(request))); assert.equal(Buffer.byteLength(JSON.stringify(request)), 256 * 1024); assert.equal((await s.run(request)).status, 'success'); const excess = structuredClone(request); excess.command_id = randomUUID(); excess.plugins[0].config.text += 'x'; assert.equal((await s.run(excess)).data.code, 'INVALID_PAYLOAD'); await flush(); }); test('all workflows compile before plugins and duplicate graph IDs reject without mutation', async () => { const s = setup(); for (const workflows of [[{id: 'valid'}, {}], [{id: 'same'}, {id: 'same'}]]) { assert.equal((await s.run(dto(1, {workflows}))).data.code, 'INVALID_PAYLOAD'); assert.ok(s.calls.every(call => call[0] === 'compile')); } s.calls.length = 0; const request = dto(1, {workflows: [{id: 'a'}, {id: 'b'}]}); await s.run(request); await flush(); assert.deepEqual(s.calls.map(call => call[0]), ['compile', 'compile', 'plugins', 'workflows']); }); test('missing context is explicit and never creates managers, ACK throws do not repeat work', async () => { const s = setup(), request = dto(); s.contexts.clear(); assert.deepEqual(await s.run(request), success(request, 'no_context')); s.contexts.set(1, s.context); assert.deepEqual(await s.run(request), success(request, 'no_context')); await s.run(dto(), () => { throw new Error('ACK failed'); }); await flush(); assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows']); await s.handler(s.socket, new Proxy({}, {get() { throw new Error('unread'); }})); }); test('socket session and context disposal checked before and after each await', async () => { const s = setup(); assert.equal((await s.run(dto(1, {node_session_id: 'old'}))).data.code, 'SESSION_MISMATCH'); s.socket.connected = false; assert.equal((await s.run(dto())).data.code, 'SESSION_MISMATCH'); s.socket.connected = true; const request = dto(), gate = deferred(); s.options.plugin_gate = gate.promise; await s.run(request); s.context.is_disposed = true; gate.resolve(); await flush(); assert.equal((await s.run(request)).data.code, 'CONTEXT_CHANGED'); assert.equal(s.calls.filter(call => call[0] === 'workflows').length, 0); assert.equal(s.context.bot_config.config_revision, 0); }); test('replacement context and reconnect never receive stale async completion', async () => { for (const change of ['replace', 'session', 'disconnect']) { const s = setup(), request = dto(), gate = deferred(); s.options.workflow_gate = gate.promise; await s.run(request); await flush(); if (change === 'replace') s.contexts.set(1, {...s.context}); if (change === 'session') s.socket.id = 'new'; if (change === 'disconnect') s.socket.connected = false; gate.resolve(); await flush(); if (change !== 'replace') { s.socket.id = 'session'; s.socket.connected = true; } assert.equal((await s.run(request)).data.code, change === 'replace' ? 'CONTEXT_CHANGED' : 'SESSION_MISMATCH'); assert.equal(s.context.bot_config.config_revision, 0); } }); test('per-context queue serializes revisions and rejects stale, conflicting revision and command payloads', async () => { const s = setup(), first = dto(2), gate = deferred(); s.options.plugin_gate = gate.promise; await s.run(first); const second = dto(3, {plugins: [{path: 'runtime', name: 'a', config: {}}]}); await s.run(second); assert.equal((await s.run(dto(1))).data.code, 'STALE_REVISION'); assert.equal((await s.run({...second, command_id: randomUUID(), plugins: []})).data.code, 'REVISION_CONFLICT'); assert.equal((await s.run({...first, config_revision: 4})).data.code, 'COMMAND_CONFLICT'); assert.deepEqual(s.calls.map(call => call[0]), ['plugins']); gate.resolve(); await flush(); assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows', 'plugins', 'workflows']); assert.equal(s.context.bot_config.config_revision, 3); assert.deepEqual(await s.run(second), success(second, 'completed')); }); test('initial context revision fences old commands and identical applied configs preserve managers', async () => { const s = setup(); s.context.bot_config.config_revision = 9; assert.equal((await s.run(dto(8))).data.code, 'STALE_REVISION'); const request = dto(9); await s.run(request); await flush(); assert.deepEqual(await s.run(request), success(request, 'completed')); assert.deepEqual(s.calls, []); assert.equal((await s.run(dto(9, {plugins: [{path: 'runtime', name: 'a', config: {}}]}))).data.code, 'REVISION_CONFLICT'); }); test('workflow partial failure is reported safely and same command recovers on next attempt', async () => { const s = setup(), request = dto(); s.options.workflow_error = {status: 'error', data: {code: 'LEAK', message: 'password secret'}}; await s.run(request); await flush(); const reply = await s.run(request); assert.equal(reply.data.code, 'PARTIAL_APPLY_FAILED'); assert.equal(JSON.stringify([reply, s.logs]).includes('secret'), false); assert.equal(s.context.bot_config.config_revision, 0); s.options.workflow_error = undefined; assert.deepEqual(await s.run(request), success(request, 'processing')); await flush(); assert.deepEqual(await s.run(request), success(request, 'completed')); assert.equal(s.calls.filter(call => call[0] === 'workflows').length, 2); }); test('pending jobs capped at 32 without evicting inflight work', async () => { const s = setup(), gate = deferred(); s.options.plugin_gate = gate.promise; const requests = Array.from({length: 32}, (_, i) => dto(i + 1)); for (const request of requests) assert.equal((await s.run(request)).data.state, 'processing'); assert.equal((await s.run(dto(33))).data.code, 'APPLY_BUSY'); assert.deepEqual(await s.run(requests[0]), success(requests[0], 'processing')); gate.resolve(); await new Promise(setImmediate); assert.deepEqual(await s.run(requests.at(-1)), success(requests.at(-1), 'completed')); assert.equal(s.calls.filter(call => call[0] === 'plugins').length, 2); assert.equal((await s.run(requests[1])).data.code, 'STALE_REVISION'); }); test('completed job cache retains 100 entries and evicted commands cannot roll revisions back', async () => { const s = setup(), first = dto(1); await s.run(first); await flush(); for (let revision = 2; revision <= 101; revision++) { await s.run(dto(revision)); await flush(); } assert.equal((await s.run(first)).data.code, 'STALE_REVISION'); assert.equal(s.context.bot_config.config_revision, 101); assert.equal(s.calls.filter(call => call[0] === 'plugins').length, 101); });