mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-03 20:13:13 +00:00
100 lines
3.5 KiB
TypeScript
100 lines
3.5 KiB
TypeScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|
||
|
|
});
|