sh-mcp/servers/sh-mcp-ops/test/server.test.ts

100 lines
3.5 KiB
TypeScript
Raw Normal View History

/**
* 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);
});
});