test(opax-web): cover secure MCP agent client
This commit is contained in:
parent
b0bdca55b0
commit
630440b417
403
opax-web/backend/agentClient.test.js
Normal file
403
opax-web/backend/agentClient.test.js
Normal file
|
|
@ -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, {}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user