← back to AbramsEgo
lib/a2a-client.test.js
116 lines
'use strict';
/**
* A2A client tests — Phase A (TK-10381)
* All tests use a local mock; zero network calls.
*/
const assert = require('assert');
const http = require('http');
const { fetchAgentCard, consult, lintPayload, assertSafeUrl, assertSameHost, loadAllowlist } = require('./a2a-client');
// --- helpers -----------------------------------------------------------------
function makeServer(responses) {
// responses: array of { path, status, body } served in order
let i = 0;
const server = http.createServer((req, res) => {
const cfg = responses[i++] ?? { status: 500, body: { error: 'unexpected request' } };
res.writeHead(cfg.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(cfg.body));
});
return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server)));
}
// A2A client enforces https:// — so we can't call our plain http mock directly.
// We patch fetchAgentCard/consult to bypass the URL scheme check in tests.
// Instead of patching, we test the validators directly and mock the internals.
// --- assertSafeUrl -----------------------------------------------------------
assert.throws(() => assertSafeUrl('http://example.com'), /only HTTPS/);
assert.throws(() => assertSafeUrl('https://example.com:8080'), /only port 443/);
assert.throws(() => assertSafeUrl('ftp://example.com'), /only HTTPS/);
assert.throws(() => assertSafeUrl('https://user:pass@example.com'), /embedded credentials/); // no userinfo
assertSafeUrl('https://example.com'); // must not throw
assertSafeUrl('https://example.com:443'); // explicit 443 is fine
console.log(' assertSafeUrl: 6 assertions pass');
// --- readJsonCapped (OOM guard) ----------------------------------------------
const { readJsonCapped } = require('./a2a-client');
(async () => {
const tooBigByHeader = { headers: { get: () => String(5_000_000) }, text: async () => '{}' };
await assert.rejects(readJsonCapped(tooBigByHeader), /too large/);
const okRes = { headers: { get: () => '' }, text: async () => JSON.stringify({ ok: true }) };
assert.deepStrictEqual(await readJsonCapped(okRes), { ok: true });
console.log(' readJsonCapped: 2 assertions pass');
})().catch(e => { console.error('readJsonCapped test FAILED:', e.message); process.exit(1); });
// --- assertSameHost (card-driven SSRF guard) ---------------------------------
// A fetched Agent Card is untrusted; its url must not pivot the RPC off-host.
assert.throws(() => assertSameHost('https://good.example.com', 'https://evil.example.com/'), /blocked card-driven redirect/);
assert.throws(() => assertSameHost('https://good.example.com', 'https://good.example.com.evil.com/'), /blocked card-driven redirect/);
assertSameHost('https://good.example.com', 'https://good.example.com/rpc'); // same host, different path — fine
assertSameHost('https://good.example.com/', 'https://good.example.com:443/'); // explicit 443 == default
console.log(' assertSameHost: 4 assertions pass');
// --- lintPayload -------------------------------------------------------------
assert.throws(() => lintPayload('my SHOPIFY_ADMIN_TOKEN=abc123'), /blocked/);
assert.throws(() => lintPayload('DATABASE_URL=postgres://...'), /blocked/);
// use split string to avoid triggering gitleaks on the test itself
assert.throws(() => lintPayload('key=' + 'sk_live_' + 'abcdefghijklmnop'), /blocked/);
// AI keys — the actual high-value secrets on this box must also be blocked
assert.throws(() => lintPayload('my ANTHROPIC_API_KEY=sk-ant-xyz'), /blocked/);
assert.throws(() => lintPayload('export OPENAI_API_KEY=sk-proj-abc'), /blocked/);
assert.throws(() => lintPayload('GEMINI_API_KEY=AIza...'), /blocked/);
assert.throws(() => lintPayload('REPLICATE_API_TOKEN=r8_abc123'), /blocked/);
lintPayload('What is the best approach to rate limiting?'); // clean
lintPayload('How should I structure my agent for A2A?'); // clean
console.log(' lintPayload: 9 assertions pass');
// --- extractText (untrusted-response parser robustness) ----------------------
const { extractText } = require('./a2a-client');
// valid inline message
assert.strictEqual(extractText({ result: { message: { parts: [{ kind: 'text', text: 'hello' }] } } }), 'hello');
// valid task artifacts
assert.strictEqual(extractText({ result: { task: { artifacts: [{ parts: [{ kind: 'text', text: 'a' }, { kind: 'text', text: 'b' }] }] } } }), 'a\nb');
// non-text parts ignored
assert.strictEqual(extractText({ result: { message: { parts: [{ kind: 'file', uri: 'x' }] } } }), null);
// MALFORMED / hostile shapes must NOT throw — they return null
assert.strictEqual(extractText({ result: { message: { parts: 'not-an-array' } } }), null);
assert.strictEqual(extractText({ result: { task: { artifacts: 'nope' } } }), null);
assert.strictEqual(extractText({ result: { message: { parts: [null, { kind: 'text' }, { kind: 'text', text: ' ' }] } } }), null);
assert.strictEqual(extractText({}), null);
assert.strictEqual(extractText(null), null);
console.log(' extractText: 8 assertions pass');
// --- loadAllowlist -----------------------------------------------------------
// The real file is data/a2a-agents.json (starts empty — [] by spec).
const list = loadAllowlist();
assert(Array.isArray(list), 'loadAllowlist returns an array');
// Empty at ship per spec — a new entry is a gated act
assert(list.length === 0, 'allowlist starts empty');
console.log(' loadAllowlist: 2 assertions pass');
// --- extractText (internal, via consult mock) --------------------------------
// We can't make real HTTPS calls in unit tests; we test the extraction logic
// by exercising the module's exported functions and verifying the JSONL log path.
const path = require('path');
const fs = require('fs');
const LOG_PATH = path.join(__dirname, '..', 'data', 'a2a-consults.jsonl');
const { logConsult } = require('./a2a-client');
logConsult({ agent: 'test', question: 'unit-test', answer: 'mock-answer' });
const lastLine = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').pop();
const parsed = JSON.parse(lastLine);
assert(parsed.agent === 'test', 'logConsult records agent');
assert(parsed.answer === 'mock-answer', 'logConsult records answer');
assert(parsed.ts, 'logConsult records timestamp');
// clean up test entry
const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(l => {
try { const p = JSON.parse(l); return p.agent !== 'test'; } catch { return false; }
});
fs.writeFileSync(LOG_PATH, lines.join('\n') + (lines.length ? '\n' : ''));
console.log(' logConsult: 3 assertions pass');
console.log('\nAll A2A Phase A tests passed. $0 (local)');