/** * HTTP host — both interfaces over one registry (build-plan §5, design.md §2.5). * * Drives `createApp` from source via supertest so the Express routing, auth * middleware, error mapping, MCP route, and the unauthenticated /healthz + * /openapi.json routes are all exercised (and instrumented for coverage). */ import { describe, it, expect } from 'vitest'; import request from 'supertest'; import { createApp } from './http.js'; import { ToolRegistry, defineTool } from './registry.js'; import { LocalAuthProvider, defaultLocalPrincipals, OPS_AUDIENCE } from './local-auth.js'; import { NoopAuditLogger } from './audit.js'; import { InMemoryRateLimiter } from './rate-limit.js'; import type { DispatchDeps } from './dispatch.js'; function build(opts: { edgeRateLimit?: { windowMs: number; limit: number } } = {}) { const registry = new ToolRegistry(); registry.register( defineTool<{ id: string }, { id: string; ok: boolean }>({ name: 'lookup_thing', description: 'd', tier: 'ops', requiredScope: 'ops:read', inputSchema: { type: 'object', required: ['id'], properties: { id: { type: 'string' } }, additionalProperties: false, }, handler: async (input) => ({ id: input.id, ok: true }), }), ); registry.register( defineTool<{ id: string }, unknown>({ name: 'boom', description: 'always throws', tier: 'ops', requiredScope: 'ops:read', inputSchema: { type: 'object', properties: { id: { type: 'string' } } }, handler: async () => { throw new Error('handler exploded with SENSITIVE detail'); }, }), ); registry.register( defineTool<{ id: string }, unknown>({ name: 'gmail_thing', description: 'needs gmail', tier: 'ops', requiredScope: 'gmail:self', inputSchema: { type: 'object', properties: { id: { type: 'string' } } }, handler: async () => ({ ok: true }), }), ); const deps: DispatchDeps = { auditLogger: new NoopAuditLogger(), rateLimiter: new InMemoryRateLimiter({ sessionCap: 3, perToolLimit: 2, windowMs: 60_000 }), }; return createApp({ registry, authProvider: new LocalAuthProvider({ audience: OPS_AUDIENCE, principals: defaultLocalPrincipals(), env: 'local', }), deps, mcpInfo: { name: 'sh-mcp-test', version: '0.0.1' }, openApi: { info: { title: 'Test', version: '0.0.1' }, servers: [{ url: 'http://x' }] }, edgeRateLimit: opts.edgeRateLimit, }); } const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); describe('createApp routes', () => { it('GET /healthz — unauthenticated', async () => { const res = await request(build()).get('/healthz'); expect(res.status).toBe(200); expect(res.body).toEqual({ status: 'ok' }); }); it('GET /openapi.json — unauthenticated, valid 3.1', async () => { const res = await request(build()).get('/openapi.json'); expect(res.status).toBe(200); expect(res.body.openapi).toBe('3.1.0'); }); it('POST /tools/:name — 401 without a token', async () => { const res = await request(build()).post('/tools/lookup_thing').send({ id: 'x' }); expect(res.status).toBe(401); }); it('POST /tools/:name — 200 success', async () => { const res = await request(build()) .post('/tools/lookup_thing') .set(auth('dev-ops-only')) .send({ id: 'WO-1' }); expect(res.status).toBe(200); expect(res.body).toEqual({ id: 'WO-1', ok: true }); }); it('POST /tools/:name — 400 invalid input', async () => { const res = await request(build()) .post('/tools/lookup_thing') .set(auth('dev-ops-only')) .send({ wrong: true }); expect(res.status).toBe(400); }); it('POST /tools/:name — 403 missing scope (server-side, not hiding)', async () => { const res = await request(build()) .post('/tools/gmail_thing') .set(auth('dev-ops-only')) .send({ id: 'x' }); expect(res.status).toBe(403); expect(res.body.requiredScope).toBe('gmail:self'); }); it('POST /tools/:name — 404 unknown tool', async () => { const res = await request(build()).post('/tools/nope').set(auth('dev-ops-only')).send({}); expect(res.status).toBe(404); }); it('POST /tools/:name — 500 hides the handler error detail', async () => { const res = await request(build()) .post('/tools/boom') .set(auth('dev-ops-only')) .send({ id: 'x' }); expect(res.status).toBe(500); expect(JSON.stringify(res.body)).not.toContain('SENSITIVE'); }); it('POST /tools/:name — 429 when the rate limit is exceeded', async () => { const app = build(); await request(app).post('/tools/lookup_thing').set(auth('dev-ops-only')).send({ id: '1' }); await request(app).post('/tools/lookup_thing').set(auth('dev-ops-only')).send({ id: '2' }); const res = await request(app) .post('/tools/lookup_thing') .set(auth('dev-ops-only')) .send({ id: '3' }); expect(res.status).toBe(429); }); it('edge rate limiter — throttles by IP BEFORE auth (429 on an unauthenticated flood)', async () => { // limit 2/window. Unauthenticated requests would normally 401, but the edge // limiter sits in front of `authenticate`, so the 3rd request is throttled // (429), not 401 — proving floods are capped before any JWT verification. const app = build({ edgeRateLimit: { windowMs: 60_000, limit: 2 } }); expect((await request(app).post('/tools/lookup_thing').send({ id: '1' })).status).toBe(401); expect((await request(app).post('/tools/lookup_thing').send({ id: '2' })).status).toBe(401); const res = await request(app).post('/tools/lookup_thing').send({ id: '3' }); expect(res.status).toBe(429); expect(res.body).toEqual({ error: 'rate_limited' }); }); it('edge rate limiter — also fronts /mcp', async () => { const app = build({ edgeRateLimit: { windowMs: 60_000, limit: 1 } }); expect((await request(app).post('/mcp').send({})).status).toBe(401); const res = await request(app).post('/mcp').send({}); expect(res.status).toBe(429); }); it('POST /mcp — 401 without a token', async () => { const res = await request(build()) .post('/mcp') .set('Accept', 'application/json, text/event-stream') .send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); expect(res.status).toBe(401); }); it('POST /mcp — initialize handshake succeeds with a token', async () => { const res = await request(build()) .post('/mcp') .set(auth('dev-ops-only')) .set('Accept', 'application/json, text/event-stream') .send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '0' }, }, }); expect(res.status).toBe(200); expect(res.text).toContain('serverInfo'); }); });