// Real local HTTP requests and WebSocket handshakes. Never use the live DB // or contact a broker. The existing check suite uses Node's type stripping. import { after, test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { registerHooks } from 'node:module'; import { execFileSync } from 'node:child_process'; import { createServer } from 'node:http'; import { connect } from 'node:net'; import { once } from 'node:events'; import { WebSocketServer } from 'ws'; import { HubConnectionBuilder, HttpTransportType, LogLevel } from '@microsoft/signalr'; // Resolve the extensionless local imports used by Next, without a build. function resolveTypes(specifier, context, nextResolve) { try { return nextResolve(specifier, context); } catch (err) { if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err; return nextResolve(`${specifier}.ts`, context); } } registerHooks({ resolve: resolveTypes }); const directory = mkdtempSync(join(tmpdir(), 'autofirmer-user-agent-')); process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite'); delete process.env.NEXT_PHASE; after(() => rmSync(directory, { recursive: true, force: true })); const { getSetting, setSetting } = await import('../lib/db.ts'); // Do not let inherited environment proxy settings route local test traffic. process.env.NO_PROXY = '*'; setSetting('proxy_url', ''); const { getRequestUserAgent } = await import('../lib/request-user-agent.ts'); const { default: axios } = await import('../lib/http-client.ts'); const { brokerWebSocket, directWebSocket, signalrWebSocket } = await import('../lib/outbound-proxy.ts'); const userAgent = getRequestUserAgent(); async function listen(server) { server.listen(0, '127.0.0.1'); await once(server, 'listening'); return `http://127.0.0.1:${server.address().port}`; } async function socketHeaders(makeSocket, server, url, path = '/') { const accepted = once(server, 'connection'); const client = makeSocket(url.replace('http:', 'ws:') + path); const opened = new Promise((resolve, reject) => { client.addEventListener('open', resolve, { once: true }); client.addEventListener('error', reject, { once: true }); }); const [peer, request] = await accepted; try { await opened; return request.headers; } finally { client.close(); peer.terminate(); } } test('first load saves a browser identity and another process reuses it', () => { assert.match(userAgent, /^Mozilla\/5\.0 .*(Chrome\/|Firefox\/)/); assert.equal(getSetting('user_agent'), userAgent); const value = execFileSync(process.execPath, [ '--experimental-strip-types', '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON', '--input-type=module', '-e', `import { registerHooks } from 'node:module'; registerHooks({ resolve: ${resolveTypes.toString()} }); const { getRequestUserAgent } = await import(${JSON.stringify(new URL('../lib/request-user-agent.ts', import.meta.url).href)}); process.stdout.write(getRequestUserAgent());`, ], { env: process.env, encoding: 'utf8' }); assert.equal(value, userAgent); }); test('Axios calls, custom headers, and nested create clients use the saved identity', async () => { const server = createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(req.headers)); }); const url = await listen(server); try { const child = axios.create({ headers: { 'user-agent': 'old child identity' } }); const grandchild = child.create({ headers: { 'User-Agent': false } }); for (const client of [axios, child, grandchild]) { const result = await client.post(url, { hello: 'world' }, { headers: { 'uSeR-aGeNt': false, Authorization: 'Bearer local-test', 'X-Test': 'kept' }, }); assert.equal(result.data['user-agent'], userAgent); assert.equal(result.data.authorization, 'Bearer local-test'); assert.equal(result.data['x-test'], 'kept'); assert.equal(client.defaults.timeout, 60_000); } assert.equal((await axios(url)).data['user-agent'], userAgent); } finally { await new Promise((resolve) => server.close(resolve)); } }); test('direct, broker and SignalR handshakes preserve the saved identity and auth', async () => { const server = createServer(); const wss = new WebSocketServer({ server }); const url = await listen(server); try { for (const makeSocket of [directWebSocket, brokerWebSocket]) { assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent); } const WS = signalrWebSocket(); for (const overload of [false, true]) { const headers = await socketHeaders((address) => { const options = { headers: { 'user-agent': 'SignalR default', Cookie: 'session=local', Authorization: 'Bearer local-test' } }; return overload ? new WS(address, options) : new WS(address, undefined, options); }, wss, url); assert.equal(headers['user-agent'], userAgent); assert.equal(headers.cookie, 'session=local'); assert.equal(headers.authorization, 'Bearer local-test'); } // Exercise SignalR itself, including its generated UA/auth headers. wss.on('connection', (socket) => { socket.on('message', () => socket.send('{}\u001e')); }); const hub = new HubConnectionBuilder().withUrl(url, { skipNegotiation: true, transport: HttpTransportType.WebSockets, WebSocket: WS, accessTokenFactory: () => 'signalr-test', }).configureLogging(LogLevel.None).build(); const accepted = once(wss, 'connection'); await hub.start(); try { const [, request] = await accepted; assert.equal(request.headers['user-agent'], userAgent); assert.equal(request.headers.authorization, 'Bearer signalr-test'); } finally { await hub.stop(); } } finally { for (const socket of wss.clients) socket.terminate(); await new Promise((resolve) => wss.close(resolve)); await new Promise((resolve) => server.close(resolve)); } }); test('HTTP proxy keeps the identity on broker sockets while control sockets stay direct', async () => { const server = createServer(); const wss = new WebSocketServer({ server }); const url = await listen(server); let tunnels = 0; const connections = new Set(); const proxy = createServer((req, res) => { // ws with HttpProxyAgent uses an absolute-form upgrade; handled below. res.writeHead(500).end(); }); function tunnel(req, socket, head, isConnect) { tunnels++; const target = new URL(isConnect ? `http://${req.url}` : req.url); const upstream = connect(Number(target.port), target.hostname, () => { if (isConnect) socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); else { upstream.write(`${req.method} ${target.pathname} HTTP/1.1\r\n`); for (let i = 0; i < req.rawHeaders.length; i += 2) upstream.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`); upstream.write('\r\n'); } if (head.length) upstream.write(head); socket.pipe(upstream).pipe(socket); }); connections.add(socket); connections.add(upstream); socket.on('error', () => upstream.destroy()); upstream.on('error', () => socket.destroy()); } proxy.on('connect', (req, socket, head) => tunnel(req, socket, head, true)); proxy.on('upgrade', (req, socket, head) => tunnel(req, socket, head, false)); const proxyUrl = await listen(proxy); setSetting('proxy_url', proxyUrl); delete globalThis.__outboundProxy; try { for (const makeSocket of [brokerWebSocket, (address) => new (signalrWebSocket())(address)]) { const before = tunnels; assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent); assert.equal(tunnels, before + 1); } const before = tunnels; assert.equal((await socketHeaders(directWebSocket, wss, url))['user-agent'], userAgent); assert.equal(tunnels, before); } finally { await globalThis.__outboundProxy.dispatcher.destroy(); globalThis.__outboundProxy.httpAgent.destroy(); globalThis.__outboundProxy.httpsAgent.destroy(); delete globalThis.__outboundProxy; setSetting('proxy_url', ''); for (const socket of connections) socket.destroy(); for (const socket of wss.clients) socket.terminate(); await new Promise((resolve) => wss.close(resolve)); await new Promise((resolve) => server.close(resolve)); await new Promise((resolve) => proxy.close(resolve)); } });