Files
wehub-resource-sync 4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

175 lines
7.1 KiB
TypeScript

// archiver v8 is ESM-only; stub it so ts-jest can load the module graph.
jest.mock('archiver', () => ({ TarArchive: jest.fn() }));
// Enable the MCP server before AppModule is imported.
process.env.MCP_ENABLED = 'true';
import { Test, TestingModule } from '@nestjs/testing';
import { type INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import type { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
// --- MCP protocol helpers ---
const MCP_HEADERS = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
};
function jsonRpcRequest(method: string, params: Record<string, unknown> = {}, id = 1) {
return { jsonrpc: '2.0', method, params, id };
}
/**
* Parse the MCP response. The StreamableHTTP transport sends:
* - `text/event-stream` SSE for normal requests (tools/list, tools/call)
* - `application/json` directly for some responses
*
* Supertest parses `application/json` into `.body`. For SSE, `.body` is empty
* and the actual content is in `.text` (raw string). We try both.
*/
function parseMcpResponse(res: { body: unknown; text: string }): Record<string, unknown> {
// If supertest parsed JSON, body will be a non-empty object
if (typeof res.body === 'object' && res.body !== null && Object.keys(res.body).length > 0) {
return res.body as Record<string, unknown>;
}
// SSE format: "event: message\ndata: {...}\n\n" — extract the data line
const text = res.text ?? '';
const match = /^data:\s*(.+)$/m.exec(text);
if (match) {
return JSON.parse(match[1]) as Record<string, unknown>;
}
// Plain JSON text fallback
if (text.trim().startsWith('{')) {
return JSON.parse(text) as Record<string, unknown>;
}
return {};
}
// --- Test suite ---
describe('MCP server (e2e)', () => {
let app: INestApplication<App>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
}, 30_000);
afterAll(async () => {
try {
await app?.close();
} catch {
/* ignore TypeORM multi-datasource teardown quirk */
}
});
// ---------------------------------------------------------------------------
// 1. MCP endpoint is reachable — initialize succeeds
// ---------------------------------------------------------------------------
it('POST /mcp with initialize request responds with 200', async () => {
const res = await request(app.getHttpServer())
.post('/mcp')
.set(MCP_HEADERS)
.send(
jsonRpcRequest('initialize', {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'test-client', version: '0.0.1' },
}),
);
expect([200, 202]).toContain(res.status);
});
// ---------------------------------------------------------------------------
// 2. tools/list returns the tool catalogue with known names
// ---------------------------------------------------------------------------
it('tools/list returns the tool catalogue', async () => {
const res = await request(app.getHttpServer()).post('/mcp').set(MCP_HEADERS).send(jsonRpcRequest('tools/list'));
expect(res.status).toBe(200);
const body = parseMcpResponse(res);
const result = body.result as Record<string, unknown> | undefined;
expect(result).toBeDefined();
const tools = result?.tools as Array<{ name: string }> | undefined;
expect(Array.isArray(tools)).toBe(true);
expect(tools!.length).toBeGreaterThan(0);
// Verify well-known tools are present
const names = tools!.map(t => t.name);
expect(names).toContain('SessionFindAll');
});
// ---------------------------------------------------------------------------
// 3. tools/call without API key returns an error tool result
// (auth is protocol-level: tool errors surface as isError=true, not HTTP 401)
// ---------------------------------------------------------------------------
it('tools/call without API key returns isError tool result', async () => {
const res = await request(app.getHttpServer())
.post('/mcp')
.set(MCP_HEADERS)
.send(
jsonRpcRequest('tools/call', {
name: 'SessionFindAll',
arguments: {},
}),
);
expect(res.status).toBe(200);
const body = parseMcpResponse(res);
// The tool handler catches UnauthorizedException and routes through handleToolError,
// returning a tool result with isError=true.
const result = body.result as Record<string, unknown> | undefined;
expect(result?.isError).toBe(true);
const content = result?.content as Array<{ type: string; text?: string }> | undefined;
expect(Array.isArray(content)).toBe(true);
const text = content?.find(c => c.type === 'text')?.text ?? '';
expect(text).toMatch(/unauthorized|missing api key/i);
});
// ---------------------------------------------------------------------------
// 4. Unsupported Content-Type → 415
// ---------------------------------------------------------------------------
it('POST /mcp with wrong Content-Type returns 415', async () => {
const res = await request(app.getHttpServer())
.post('/mcp')
.set({ 'Content-Type': 'text/plain', Accept: 'application/json, text/event-stream' })
.send('{}');
expect(res.status).toBe(415);
});
// ---------------------------------------------------------------------------
// 5. MCP_READONLY mode — write tools hidden from catalogue
// Tested at integration level: the `readOnly` flag path in mcp.server.ts
// calls registry.list({ readOnly: true }), which is unit-tested in
// tool-registry.spec.ts. A second full app boot is not cost-effective here;
// we note the gap and mark as pending.
// ---------------------------------------------------------------------------
it.todo('MCP_READONLY=true hides write tools — requires second app boot');
// ---------------------------------------------------------------------------
// 6. Rate limiter at 429
// The per-key limiter is fully covered by mcp-rate-limit.spec.ts.
// Triggering it in e2e needs > 60 same-key tool calls in 60s, making the
// suite slow. The wiring (mountMcpServer → rateLimiter.check) is 3 lines
// with no branching. Marking as pending.
// ---------------------------------------------------------------------------
it.todo('rate limiter returns 429-equivalent tool error past cap — see mcp-rate-limit.spec.ts');
// ---------------------------------------------------------------------------
// 7. Session-scoped key scoping
// Requires a created session + a key with allowedSessions set. The full
// session lifecycle (QR scan, WhatsApp connect) is not automatable in e2e.
// Covered by invokeTool unit test (tool-invoker.spec.ts).
// ---------------------------------------------------------------------------
it.todo('session-scoped key calling another sessions tool is rejected — see tool-invoker.spec.ts');
});