/** * sh-mcp-ops integration tests — the fully composed server over HTTP. * * Exercises the real registry + LocalAuthProvider + dispatch path end-to-end * with the in-memory dev clients (build-plan §5): healthz, openapi, tool-hiding * in the per-tool path, server-side scope enforcement, per-user data isolation, * and the unauthenticated-path guarantees (design.md §2.5). */ import { describe, it, expect, beforeAll } from 'vitest'; import request from 'supertest'; import type { Express } from 'express'; import { buildOpsApp } from '../src/app.js'; import type { OpsConfig } from '../src/config.js'; const config: OpsConfig = { env: 'local', port: 0, audience: 'sh-mcp-ops' }; let app: Express; beforeAll(async () => { ({ app } = await buildOpsApp(config)); }); const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); describe('sh-mcp-ops server', () => { it('GET /healthz is unauthenticated and ok', async () => { const res = await request(app).get('/healthz'); expect(res.status).toBe(200); expect(res.body).toEqual({ status: 'ok' }); }); it('GET /openapi.json is unauthenticated, 3.1, and lists the 15 ops tools', async () => { const res = await request(app).get('/openapi.json'); expect(res.status).toBe(200); expect(res.body.openapi).toBe('3.1.0'); expect(Object.keys(res.body.paths)).toHaveLength(15); expect(res.body.components.securitySchemes.bearerAuth.scheme).toBe('bearer'); }); it('rejects an unauthenticated tool call (→401)', async () => { const res = await request(app) .post('/tools/lookup_work_order') .send({ workOrderId: 'WO-1001' }); expect(res.status).toBe(401); }); it('returns real dev data for a permitted tool', async () => { const res = await request(app) .post('/tools/lookup_work_order') .set(auth('dev-ops-only')) .send({ workOrderId: 'WO-1001' }); expect(res.status).toBe(200); expect(res.body.found).toBe(true); expect(res.body.workOrder.workOrderId).toBe('WO-1001'); }); it('rejects malformed input (→400) before the handler', async () => { const res = await request(app) .post('/tools/lookup_work_order') .set(auth('dev-ops-only')) .send({ nope: true }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_input'); }); it('SERVER-SIDE SCOPE: a forced call to a tool the caller lacks scope for is 403', async () => { // dev-ops-only lacks gmail:self — search_inbox is registered but hidden. const res = await request(app) .post('/tools/search_inbox') .set(auth('dev-ops-only')) .send({ query: 'invoice' }); expect(res.status).toBe(403); expect(res.body.requiredScope).toBe('gmail:self'); }); it('PER-USER ISOLATION: a user only sees their own gmail data', async () => { // dev-assistant = lauren@; her seeded inbox is non-empty. const res = await request(app) .post('/tools/search_inbox') .set(auth('dev-assistant')) .send({ query: 'Invoice' }); expect(res.status).toBe(200); expect(Array.isArray(res.body.messages)).toBe(true); }); it('returns 404 for an unknown tool', async () => { const res = await request(app).post('/tools/does_not_exist').set(auth('dev-ops-only')).send({}); expect(res.status).toBe(404); }); it('AUDIENCE BINDING: a finance principal is rejected by the ops server (→401)', async () => { const res = await request(app) .post('/tools/lookup_work_order') .set(auth('dev-finance')) .send({ workOrderId: 'WO-1001' }); expect(res.status).toBe(401); }); });