import test from 'node:test'; import assert from 'node:assert/strict'; import {createServer} from 'node:http'; import {EventEmitter, once} from 'node:events'; import {randomUUID} from 'node:crypto'; import {connect as connect_tcp} from 'node:net'; import {Server} from 'socket.io'; import {io as client} from 'socket.io-client'; import type {Socket as ClientSocket} from 'socket.io-client'; import {start_forward_server, stop_forward_server} from '../index.ts'; import {DEFAULT_LIMITS, resolve_options} from '../utils/relay_config.ts'; import {encode_token} from '../utils/token_utils.ts'; import type {BotLogRecord, ForwardAck, RelayClaims, SessionScope} from '../types.ts'; const key = 'lifecycle-test-secret-no-production-value'; const options = () => ({forward_id: 0, key, port: 0, allowed_origins: ['https://console.example']}); const address = (server: ReturnType) => { const value = server.address(); assert.ok(value && typeof value === 'object'); return `http://127.0.0.1:${value.port}`; }; async function connect(socket: ClientSocket) { await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('connect_error', reject); socket.connect(); }); } function control_request(control: EventEmitter, event: string, value: unknown): ForwardAck { let result: ForwardAck | undefined; control.emit(event, value, (ack: ForwardAck) => { result = ack; }); assert.ok(result, `Missing control ACK for ${event}`); return result; } test('configuration validates explicit origins, IDs, limits and required secret', () => { assert.throws(() => resolve_options({...options(), key: ''})); assert.throws(() => resolve_options({...options(), forward_id: -1})); assert.throws(() => resolve_options({...options(), allowed_origins: ['*']})); assert.throws(() => resolve_options({...options(), limits: {rpc_timeout_ms: 0}})); assert.equal(DEFAULT_LIMITS.rpc_timeout_ms, 5_000); }); test('concurrent startup shares one listening service, stop is idempotent and restart works', async t => { t.after(stop_forward_server); const input = options(); const [a, b] = await Promise.all([start_forward_server(input), start_forward_server(input)]); assert.equal(a, b); assert.ok(a.http_server.listening); await assert.rejects(start_forward_server({...input, forward_id: 2}), /different options/); await Promise.all([a.stop(), a.stop()]); assert.equal(a.http_server.listening, false); const restarted = await start_forward_server(options()); assert.notEqual(restarted, a); assert.ok(restarted.http_server.listening); }); test('port collision rejects startup and resets initialization for retry', async t => { const occupied = createServer(); occupied.listen(0, '127.0.0.1'); await once(occupied, 'listening'); t.after(async () => { await stop_forward_server(); await new Promise(resolve => occupied.close(() => resolve())); }); const bound = occupied.address(); assert.ok(bound && typeof bound === 'object'); await assert.rejects(start_forward_server({...options(), port: bound.port, host: '127.0.0.1'}), /EADDRINUSE/); const relay = await start_forward_server(options()); assert.ok(relay.http_server.listening); }); test('shutdown deadline terminates owned stalled HTTP connections and allows restart', async t => { t.after(stop_forward_server); const relay = await start_forward_server({...options(), limits: {close_timeout_ms: 30}}); const bound = relay.http_server.address(); assert.ok(bound && typeof bound === 'object'); const stalled = connect_tcp({host: '127.0.0.1', port: bound.port}); t.after(() => stalled.destroy()); await once(stalled, 'connect'); stalled.write('GET / HTTP/1.1\r\nHost: localhost\r\n'); const closed = once(stalled, 'close'); await assert.rejects(relay.stop(), /shutdown timed out/); await closed; const restarted = await start_forward_server(options()); assert.ok(restarted.http_server.listening); }); test('borrowed host and unrelated Socket.IO stay alive through relay stop and restart', async t => { const host = createServer((_request, response) => response.end('main-alive')); const control = new EventEmitter(); const input = {forward_id: 0, key, http_server: host, allowed_origins: [] as string[], control}; let relay = await start_forward_server(input); // Main installs its own Socket.IO service after the embedded relay. const main_io = new Server(host, {path: '/api/node', serveClient: false}); host.listen(0, '127.0.0.1'); await once(host, 'listening'); const main_client = client(address(host), {path: '/api/node', autoConnect: false, reconnection: false}); t.after(async () => { main_client.disconnect(); await stop_forward_server(); await main_io.close(); }); await connect(main_client); for (let count = 0; count < 3; count++) { await relay.stop(); assert.equal(host.listening, true); assert.equal(main_client.connected, true); assert.equal(await (await fetch(`${address(host)}/health`)).text(), 'main-alive'); assert.equal((await fetch(`${address(host)}/api/forward_server/?EIO=4&transport=polling`)).status, 503); relay = await start_forward_server(input); const response = await fetch(`${address(host)}/api/forward_server/?EIO=4&transport=polling`); assert.equal(response.status, 200); assert.match(await response.text(), /^0\{/); assert.equal(control.listenerCount('forward_open_session'), 1); } assert.ok(host.listenerCount('request') <= 3); }); test('polling and websocket enforce origins and use authenticated control scope', async t => { const control = new EventEmitter(); const relay = await start_forward_server({...options(), control}); const sockets: ClientSocket[] = []; t.after(async () => { for (const socket of sockets) socket.disconnect(); await stop_forward_server(); }); const scope: SessionScope = { forward_id: 0, forward_session_id: randomUUID(), node_session_id: 'authenticated-main-node-session', node_id: 10, bot_id: 20, }; const now = Date.now(); assert.equal(control_request(control, 'forward_open_session', {...scope, lease_expires_at: now + 60_000}).status, 'success'); const grant_id = randomUUID(); assert.equal(control_request(control, 'forward_authorize_grant', { forward_session_id: scope.forward_session_id, grant_id, user_id: 30, permissions: ['bot.snapshot'], expires_at: now + 60_000, }).status, 'success'); const claims: RelayClaims = { ...scope, v: 1, role: 'web', user_id: 30, grant_id, permissions: ['bot.snapshot'], issued_at: now, join_before: now + 30_000, expires_at: now + 60_000, jti: randomUUID(), }; for (const transport of ['polling', 'websocket'] as const) { const socket = client(address(relay.http_server), { path: relay.path, transports: [transport], autoConnect: false, reconnection: false, extraHeaders: {Origin: 'https://console.example'}, }); sockets.push(socket); await connect(socket); const ack = await socket.timeout(2_000).emitWithAck('join_room', {token: encode_token(key, claims)}); assert.equal(ack.status, 'success'); assert.equal(ack.data.node_ready, false); socket.disconnect(); const forbidden = client(address(relay.http_server), { path: relay.path, transports: [transport], autoConnect: false, reconnection: false, extraHeaders: {Origin: 'https://attacker.example'}, }); sockets.push(forbidden); await assert.rejects(connect(forbidden)); } }); test('default transport and validators relay large log history over polling and websocket', async t => { const control = new EventEmitter(); const relay = await start_forward_server({...options(), control}); const sockets: ClientSocket[] = []; t.after(async () => { for (const socket of sockets) socket.disconnect(); await stop_forward_server(); }); const scope: SessionScope = {forward_id: 0, forward_session_id: randomUUID(), node_session_id: 'large-history-node-session', node_id: 10, bot_id: 20}; const now = Date.now(); assert.equal(control_request(control, 'forward_open_session', {...scope, lease_expires_at: now + 60_000}).status, 'success'); const records: BotLogRecord[] = Array.from({length: 20}, (_, index): BotLogRecord => ({id: randomUUID(), timestamp: now, context_generation: null, seq: index + 1, segments: Array.from({length: 32}, () => ({message: '\u754c'.repeat(1024), font_color: '#ffffff', font_style: ['normal']}))})); const result = {status: 'success', data: {stream_id: randomUUID(), seq: 20, records, truncated: false}}; assert.ok(Buffer.byteLength(JSON.stringify(result)) > 1024 * 1024); for (const transport of ['polling', 'websocket'] as const) { const grant_id = randomUUID(); assert.equal(control_request(control, 'forward_authorize_grant', { forward_session_id: scope.forward_session_id, grant_id, user_id: 30, permissions: ['bot.logs'], expires_at: now + 60_000, }).status, 'success'); const base: RelayClaims = {...scope, v: 1, role: 'node', permissions: ['bot.logs'], issued_at: now, join_before: now + 30_000, expires_at: now + 60_000, jti: randomUUID()}; const node = client(address(relay.http_server), {path: relay.path, transports: [transport], autoConnect: false, reconnection: false}); sockets.push(node); node.on('relay_request', (_request, reply) => reply(result)); await connect(node); assert.equal((await node.timeout(2_000).emitWithAck('join_room', {token: encode_token(key, base)})).status, 'success'); assert.equal((await node.timeout(2_000).emitWithAck('forward_node_ready', { forward_session_id: scope.forward_session_id})).status, 'success'); const web = client(address(relay.http_server), {path: relay.path, transports: [transport], autoConnect: false, reconnection: false}); sockets.push(web); await connect(web); assert.equal((await web.timeout(2_000).emitWithAck('join_room', {token: encode_token(key, { ...base, role: 'web', grant_id, user_id: 30, jti: randomUUID(), })})).status, 'success'); const history = await web.timeout(5_000).emitWithAck('relay_request', { request_id: randomUUID(), method: 'get_log_history', payload: {}, }); assert.deepEqual(history, result); assert.equal(node.connected, true); assert.equal(web.connected, true); web.disconnect(); node.disconnect(); } }); test('control rebinding removes only owned listeners and stale disconnect cannot disable new control', async t => { const first = new EventEmitter(); const second = new EventEmitter(); let unrelated = 0; first.on('forward_open_session', () => { unrelated++; }); const relay = await start_forward_server({...options(), control: first}); t.after(stop_forward_server); const dispose = relay.bind_control(second); assert.equal(first.listenerCount('forward_open_session'), 1); first.emit('disconnect'); assert.equal(relay.store.control_available, true); first.emit('forward_open_session'); assert.equal(unrelated, 1); second.emit('disconnect'); assert.equal(relay.store.control_available, false); dispose(); assert.equal(second.listenerCount('forward_open_session'), 0); }); test('borrowed host dispatches custom namespace WebSocket without closing an existing Main connection', async t => { const host = createServer((_request, response) => response.end('main')); const main_io = new Server(host, {path: '/api/node', serveClient: false}); host.listen(0, '127.0.0.1'); await once(host, 'listening'); const main_client = client(address(host), {path: '/api/node', autoConnect: false, reconnection: false}); await connect(main_client); const relay = await start_forward_server({forward_id: 0, key, http_server: host, allowed_origins: [], namespace: '/relay'}); const web = client(`${address(host)}/relay`, {path: relay.path, transports: ['websocket'], autoConnect: false, reconnection: false}); t.after(async () => { web.disconnect(); main_client.disconnect(); await stop_forward_server(); await main_io.close(); }); await connect(web); const result = await web.timeout(1_000).emitWithAck('forward_open_session', {}); assert.equal(result.status, 'error'); assert.equal(result.data.code, 'FORBIDDEN'); await relay.stop(); assert.equal(main_client.connected, true); assert.equal(host.listening, true); }); test('closing and relistening the borrowed host reuses its relay dispatcher', async t => { const host = createServer((_request, response) => response.end('main')); const input = {forward_id: 0, key, http_server: host, allowed_origins: [] as string[]}; let relay = await start_forward_server(input); host.listen(0, '127.0.0.1'); await once(host, 'listening'); t.after(async () => { await stop_forward_server(); if (host.listening) await new Promise(resolve => host.close(() => resolve())); }); const listener_counts = {request: host.listenerCount('request'), upgrade: host.listenerCount('upgrade')}; await new Promise(resolve => host.close(() => resolve())); await relay.stop(); relay = await start_forward_server(input); host.listen(0, '127.0.0.1'); await once(host, 'listening'); const web = client(address(host), {path: relay.path, transports: ['websocket'], autoConnect: false, reconnection: false}); t.after(() => web.disconnect()); await connect(web); assert.equal(host.listenerCount('request'), listener_counts.request); assert.equal(host.listenerCount('upgrade'), listener_counts.upgrade); assert.equal(await (await fetch(`${address(host)}/`)).text(), 'main'); });