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