← 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)');