sh-mcp/packages/shared/src/openapi.test.ts

310 lines
10 KiB
TypeScript
Raw Normal View History

Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2) * Phase 0b slice: monorepo scaffold + shared core + integration packages The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers. - Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate), eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu). - @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry, redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2. - 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base, gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind injected client interfaces; finance handlers call redact(). Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally deferred until the 0a spike resolves it (G16/§0.4). * Complete Cognito auth provider + Phase 1 build brief Finish the WIP CognitoAuthProvider (client_id allow-list as audience boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with its test suite, and check in docs/build-plan-phase-1.md so the Phase 1 work has its governing brief in-tree (design.md §2.5). * ci: disable cdk synth for Phase 0b (no CDK app yet) The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails with '--app is required'. Disable it here; Phase 1 re-enables it with the server CDK stubs.
2026-06-26 12:42:17 -04:00
import { describe, it, expect, beforeEach } from 'vitest';
import { ToolRegistry, defineTool } from './registry.js';
import { generateOpenAPIPaths } from './openapi.js';
import type { OASPathsResult } from './openapi.js';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const lookupWorkOrder = defineTool({
name: 'lookup-work-order',
description: 'Look up a work order by ID.',
tier: 'ops',
requiredScope: 'ops:read',
inputSchema: {
type: 'object',
required: ['workOrderId'],
properties: {
workOrderId: { type: 'string', description: 'The work order ID.' },
},
additionalProperties: false,
},
handler: async (_input, _ctx) => ({ id: 'WO-001', status: 'open' }),
});
const searchVendors = defineTool({
name: 'search-vendors',
description: 'Search vendors in QuickBooks Online.',
tier: 'finance',
requiredScope: 'finance:read',
inputSchema: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', description: 'Vendor name or keyword.' },
limit: { type: 'integer', default: 10 },
},
additionalProperties: false,
},
handler: async (_input, _ctx) => ({ vendors: [] }),
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildRegistry(...tools: ReturnType<typeof defineTool>[]): ToolRegistry {
const reg = new ToolRegistry();
for (const t of tools) reg.register(t);
return reg;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('generateOpenAPIPaths', () => {
let result: OASPathsResult;
beforeEach(() => {
const registry = buildRegistry(lookupWorkOrder, searchVendors);
result = generateOpenAPIPaths(registry);
});
// -------------------------------------------------------------------------
// Basic structure
// -------------------------------------------------------------------------
it('produces a paths object', () => {
expect(result).toHaveProperty('paths');
expect(typeof result.paths).toBe('object');
});
it('produces one path per registered tool', () => {
expect(Object.keys(result.paths)).toHaveLength(2);
});
it('generates the correct path key for each tool', () => {
expect(result.paths).toHaveProperty('/tools/lookup-work-order');
expect(result.paths).toHaveProperty('/tools/search-vendors');
});
// -------------------------------------------------------------------------
// POST operation structure
// -------------------------------------------------------------------------
it('wraps each tool in a POST operation', () => {
const pathItem = result.paths['/tools/lookup-work-order']!;
expect(pathItem).toHaveProperty('post');
expect(pathItem).not.toHaveProperty('get');
});
it('sets operationId to the tool name', () => {
expect(result.paths['/tools/lookup-work-order']!.post.operationId).toBe(
'lookup-work-order',
);
});
it('sets summary to the tool description', () => {
expect(result.paths['/tools/lookup-work-order']!.post.summary).toBe(
'Look up a work order by ID.',
);
});
// -------------------------------------------------------------------------
// Tags and security
// -------------------------------------------------------------------------
it('tags an ops tool with ["ops"]', () => {
expect(result.paths['/tools/lookup-work-order']!.post.tags).toEqual(['ops']);
});
it('tags a finance tool with ["finance"]', () => {
expect(result.paths['/tools/search-vendors']!.post.tags).toEqual([
'finance',
]);
});
it('adds bearerAuth security requirement to every operation', () => {
const op = result.paths['/tools/lookup-work-order']!.post;
expect(op.security).toEqual([{ bearerAuth: [] }]);
});
it('sets x-required-scope extension from the tool definition', () => {
expect(
result.paths['/tools/lookup-work-order']!.post['x-required-scope'],
).toBe('ops:read');
expect(
result.paths['/tools/search-vendors']!.post['x-required-scope'],
).toBe('finance:read');
});
// -------------------------------------------------------------------------
// Request body
// -------------------------------------------------------------------------
it('marks requestBody as required', () => {
const op = result.paths['/tools/lookup-work-order']!.post;
expect(op.requestBody.required).toBe(true);
});
it('uses application/json for the requestBody media type', () => {
const op = result.paths['/tools/lookup-work-order']!.post;
expect(op.requestBody.content).toHaveProperty('application/json');
});
it('round-trips the tool inputSchema verbatim into the requestBody', () => {
const op = result.paths['/tools/lookup-work-order']!.post;
expect(op.requestBody.content['application/json'].schema).toEqual(
lookupWorkOrder.inputSchema,
);
});
it('round-trips the finance tool inputSchema verbatim', () => {
const op = result.paths['/tools/search-vendors']!.post;
expect(op.requestBody.content['application/json'].schema).toEqual(
searchVendors.inputSchema,
);
});
// -------------------------------------------------------------------------
// Responses
// -------------------------------------------------------------------------
it('includes a 200 response', () => {
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
expect(responses).toHaveProperty('200');
});
it('200 response has application/json content', () => {
const r200 =
result.paths['/tools/lookup-work-order']!.post.responses['200']!;
expect(r200.content).toHaveProperty('application/json');
});
it('includes a 403 response for scope errors', () => {
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
expect(responses).toHaveProperty('403');
});
it('403 response schema has requiredScope property', () => {
const r403 =
result.paths['/tools/lookup-work-order']!.post.responses['403']!;
const schema = r403.content!['application/json'].schema as {
properties: Record<string, unknown>;
};
expect(schema.properties).toHaveProperty('requiredScope');
});
it('includes a 401 response for missing/invalid token', () => {
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
expect(responses).toHaveProperty('401');
});
// -------------------------------------------------------------------------
// Components / security schemes
// -------------------------------------------------------------------------
it('includes a bearerAuth security scheme in components', () => {
expect(result.components.securitySchemes).toHaveProperty('bearerAuth');
expect(result.components.securitySchemes.bearerAuth.type).toBe('http');
expect(result.components.securitySchemes.bearerAuth.scheme).toBe('bearer');
expect(result.components.securitySchemes.bearerAuth.bearerFormat).toBe(
'JWT',
);
});
// -------------------------------------------------------------------------
// Empty registry
// -------------------------------------------------------------------------
it('produces an empty paths object for an empty registry', () => {
const empty = generateOpenAPIPaths(new ToolRegistry());
expect(Object.keys(empty.paths)).toHaveLength(0);
});
// -------------------------------------------------------------------------
// Round-trip: registry → paths → verify all tools represented
// -------------------------------------------------------------------------
it('round-trip: every registered tool appears exactly once in paths', () => {
const tools = [
defineTool({
name: 'create-task',
description: 'Create a task.',
tier: 'ops',
requiredScope: 'ops:tasks',
inputSchema: {
type: 'object',
required: ['title'],
properties: { title: { type: 'string' } },
},
handler: async () => ({ id: 'T-1' }),
}),
defineTool({
name: 'lookup-payment-by-vendor',
description: 'Look up payments by vendor.',
tier: 'finance',
requiredScope: 'finance:read',
inputSchema: {
type: 'object',
required: ['vendorName'],
properties: { vendorName: { type: 'string' } },
},
handler: async () => ({ payments: [] }),
}),
];
const reg = buildRegistry(...tools);
const out = generateOpenAPIPaths(reg);
const paths = Object.keys(out.paths);
expect(paths).toContain('/tools/create-task');
expect(paths).toContain('/tools/lookup-payment-by-vendor');
expect(paths).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// ToolRegistry — defineTool and registry unit tests
// ---------------------------------------------------------------------------
describe('ToolRegistry', () => {
it('registers and lists a tool', () => {
const reg = new ToolRegistry();
reg.register(lookupWorkOrder);
expect(reg.list()).toHaveLength(1);
expect(reg.list()[0]!.name).toBe('lookup-work-order');
});
it('get() returns a registered tool by name', () => {
const reg = new ToolRegistry();
reg.register(lookupWorkOrder);
const found = reg.get('lookup-work-order');
expect(found).toBeDefined();
expect(found!.name).toBe('lookup-work-order');
});
it('get() returns undefined for unknown tool', () => {
const reg = new ToolRegistry();
expect(reg.get('nonexistent')).toBeUndefined();
});
it('throws on duplicate tool name', () => {
const reg = new ToolRegistry();
reg.register(lookupWorkOrder);
expect(() => reg.register(lookupWorkOrder)).toThrow(
/duplicate tool name/,
);
});
it('register() is chainable', () => {
const reg = new ToolRegistry();
reg.register(lookupWorkOrder).register(searchVendors);
expect(reg.size).toBe(2);
});
it('size reflects the number of registered tools', () => {
const reg = new ToolRegistry();
expect(reg.size).toBe(0);
reg.register(lookupWorkOrder);
expect(reg.size).toBe(1);
});
});
describe('defineTool', () => {
it('returns the definition unchanged', () => {
const def = defineTool(lookupWorkOrder);
expect(def).toBe(lookupWorkOrder);
});
});