Files
evomap__evolver/test/atpProxyRouting.test.js
evolver-publish 4854a3b9b9 Release v1.87.3
2026-06-01 15:20:47 +00:00

230 lines
8.4 KiB
JavaScript

// Regression guard for #460 Bug 2: when EVOMAP_PROXY=1 and a proxy is
// running, src/atp/hubClient.js MUST route /atp/* calls through the local
// proxy (127.0.0.1) and NOT make a direct call to the Hub. The proxy is the
// single egress point for the mailbox/task/session/ATP surface.
//
// We spin up an in-memory HTTP server that impersonates both the proxy and
// the hub on the same loopback interface. The proxy endpoint increments
// `proxyHits`; the hub endpoint increments `hubHits`. We then assert which
// counter moves under each env/settings combination.
'use strict';
const { describe, it, before, after, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
function listen(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({ server, url: `http://127.0.0.1:${port}` });
});
});
}
function readBody(req) {
return new Promise((resolve) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try { resolve(raw ? JSON.parse(raw) : {}); }
catch { resolve({ raw }); }
});
});
}
describe('ATP hubClient proxy routing (regression #460 Bug 2)', () => {
let proxyServer;
let hubServer;
let proxyUrl;
let hubUrl;
let proxyHits;
let hubHits;
let lastProxyBody;
let origHome;
let origUserProfile;
let tmpHome;
let origEnv;
const ENV_KEYS = [
'EVOMAP_PROXY', 'A2A_TRANSPORT', 'A2A_HUB_URL', 'A2A_NODE_ID',
'A2A_NODE_SECRET', 'EVOMAP_PROXY_PORT', 'EVOMAP_HUB_ALLOW_INSECURE',
];
before(async () => {
proxyServer = await listen(async (req, res) => {
proxyHits.push({ method: req.method, path: req.url });
if (req.method === 'POST') lastProxyBody = await readBody(req);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ via: 'proxy', path: req.url }));
});
proxyUrl = proxyServer.url;
hubServer = await listen(async (req, res) => {
hubHits.push({ method: req.method, path: req.url });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ via: 'hub', path: req.url }));
});
hubUrl = hubServer.url;
});
after(async () => {
await new Promise((r) => proxyServer.server.close(r));
await new Promise((r) => hubServer.server.close(r));
});
beforeEach(() => {
proxyHits = [];
hubHits = [];
lastProxyBody = null;
origEnv = {};
for (const k of ENV_KEYS) { origEnv[k] = process.env[k]; delete process.env[k]; }
origHome = process.env.HOME;
origUserProfile = process.env.USERPROFILE;
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'evomap-atp-route-'));
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
// Seed A2A env so hubClient can call getNodeId / buildHubHeaders without
// touching the filesystem persist path. We mint a fresh 64-hex secret
// per test so module caches (if any) don't leak between tests.
process.env.A2A_HUB_URL = hubUrl;
process.env.A2A_NODE_ID = 'node_' + crypto.randomBytes(8).toString('hex');
process.env.A2A_NODE_SECRET = crypto.randomBytes(32).toString('hex');
// The mock hub above is plain http://127.0.0.1:<port>. hubClient's
// _hubPost / _hubGet now enforce the same https-only posture as
// hubFetch, so we set the documented escape hatch to keep these mock
// servers usable.
process.env.EVOMAP_HUB_ALLOW_INSECURE = '1';
// Bust the require cache so our env changes take effect for every test.
for (const key of Object.keys(require.cache)) {
if (/src[\\/](atp[\\/]hubClient|proxy[\\/]server[\\/]settings|gep[\\/]a2aProtocol)\.js$/.test(key)) {
delete require.cache[key];
}
}
});
afterEach(() => {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (origUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = origUserProfile;
for (const k of ENV_KEYS) {
if (origEnv[k] === undefined) delete process.env[k];
else process.env[k] = origEnv[k];
}
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best effort */ }
});
function writeProxySettings(url) {
// settings.js uses os.homedir()/.evolver/settings.json. os.homedir()
// on linux reads $HOME which we've swapped to tmpHome above.
const settingsDir = path.join(tmpHome, '.evolver');
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({
proxy: { url, pid: process.pid, started_at: new Date().toISOString() },
}));
}
it('routes placeOrder through proxy when EVOMAP_PROXY=1 and proxy is running', async () => {
process.env.EVOMAP_PROXY = '1';
writeProxySettings(proxyUrl);
const hubClient = require('../src/atp/hubClient');
const result = await hubClient.placeOrder({
capabilities: ['test'],
budget: 5,
question: 'does proxy see this?',
});
assert.equal(result.ok, true);
assert.equal(result.data.via, 'proxy', 'response should come from proxy, not hub');
assert.equal(hubHits.length, 0, 'hub MUST NOT be called directly when proxy is running');
assert.equal(proxyHits.length, 1);
assert.equal(proxyHits[0].method, 'POST');
assert.equal(proxyHits[0].path, '/atp/order');
assert.equal(lastProxyBody.sender_id, process.env.A2A_NODE_ID);
assert.equal(lastProxyBody.budget, 5);
});
it('routes getOrderStatus (GET) through proxy when EVOMAP_PROXY=1', async () => {
process.env.EVOMAP_PROXY = '1';
writeProxySettings(proxyUrl);
const hubClient = require('../src/atp/hubClient');
const result = await hubClient.getOrderStatus('ord_abc123');
assert.equal(result.ok, true);
assert.equal(result.data.via, 'proxy');
assert.equal(hubHits.length, 0);
assert.equal(proxyHits.length, 1);
assert.equal(proxyHits[0].method, 'GET');
assert.equal(proxyHits[0].path, '/atp/order/ord_abc123');
});
it('routes listProofs with query string through proxy when EVOMAP_PROXY=1', async () => {
process.env.EVOMAP_PROXY = '1';
writeProxySettings(proxyUrl);
const hubClient = require('../src/atp/hubClient');
const result = await hubClient.listProofs({ role: 'merchant', limit: 10 });
assert.equal(result.ok, true);
assert.equal(hubHits.length, 0);
assert.equal(proxyHits.length, 1);
assert.match(proxyHits[0].path, /^\/atp\/proofs\?/);
assert.match(proxyHits[0].path, /role=merchant/);
assert.match(proxyHits[0].path, /limit=10/);
});
it('falls back to hub direct when EVOMAP_PROXY is unset', async () => {
// EVOMAP_PROXY not set at all -- legacy behavior.
const hubClient = require('../src/atp/hubClient');
const result = await hubClient.placeOrder({ capabilities: ['test'], budget: 1 });
assert.equal(result.ok, true);
assert.equal(result.data.via, 'hub');
assert.equal(hubHits.length, 1);
assert.equal(hubHits[0].path, '/a2a/atp/order');
assert.equal(proxyHits.length, 0, 'proxy MUST NOT be called when EVOMAP_PROXY is unset');
});
it('falls back to hub direct when EVOMAP_PROXY=1 but proxy is not running', async () => {
process.env.EVOMAP_PROXY = '1';
// intentionally NOT calling writeProxySettings -- simulates "user set the
// env flag but the proxy never started / crashed". We must not strand
// the call; falling back to hub keeps the CLI usable.
const hubClient = require('../src/atp/hubClient');
const result = await hubClient.placeOrder({ capabilities: ['test'], budget: 1 });
assert.equal(result.ok, true);
assert.equal(result.data.via, 'hub');
assert.equal(hubHits.length, 1);
assert.equal(proxyHits.length, 0);
});
it('A2A_TRANSPORT=mailbox is equivalent to EVOMAP_PROXY=1', async () => {
process.env.A2A_TRANSPORT = 'mailbox';
writeProxySettings(proxyUrl);
const hubClient = require('../src/atp/hubClient');
const result = await hubClient.placeOrder({ capabilities: ['test'], budget: 1 });
assert.equal(result.data.via, 'proxy');
assert.equal(hubHits.length, 0);
assert.equal(proxyHits.length, 1);
});
});