Judge r2 (grade C) — all 3 blockers + both polish items:
1. REQUEST-vs-NOTIFICATION by PRESENCE, never truthiness: validateJsonRpc
uses hasOwnProperty('id'); id 0 and id '' are VALID request ids echoed
back BY VALUE (strict-equality tested), id:null treated as a request
under the documented error-id convention. mcp-core default branch fixed
the same way (`if (id)` → hasOwnProperty — id 0 / '' unknown-method now
answer -32601 with the id echoed instead of being swallowed as falsy).
Routes' malformed-notification silence path also switched to
presence-based detection (id:null + bad params → 400 envelope, not 204).
2. params:null REJECTED (-32600): contract is object-or-omitted ONLY
(strict JSON-RPC; the old code's m.params !== null guard contradicted
the documented contract). Boundary tests pin the policy.
3. Unknown-method boundary tests: id 0 → -32601 id:0, id '' → -32601 id:'',
id null → -32601 id:null — asserted at BOTH layer levels (route).
Polish:
- 16-case TABLE-DRIVEN boundary matrix covering every allowed/rejected
id/params combination incl. 202 vs 204 notification split.
- Verifier now probes writable /tmp up front (exit 73 + clear message if
unavailable; documented requirement) instead of dying at mktemp.
- Verifier target count 57 → 73 (DC-162 suites) with the boundary table
named in the assertion label.
Full DC-162 suite: 73 passed (route 50 + core 23). No transport/credential
semantics changed from rev2.
urn:ump:or2qqhcytp6lfzlyi7xru7hdqy3uar2hbjllsrrbxrbray3c5kpa
527 lines
24 KiB
JavaScript
527 lines
24 KiB
JavaScript
/**
|
|
* DC-162 rev2: MCP Streamable HTTP route tests.
|
|
*
|
|
* Auth is simulated by injecting req.auth in a pre-middleware exactly as
|
|
* the app's middleware chain does (src/utilities/middleware.js:549-586 —
|
|
* types 'session' | 'jwt' | 'apikey'). Tool execution is asserted via a
|
|
* fetchImpl spy: THE CONTRACT is that the downstream call carries the
|
|
* CALLER'S credential with its TYPE preserved (apikey→x-api-key,
|
|
* jwt→Authorization Bearer) so per-route scopes apply.
|
|
*
|
|
* Mount site mirrors production: app.js does apiRouter.use('/mcp',
|
|
* mcpRoutes(...)) — if the factory ever re-adds the prefix the requests
|
|
* 404, which the tests catch.
|
|
*/
|
|
|
|
const express = require('express');
|
|
|
|
function buildRouter({ auth = null, authManager, fetchImpl } = {}) {
|
|
jest.resetModules();
|
|
const mod = require('../../routes/mcp');
|
|
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
|
const asyncHandler = (fn) => async (req, res, next) => {
|
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
};
|
|
const errorResponse = (res, code, msg, extra) => res.status(code).json({ success: false, error: msg, ...(extra || {}) });
|
|
|
|
const router = express.Router();
|
|
router.use((req, res, next) => { req.auth = auth; next(); });
|
|
router.use(mod({ asyncHandler, errorResponse, log, authManager, fetchImpl, selfBaseUrl: 'http://loopback.test' }));
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/v1/mcp', router);
|
|
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
|
res.status(err.statusCode || 500).json({ success: false, error: err.message });
|
|
});
|
|
return { app, log };
|
|
}
|
|
|
|
async function start(app) {
|
|
const server = await new Promise((resolve) => {
|
|
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
});
|
|
// base is the Streamable-HTTP endpoint itself; sub-paths append (/info…)
|
|
return { server, base: `http://127.0.0.1:${server.address().port}/api/v1/mcp` };
|
|
}
|
|
|
|
// dk_-shaped test constants built via concat: a display-layer redactor
|
|
// flattens literal key-shaped strings; both sides of each assertion use
|
|
// the SAME constant so the contract stays meaningful.
|
|
const DK_CALLER = 'dk_' + 'caller_key';
|
|
const DK_MINTED = 'dk_' + 'minted_abc';
|
|
const JWT_VALUE = 'eyJhbGciOi' + '.payload.sig';
|
|
|
|
const SESSION = { type: 'session', scope: ['admin'] };
|
|
const APIKEY = { type: 'apikey', keyId: 'k1', name: 'AI connection (claude)', scope: ['read', 'write'] };
|
|
const JWTAUTH = { type: 'jwt', userId: 'u1', scope: ['read', 'write'] };
|
|
|
|
const ACCEPT = { Accept: 'application/json, text/event-stream' };
|
|
|
|
async function postJson(url, body, headers = {}) {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...ACCEPT, ...headers },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const text = await res.text();
|
|
let json = null;
|
|
try { json = JSON.parse(text); } catch (_) {}
|
|
return { status: res.status, json, text };
|
|
}
|
|
|
|
describe('DC-162 rev2 mcp routes (Streamable HTTP)', () => {
|
|
describe('initialize + metadata', () => {
|
|
test('initialize answers 2025-03-10 with serverInfo version from package.json', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-10', capabilities: {}, clientInfo: { name: 't', version: '1' } } });
|
|
expect(r.status).toBe(200);
|
|
expect(r.json.result.serverInfo.name).toBe('dashcaddy');
|
|
expect(r.json.result.protocolVersion).toBe('2025-03-10');
|
|
const pkg = require('../../package.json');
|
|
expect(r.json.result.serverInfo.version).toBe(pkg.version);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('initialize honors a pinned 2024-11-05 request', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
expect(r.json.result.protocolVersion).toBe('2024-11-05');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('tools/list returns every tool without a machine credential', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
|
|
expect(r.status).toBe(200);
|
|
expect(r.json.result.tools.length).toBeGreaterThanOrEqual(15);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('ping answers 200 with empty result', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 11, method: 'ping', params: {} });
|
|
expect(r.status).toBe(200);
|
|
expect(r.json.result).toEqual({});
|
|
} finally { server.close(); }
|
|
});
|
|
});
|
|
|
|
describe('credential TYPE preservation (r1 blocker)', () => {
|
|
const captureFetch = (calls) => async (url, opts = {}) => {
|
|
calls.push({ url, headers: opts.headers, method: opts.method });
|
|
return { ok: true, status: 200, text: async () => JSON.stringify({ services: [{ id: 'plex', name: 'Plex' }] }) };
|
|
};
|
|
|
|
test('apikey caller: downstream carries x-api-key, NOT Authorization', async () => {
|
|
const calls = [];
|
|
const { app } = buildRouter({ auth: APIKEY, fetchImpl: captureFetch(calls) });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base,
|
|
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } },
|
|
{ 'x-api-key': DK_CALLER });
|
|
expect(r.status).toBe(200);
|
|
expect(JSON.parse(r.json.result.content[0].text).count).toBe(1);
|
|
expect(calls[0].headers['x-api-key']).toBe(DK_CALLER);
|
|
expect(calls[0].headers.Authorization).toBeUndefined();
|
|
expect(calls[0].url).toBe('http://loopback.test/api/v1/services');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('jwt caller: downstream carries Authorization Bearer, NOT x-api-key', async () => {
|
|
const calls = [];
|
|
const { app } = buildRouter({ auth: JWTAUTH, fetchImpl: captureFetch(calls) });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base,
|
|
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } },
|
|
{ Authorization: `Bearer ${JWT_VALUE}` });
|
|
expect(r.status).toBe(200);
|
|
expect(calls[0].headers.Authorization).toBe(`Bearer ${JWT_VALUE}`);
|
|
expect(calls[0].headers['x-api-key']).toBeUndefined();
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('session caller: tools/call denies with -32001 (401), zero downstream calls', async () => {
|
|
const calls = [];
|
|
const { app } = buildRouter({ auth: SESSION, fetchImpl: captureFetch(calls) });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base,
|
|
{ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } });
|
|
expect(r.status).toBe(401);
|
|
expect(r.json.error.code).toBe(-32001);
|
|
expect(r.json.id).toBe(9);
|
|
expect(calls.length).toBe(0);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('unverified x-api-key header with session auth: still denied (auth middleware sets req.auth.type)', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base,
|
|
{ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'x', arguments: {} } },
|
|
{ 'x-api-key': DK_CALLER });
|
|
expect(r.json.error.code).toBe(-32001);
|
|
} finally { server.close(); }
|
|
});
|
|
});
|
|
|
|
describe('JSON-RPC validation (r1 blocker)', () => {
|
|
test('primitive body rejected 400 (transport: strict JSON parser) — envelope not guaranteed', async () => {
|
|
// The app-wide express.json({strict:true}) parser rejects primitive
|
|
// JSON before any handler runs: HTTP 400 is the outcome the client
|
|
// sees. Objects that fail JSON-RPC validation below get the
|
|
// JSON-RPC -32600 envelope; primitives get the transport 400.
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, 'garbage-string');
|
|
expect(r.status).toBe(400);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('missing jsonrpc 2.0 → 400 -32600', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { id: 4, method: 'ping' });
|
|
expect(r.status).toBe(400);
|
|
expect(r.json.error.code).toBe(-32600);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('empty method → 400 -32600', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 5, method: '' });
|
|
expect(r.status).toBe(400);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('params as array → 400 -32600', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 6, method: 'ping', params: [1, 2] });
|
|
expect(r.status).toBe(400);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('tools/call missing params.name → 400 -32602', async () => {
|
|
const { app } = buildRouter({ auth: APIKEY });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base,
|
|
{ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { arguments: {} } },
|
|
{ 'x-api-key': DK_CALLER });
|
|
expect(r.status).toBe(400);
|
|
expect(r.json.error.code).toBe(-32602);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('batch array → 400 -32600 (2025-03-10 removed batching)', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, [
|
|
{ jsonrpc: '2.0', id: 1, method: 'ping' },
|
|
{ jsonrpc: '2.0', id: 2, method: 'ping' },
|
|
]);
|
|
expect(r.status).toBe(400);
|
|
expect(r.json.error.code).toBe(-32600);
|
|
expect(r.json.error.message).toMatch(/batching/i);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('empty array → 400 -32600', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, []);
|
|
expect(r.status).toBe(400);
|
|
expect(r.json.error.code).toBe(-32600);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('malformed notification (jsonrpc+method, no id, bad params) → 204 no body', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', method: 'notifications/test', params: 'not-an-object' });
|
|
expect(r.status).toBe(204);
|
|
expect(r.text).toBe('');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('unknown method with id → -32601', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 8, method: 'bogus/method', params: {} });
|
|
expect(r.json.error.code).toBe(-32601);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
// Judge r2 blockers #1/#2/#3 as ONE table-driven boundary matrix
|
|
// (also r2 polish): id/params presence, falsy ids, null policy.
|
|
const BOUNDARY = [
|
|
// [name, request, expect: {status, errCode?, id?, notification?}]
|
|
['id 0: valid request id, echoed', { jsonrpc: '2.0', id: 0, method: 'ping' }, { status: 200, id: 0 }],
|
|
['id empty string: valid request id, echoed', { jsonrpc: '2.0', id: '', method: 'ping' }, { status: 200, id: '' }],
|
|
['id null: request under our policy, error id null echoed', { jsonrpc: '2.0', id: null, method: 'ping' }, { status: 200, id: null }],
|
|
['id null with unknown method: -32601 id null', { jsonrpc: '2.0', id: null, method: 'bogus/x' }, { status: 200, errCode: -32601, id: null }],
|
|
['id 0 with unknown method: -32601 echoes 0, never swallowed', { jsonrpc: '2.0', id: 0, method: 'bogus/x' }, { status: 200, errCode: -32601, id: 0 }],
|
|
['id empty string with unknown method: -32601 echoes ""', { jsonrpc: '2.0', id: '', method: 'bogus/x' }, { status: 200, errCode: -32601, id: '' }],
|
|
['params null rejected: -32600 (object-or-omitted only)', { jsonrpc: '2.0', id: 3, method: 'ping', params: null }, { status: 400, errCode: -32600 }],
|
|
['params omitted: accepted', { jsonrpc: '2.0', id: 4, method: 'ping' }, { status: 200 }],
|
|
['params empty object: accepted', { jsonrpc: '2.0', id: 5, method: 'ping', params: {} }, { status: 200 }],
|
|
['params array rejected', { jsonrpc: '2.0', id: 6, method: 'ping', params: [] }, { status: 400, errCode: -32600 }],
|
|
['params string rejected', { jsonrpc: '2.0', id: 7, method: 'ping', params: 'x' }, { status: 400, errCode: -32600 }],
|
|
['id boolean rejected: -32600 id null', { jsonrpc: '2.0', id: true, method: 'ping' }, { status: 400, errCode: -32600, id: null }],
|
|
['id object rejected', { jsonrpc: '2.0', id: { a: 1 }, method: 'ping' }, { status: 400, errCode: -32600 }],
|
|
['no id (absent): notification → 202', { jsonrpc: '2.0', method: 'notifications/x' }, { status: 202, notification: true }],
|
|
['no id + broken params: malformed notification → 204 silent', { jsonrpc: '2.0', method: 'notifications/x', params: 'oops' }, { status: 204, notification: true }],
|
|
['no id + params null: malformed notification → 204 silent', { jsonrpc: '2.0', method: 'notifications/x', params: null }, { status: 204, notification: true }],
|
|
];
|
|
for (const [name, request, exp] of BOUNDARY) {
|
|
test(`boundary: ${name}`, async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, request);
|
|
expect(r.status).toBe(exp.status);
|
|
if (exp.errCode !== undefined) expect(r.json.error.code).toBe(exp.errCode);
|
|
// strict equality on id: falsy ids (0, '') must echo by VALUE
|
|
if ('id' in exp) expect(r.json.id).toBe(exp.id);
|
|
if (exp.notification) expect(r.text).toBe('');
|
|
} finally { server.close(); }
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('notifications (no id) — spec 202 Accepted', () => {
|
|
test('notifications/initialized → 202 empty body', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
|
|
expect(r.status).toBe(202);
|
|
expect(r.text).toBe('');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('tools/call notification keyless → 404 (not accepted), no body', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', method: 'tools/call', params: { name: 'x', arguments: {} } });
|
|
expect(r.status).toBe(404);
|
|
expect(r.text).toBe('');
|
|
} finally { server.close(); }
|
|
});
|
|
});
|
|
|
|
describe('transport rules (Streamable HTTP spec)', () => {
|
|
test('GET / → 405 + Allow: POST (spec-permitted rejection)', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(base);
|
|
expect(res.status).toBe(405);
|
|
expect(res.headers.get('allow')).toBe('POST');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('DELETE / → 405 + Allow (stateless: no session to terminate)', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(base, { method: 'DELETE' });
|
|
expect(res.status).toBe(405);
|
|
expect(res.headers.get('allow')).toBe('POST');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('foreign Origin → 403 (DNS-rebinding guard)', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'ping' }, { Origin: 'http://evil.test' });
|
|
expect(r.status).toBe(403);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('same-origin Origin allowed', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const host = new URL(base).host;
|
|
const r = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'ping' }, { Origin: `http://${host}` });
|
|
expect(r.status).toBe(200);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('Accept excluding application/json → 406', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(base, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }),
|
|
});
|
|
expect(res.status).toBe(406);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('MCP-Protocol-Version header validated when present', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const bad = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'ping' }, { 'MCP-Protocol-Version': '1999-01-01' });
|
|
expect(bad.status).toBe(400);
|
|
const good = await postJson(base, { jsonrpc: '2.0', id: 2, method: 'ping' }, { 'MCP-Protocol-Version': '2025-03-10' });
|
|
expect(good.status).toBe(200);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('loopback network failure surfaces as JSON-RPC result, not a crash', async () => {
|
|
const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
|
|
const { app } = buildRouter({ auth: APIKEY, fetchImpl });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(base,
|
|
{ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } },
|
|
{ 'x-api-key': DK_CALLER });
|
|
expect(r.status).toBe(200);
|
|
const payload = JSON.parse(r.json.result.content[0].text);
|
|
expect(payload.error).toBe(true);
|
|
expect(payload.code).toBe('NETWORK_ERROR');
|
|
} finally { server.close(); }
|
|
});
|
|
});
|
|
|
|
describe('GET /info', () => {
|
|
test('returns streamable-http metadata for the modal', async () => {
|
|
const { app } = buildRouter({ auth: APIKEY });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/info`);
|
|
const body = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(body.endpoint).toBe(`http://127.0.0.1:${server.address().port}/api/v1/mcp`);
|
|
expect(body.transport).toBe('streamable-http');
|
|
expect(body.protocolVersion).toBe('2025-03-10');
|
|
expect(body.toolCount).toBeGreaterThanOrEqual(15);
|
|
expect(body.scopes).toEqual(['read', 'write']);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('honors x-forwarded-proto/host for the endpoint URL', async () => {
|
|
const { app } = buildRouter({ auth: SESSION });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/info`, { headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'status.sami' } });
|
|
const body = await res.json();
|
|
expect(body.endpoint).toBe('https://status.sami/api/v1/mcp');
|
|
} finally { server.close(); }
|
|
});
|
|
});
|
|
|
|
describe('GET /connections (r1 polish: AI keys only)', () => {
|
|
const makeAuthManager = (keys) => ({
|
|
listAPIKeys: jest.fn(async () => keys),
|
|
generateAPIKey: jest.fn(async (name, scopes) => ({
|
|
key: DK_MINTED, id: 'kid1', name, scopes, createdAt: '2026-10-08T00:00:00.000Z',
|
|
})),
|
|
});
|
|
|
|
test('session lists only AI connection keys', async () => {
|
|
const keys = [
|
|
{ id: 'a', name: 'AI connection (claude)', scopes: ['read', 'write'] },
|
|
{ id: 'b', name: 'ci deploy key', scopes: ['write'] },
|
|
];
|
|
const { app } = buildRouter({ auth: SESSION, authManager: makeAuthManager(keys) });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/connections`);
|
|
const body = await res.json();
|
|
expect(body.connections.length).toBe(1);
|
|
expect(body.connections[0].name).toBe('AI connection (claude)');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('apikey caller refused 403', async () => {
|
|
const { app } = buildRouter({ auth: APIKEY, authManager: makeAuthManager([]) });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/connections`);
|
|
expect(res.status).toBe(403);
|
|
} finally { server.close(); }
|
|
});
|
|
});
|
|
|
|
describe('POST /config — connection key minting', () => {
|
|
const makeAuthManager = () => ({
|
|
generateAPIKey: jest.fn(async (name, scopes) => ({
|
|
key: DK_MINTED, id: 'kid1', name, scopes, createdAt: '2026-10-08T00:00:00.000Z',
|
|
})),
|
|
});
|
|
|
|
test('session caller gets a key + standard streamable-http config', async () => {
|
|
const authManager = makeAuthManager();
|
|
const { app } = buildRouter({ auth: SESSION, authManager });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(`${base}/config`, { client: 'claude' });
|
|
expect(r.status).toBe(200);
|
|
expect(r.json.key).toBe(DK_MINTED);
|
|
expect(authManager.generateAPIKey).toHaveBeenCalledWith('AI connection (claude)', ['read', 'write']);
|
|
expect(r.json.httpConfig.transport).toBe('streamable-http');
|
|
expect(r.json.httpConfig.headers['x-api-key']).toBe(DK_MINTED);
|
|
expect(r.json.warning).toMatch(/not be shown again/);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('apikey caller is refused — keys cannot mint keys', async () => {
|
|
const { app } = buildRouter({ auth: APIKEY, authManager: makeAuthManager() });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(`${base}/config`, { client: 'claude' });
|
|
expect(r.status).toBe(403);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('invalid client rejected', async () => {
|
|
const { app } = buildRouter({ auth: SESSION, authManager: makeAuthManager() });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(`${base}/config`, { client: 'skynet' });
|
|
expect(r.status).toBe(400);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('chatgpt config carries the honest public-https note', async () => {
|
|
const { app } = buildRouter({ auth: SESSION, authManager: makeAuthManager() });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const r = await postJson(`${base}/config`, { client: 'chatgpt' });
|
|
expect(r.status).toBe(200);
|
|
expect(r.json.note).toMatch(/public https|PUBLIC/);
|
|
} finally { server.close(); }
|
|
});
|
|
});
|
|
});
|