OSVauco/opax-web/backend/agentClient.test.js

404 lines
10 KiB
JavaScript

import { after, afterEach, before, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import {
AgentNotConfiguredError,
callAgent,
} from './agentClient.js';
const DUMMY_SECRET = 'test-secret-not-production';
const DUMMY_TOOL = 'get_health';
const GENERIC_ERROR_MESSAGE = 'Agent request failed.';
const MAX_RESPONSE_SIZE = 1024 * 1024;
const ENV_KEYS = [
'OPAX_MCP_URL',
'MCP_SECRET',
'OPAX_MCP_ALLOWED_ORIGIN',
];
const originalEnv = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
);
const originalFetch = globalThis.fetch;
function restoreEnvironment() {
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalEnv[key];
}
}
}
function clearTestEnvironment() {
for (const key of ENV_KEYS) {
delete process.env[key];
}
}
function configureLocalMock(url) {
process.env.OPAX_MCP_URL = url;
process.env.MCP_SECRET = DUMMY_SECRET;
delete process.env.OPAX_MCP_ALLOWED_ORIGIN;
}
async function expectGenericFailure(action) {
await assert.rejects(action, {
message: GENERIC_ERROR_MESSAGE,
});
}
async function expectNoFetch(action) {
let fetchCalled = false;
globalThis.fetch = async () => {
fetchCalled = true;
throw new Error('fetch must not be called');
};
try {
await action();
assert.equal(fetchCalled, false);
} finally {
globalThis.fetch = originalFetch;
}
}
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
'Content-Type': 'application/json',
});
response.end(JSON.stringify(body));
}
describe('callAgent', { concurrency: false }, () => {
beforeEach(() => {
clearTestEnvironment();
globalThis.fetch = originalFetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
restoreEnvironment();
});
after(() => {
globalThis.fetch = originalFetch;
restoreEnvironment();
});
describe('configuration and local validation', () => {
it('rejects a missing MCP URL with AgentNotConfiguredError', async () => {
process.env.MCP_SECRET = DUMMY_SECRET;
await assert.rejects(
() => callAgent(DUMMY_TOOL, {}),
AgentNotConfiguredError,
);
});
it('rejects a missing MCP secret with AgentNotConfiguredError', async () => {
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
await assert.rejects(
() => callAgent(DUMMY_TOOL, {}),
AgentNotConfiguredError,
);
});
it('rejects whitespace-only MCP configuration', async () => {
process.env.OPAX_MCP_URL = ' ';
process.env.MCP_SECRET = DUMMY_SECRET;
await assert.rejects(
() => callAgent(DUMMY_TOOL, {}),
AgentNotConfiguredError,
);
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
process.env.MCP_SECRET = ' ';
await assert.rejects(
() => callAgent(DUMMY_TOOL, {}),
AgentNotConfiguredError,
);
});
it('rejects invalid and unsafe URLs before fetch', async () => {
const unsafeUrls = [
'not a URL',
'ftp://127.0.0.1:12345',
'http://example.com',
'http://user:password@127.0.0.1:12345',
'http://127.0.0.1:12345#fragment',
];
for (const url of unsafeUrls) {
process.env.OPAX_MCP_URL = url;
process.env.MCP_SECRET = DUMMY_SECRET;
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, {})),
);
}
});
it('rejects HTTPS without an allowed origin before fetch', async () => {
process.env.OPAX_MCP_URL = 'https://example.invalid';
process.env.MCP_SECRET = DUMMY_SECRET;
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, {})),
);
});
it('rejects HTTPS with a mismatched origin before fetch', async () => {
process.env.OPAX_MCP_URL = 'https://example.invalid';
process.env.MCP_SECRET = DUMMY_SECRET;
process.env.OPAX_MCP_ALLOWED_ORIGIN = 'https://allowed.invalid';
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, {})),
);
});
it('rejects non-allowlisted and mutating tools before fetch', async () => {
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
process.env.MCP_SECRET = DUMMY_SECRET;
for (const tool of ['unknown_tool', 'commit_and_push_files']) {
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(tool, {})),
);
}
});
it('rejects circular arguments before fetch', async () => {
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
process.env.MCP_SECRET = DUMMY_SECRET;
const circular = {};
circular.self = circular;
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, circular)),
);
});
it('rejects an oversized request before fetch', async () => {
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
process.env.MCP_SECRET = DUMMY_SECRET;
const largeArguments = {
data: 'a'.repeat(17000),
};
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, largeArguments)),
);
});
});
describe('local MCP mock', () => {
let server;
let serverUrl;
let handler;
before(async () => {
server = createServer((request, response) => {
Promise.resolve(handler?.(request, response))
.catch(() => {
if (!response.headersSent) {
response.writeHead(500);
}
response.end();
});
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
serverUrl = `http://127.0.0.1:${address.port}`;
});
beforeEach(() => {
configureLocalMock(serverUrl);
handler = (_request, response) => {
response.writeHead(500);
response.end();
};
});
after(async () => {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
it('returns parsed inner JSON from a valid JSON-RPC response', async () => {
const expected = {
ok: true,
status: 'healthy',
};
handler = (_request, response) => {
sendJson(response, 200, {
jsonrpc: '2.0',
id: 'mock-response-id',
result: {
content: [{
type: 'text',
text: JSON.stringify(expected),
}],
},
});
};
const result = await callAgent(DUMMY_TOOL, {
check: true,
});
assert.deepEqual(result, expected);
});
it('sends the required JSON-RPC request contract', async () => {
const argumentsValue = {
scope: 'local-test',
};
let received;
handler = async (request, response) => {
let body = '';
for await (const chunk of request) {
body += chunk;
}
received = {
method: request.method,
contentType: request.headers['content-type'],
authorization: request.headers.authorization,
body: JSON.parse(body),
};
sendJson(response, 200, {
jsonrpc: '2.0',
id: received.body.id,
result: {
content: [{
type: 'text',
text: '{}',
}],
},
});
};
await callAgent(DUMMY_TOOL, argumentsValue);
assert.equal(received.method, 'POST');
assert.equal(received.contentType, 'application/json');
assert.equal(
received.authorization,
`Bearer ${DUMMY_SECRET}`,
);
assert.equal(received.body.jsonrpc, '2.0');
assert.equal(received.body.method, 'tools/call');
assert.equal(typeof received.body.id, 'string');
assert.ok(received.body.id.length > 0);
assert.equal(received.body.params.name, DUMMY_TOOL);
assert.deepEqual(
received.body.params.arguments,
argumentsValue,
);
});
it('normalizes an HTTP error', async () => {
handler = (_request, response) => {
response.writeHead(500);
response.end();
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
});
it('normalizes malformed outer JSON', async () => {
handler = (_request, response) => {
response.writeHead(200, {
'Content-Type': 'application/json',
});
response.end('not-json');
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
});
it('normalizes a JSON-RPC error', async () => {
handler = (_request, response) => {
sendJson(response, 200, {
jsonrpc: '2.0',
id: 'mock-response-id',
error: {
code: -32000,
message: 'mock failure',
},
});
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
});
it('normalizes invalid content responses', async () => {
const invalidResponses = [
{
result: {
content: [],
},
},
{
result: {
content: [{
type: 'image',
text: '{}',
}],
},
},
{
result: {
content: [{
type: 'text',
text: 'not-json',
}],
},
},
];
for (const invalidResponse of invalidResponses) {
handler = (_request, response) => {
sendJson(response, 200, {
jsonrpc: '2.0',
id: 'mock-response-id',
...invalidResponse,
});
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
}
});
it('normalizes a response body larger than 1 MiB', async () => {
handler = (_request, response) => {
response.writeHead(200, {
'Content-Type': 'text/plain',
});
response.end('a'.repeat(MAX_RESPONSE_SIZE + 1));
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
});
});
});