285 lines
14 KiB
TypeScript
285 lines
14 KiB
TypeScript
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<typeof createServer>) => {
|
|
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<void>((resolve, reject) => {
|
|
socket.once('connect', resolve);
|
|
socket.once('connect_error', reject);
|
|
socket.connect();
|
|
});
|
|
}
|
|
function control_request(control: EventEmitter, event: string, value: unknown): ForwardAck<unknown> {
|
|
let result: ForwardAck<unknown> | undefined;
|
|
control.emit(event, value, (ack: ForwardAck<unknown>) => { 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<void>(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<void>(resolve => host.close(() => resolve()));
|
|
});
|
|
const listener_counts = {request: host.listenerCount('request'), upgrade: host.listenerCount('upgrade')};
|
|
await new Promise<void>(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');
|
|
});
|