92 lines
5.8 KiB
JavaScript
92 lines
5.8 KiB
JavaScript
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', '/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');
|
|
}
|
|
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('{}'); }
|
|
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);
|
|
} finally { await close(web); await close(upstream); }
|
|
});
|
|
|
|
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
|
|
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
|
|
const url = `http://127.0.0.1:${probe.address().port}`; await close(probe);
|
|
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:/);
|
|
}
|
|
});
|