From 630440b417fd6c3d22af63def55fe416098b624f Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Mon, 7 Sep 2026 21:43:43 +0000 Subject: [PATCH] test(opax-web): cover secure MCP agent client --- opax-web/backend/agentClient.test.js | 403 +++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 opax-web/backend/agentClient.test.js diff --git a/opax-web/backend/agentClient.test.js b/opax-web/backend/agentClient.test.js new file mode 100644 index 0000000..dbf1789 --- /dev/null +++ b/opax-web/backend/agentClient.test.js @@ -0,0 +1,403 @@ +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, {})); + }); + }); +});