chore: initial commit
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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');
|
||||
});
|
||||
Reference in New Issue
Block a user