leasePort({closed:true}) ignored its option: it probed an ephemeral port and
closed it, so a parallel test file could bind it and answer a request the
test expected to fail (preview.test.mjs:209 saw 200 instead of 502).
leaseClosedPort holds a bound, non-listening socket in a perl child until the
test's after hook. Connects still get ECONNREFUSED, bind() from another
process gets EADDRINUSE, and the kernel's ephemeral allocation skips the port.
Lane mode keeps its band and per-port lock, and releases the socket before
the lock. leasePort now refuses a closed option, so a stale call cannot get
the unheld port again.
Unanswerable callers (webui preview, serve, service) move to leaseClosedPort(t).
Callers that listen on the port themselves (stack-release rehearse, control-board
serve, tester-cli) keep leasePort and drop the ignored option.
112 lines
7.6 KiB
JavaScript
112 lines
7.6 KiB
JavaScript
import { leaseClosedPort } from '../../../scripts/testing/lane-fixtures.mjs';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createServer, get } from 'node:http';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { startServer, boardURL, isLoopback } from '../src/serve.mjs';
|
|
import { fixture, close } from './fixture.mjs';
|
|
|
|
const post = (base, path, body, extra = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...extra }, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
|
|
|
test('loopback host and board origin fail closed', async () => {
|
|
for (const h of ['127.0.0.1', '127.2.3.4', '::1', 'localhost']) assert.equal(isLoopback(h), true);
|
|
for (const h of ['0.0.0.0', '192.168.1.1', '::', 'example.com', '']) {
|
|
assert.equal(isLoopback(h), false);
|
|
await assert.rejects(startServer({ host: h, port: 0 }), /non-loopback/);
|
|
}
|
|
for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
|
|
assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
|
|
assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
|
|
});
|
|
|
|
test('real board fixture passes through WebUI; assets and isolated seen/reply work', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
assert.equal(f.web.address().address, '127.0.0.1');
|
|
const data = await (await fetch(f.base + '/api/board')).json();
|
|
assert.equal(data.sessions.length, 4);
|
|
assert.equal(data.sessions.filter(r => r.waitingOnYou).length, 2);
|
|
assert.deepEqual(data.counts, { working: 1, waiting: 1, error: 1, offline: 1, idle: 0, unknown: 0 });
|
|
const r = data.sessions.find(r => r.agent === 'agent1');
|
|
const seen = await post(f.base, '/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: true });
|
|
assert.equal(seen.status, 200); assert.equal((await seen.json()).seen.length, 1);
|
|
const reply = await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'fixture reply' });
|
|
assert.equal((await reply.json()).delivered, true); assert.equal(f.captures.length, 1);
|
|
assert.match(f.captures[0].args.at(-1), /^fixture reply\n\(control-board:/);
|
|
for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/editor.js', '/editor.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {
|
|
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
}
|
|
// #1542: the shell's tokens, sprite, stylesheet and mono fonts, each with its type.
|
|
for (const [path, type] of [['/tokens.css', 'text/css'], ['/shell.css', 'text/css'], ['/icons.svg', 'image/svg+xml'], ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2'])]) {
|
|
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.ok(response.headers.get('content-type').startsWith(type), path);
|
|
}
|
|
assert.equal((await fetch(f.base + '/../../AGENTS.md')).status, 404);
|
|
assert.equal((await fetch(f.base + '/api/reply')).status, 405);
|
|
assert.equal((await fetch(f.base + '/api/board', { method: 'OPTIONS' })).status, 405);
|
|
assert.equal((await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'blocked' }, { origin: 'https://evil.example' })).status, 403);
|
|
assert.equal(f.captures.length, 1);
|
|
const hostileHost = await new Promise((resolve, reject) => {
|
|
get(f.base + '/api/board', { headers: { host: 'evil.example' } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject);
|
|
});
|
|
assert.equal(hostileHost, 403);
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test('proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect', async () => {
|
|
const requests = [];
|
|
const upstream = createServer(async (req, res) => {
|
|
let body = ''; for await (const c of req) body += c;
|
|
requests.push({ url: req.url, method: req.method, body });
|
|
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
|
|
if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); }
|
|
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
|
|
});
|
|
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
|
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}` });
|
|
const base = `http://127.0.0.1:${web.address().port}`;
|
|
try {
|
|
for (const path of ['/api/reply', '/api/seen']) {
|
|
const raw = '{ "agent": "proj/agent1", "text": "hello Ω", "seen": false }';
|
|
const res = await post(base, path, raw);
|
|
assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
|
|
assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
|
|
}
|
|
for (const raw of ['{', 'null', '[]', JSON.stringify({ text: 'x'.repeat(5000) })]) {
|
|
try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
|
|
}
|
|
assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
|
|
assert.equal(requests.length, 2);
|
|
assert.equal((await fetch(base + '/api/board')).status, 502);
|
|
assert.equal(requests.length, 3);
|
|
// CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent.
|
|
for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) {
|
|
const res = await fetch(base + path);
|
|
assert.equal(res.status, 404, path);
|
|
assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
|
|
assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
|
|
}
|
|
const before = requests.length;
|
|
assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
|
|
assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
|
|
assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
|
|
assert.equal(requests.length, before, 'refused before the board');
|
|
// Only the conversation routes carry a query upstream.
|
|
await fetch(base + '/api/board?x=1');
|
|
assert.equal(requests.at(-1).url, '/api/board');
|
|
} finally { await close(web); await close(upstream); }
|
|
});
|
|
|
|
test('unreachable board reports URL; CLI rejects unsupported options', async t => {
|
|
const url = `http://127.0.0.1:${await leaseClosedPort(t)}`;
|
|
const web = await startServer({ port: 0, board: url, timeout: 100 });
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
|
|
assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
|
|
} finally { await close(web); }
|
|
for (const args of [[], ['serve', '--port', 'abc'], ['serve', '--host', '0.0.0.0'], ['serve', '--board', 'http://example.com']]) {
|
|
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../src/cli.mjs', import.meta.url)), ...args], { encoding: 'utf8' });
|
|
assert.equal(result.status, 2); assert.match(result.stderr, /^refused:/);
|
|
}
|
|
});
|