mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-09-30 22:53:17 +00:00
Some checks are pending
deploy / deploy (push) Waiting to run
* 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.
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
/**
|
|
* Unit tests for @sh-mcp/knowledge-base.
|
|
*
|
|
* All tests use:
|
|
* - A mock AuthContext with the required ops:read scope.
|
|
* - A mock KnowledgeBaseClient — no AWS calls, no network.
|
|
*
|
|
* Coverage targets:
|
|
* - Happy path: results returned and shaped correctly.
|
|
* - Empty result: KB returns no matches.
|
|
* - Scope enforcement: missing scope throws ScopeError.
|
|
* - Client error: upstream error surfaces as a rejected promise.
|
|
* - Throttle / retry: upstream ThrottlingException propagates (retry
|
|
* logic, if added, would be tested here).
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { createKnowledgeBaseTools } from '../src/tools.js';
|
|
import type { KnowledgeBaseClient, KnowledgeBaseResult } from '../src/client.js';
|
|
import type { AuthContext } from '@sh-mcp/shared';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** A valid AuthContext carrying the ops:read scope. */
|
|
const authorisedCtx: AuthContext = {
|
|
sub: 'lauren@seahavenind.com',
|
|
scopes: ['ops:read'],
|
|
aud: 'sh-mcp-ops',
|
|
};
|
|
|
|
/** An AuthContext with no scopes — used to test scope enforcement. */
|
|
const unauthorisedCtx: AuthContext = {
|
|
sub: 'guest@example.com',
|
|
scopes: [],
|
|
aud: 'sh-mcp-ops',
|
|
};
|
|
|
|
/** A finance-only AuthContext (finance:read but not ops:read). */
|
|
const financeOnlyCtx: AuthContext = {
|
|
sub: 'accounting@seahavenind.com',
|
|
scopes: ['finance:read'],
|
|
aud: 'sh-mcp-finance',
|
|
};
|
|
|
|
/** Sample KB results returned by the mock. */
|
|
const sampleResults: KnowledgeBaseResult[] = [
|
|
{
|
|
source: 's3://sh-kb-data/notion/procedures.md',
|
|
score: 0.92,
|
|
passage: 'All maintenance requests must be submitted via the work-order portal.',
|
|
},
|
|
{
|
|
source: 's3://sh-kb-data/work-orders/WO-1042.md',
|
|
score: 0.78,
|
|
passage: 'Work order 1042: HVAC inspection completed 2025-11-15.',
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock client factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeMockClient(
|
|
implementation?: Partial<KnowledgeBaseClient>
|
|
): KnowledgeBaseClient {
|
|
return {
|
|
retrieve: vi.fn().mockResolvedValue(sampleResults),
|
|
...implementation,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('search_knowledge_base', () => {
|
|
let mockClient: KnowledgeBaseClient;
|
|
|
|
beforeEach(() => {
|
|
mockClient = makeMockClient();
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Happy path
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns shaped results for a valid query', async () => {
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
|
|
const output = await tool.handler(
|
|
{ query: 'maintenance procedures', maxResults: 5 },
|
|
authorisedCtx
|
|
);
|
|
|
|
expect(output.count).toBe(2);
|
|
expect(output.results).toHaveLength(2);
|
|
|
|
const [first] = output.results;
|
|
expect(first.source).toBe('s3://sh-kb-data/notion/procedures.md');
|
|
expect(first.score).toBe(0.92);
|
|
expect(first.passage).toContain('maintenance requests');
|
|
});
|
|
|
|
it('passes query and maxResults through to the client', async () => {
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
|
|
await tool.handler({ query: 'HVAC vendors', maxResults: 3 }, authorisedCtx);
|
|
|
|
expect(mockClient.retrieve).toHaveBeenCalledOnce();
|
|
expect(mockClient.retrieve).toHaveBeenCalledWith({
|
|
query: 'HVAC vendors',
|
|
maxResults: 3,
|
|
});
|
|
});
|
|
|
|
it('defaults maxResults to 5 when omitted', async () => {
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
|
|
await tool.handler({ query: 'fire safety' }, authorisedCtx);
|
|
|
|
expect(mockClient.retrieve).toHaveBeenCalledWith({
|
|
query: 'fire safety',
|
|
maxResults: 5,
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Tool metadata assertions
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('has correct tool metadata', () => {
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
|
|
expect(tool.name).toBe('search_knowledge_base');
|
|
expect(tool.tier).toBe('ops');
|
|
expect(tool.requiredScope).toBe('ops:read');
|
|
expect(tool.description).toMatch(/knowledge base/i);
|
|
});
|
|
|
|
it('exports exactly one tool', () => {
|
|
const tools = createKnowledgeBaseTools(mockClient);
|
|
expect(tools).toHaveLength(1);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Empty result
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns an empty results array when the KB finds no matches', async () => {
|
|
const emptyClient = makeMockClient({
|
|
retrieve: vi.fn().mockResolvedValue([]),
|
|
});
|
|
const [tool] = createKnowledgeBaseTools(emptyClient);
|
|
|
|
const output = await tool.handler(
|
|
{ query: 'nonexistent topic xyz' },
|
|
authorisedCtx
|
|
);
|
|
|
|
expect(output.count).toBe(0);
|
|
expect(output.results).toEqual([]);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Scope enforcement
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('throws ScopeError when the caller has no scopes', async () => {
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
|
|
await expect(
|
|
tool.handler({ query: 'anything' }, unauthorisedCtx)
|
|
).rejects.toThrow();
|
|
|
|
// The client must NOT be called when auth fails.
|
|
expect(mockClient.retrieve).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws ScopeError when the caller only has a finance scope (not ops:read)', async () => {
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
|
|
await expect(
|
|
tool.handler({ query: 'anything' }, financeOnlyCtx)
|
|
).rejects.toThrow();
|
|
|
|
expect(mockClient.retrieve).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('succeeds when the caller has ops:read among multiple scopes', async () => {
|
|
const multiScopeCtx: AuthContext = {
|
|
sub: 'adam@seahavenind.com',
|
|
scopes: ['ops:read', 'ops:tasks', 'finance:read', 'finance:admin'],
|
|
aud: 'sh-mcp-ops',
|
|
};
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
|
|
const output = await tool.handler({ query: 'anything' }, multiScopeCtx);
|
|
expect(output.count).toBe(2);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Client error
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('surfaces a client error as a rejected promise', async () => {
|
|
const errorClient = makeMockClient({
|
|
retrieve: vi.fn().mockRejectedValue(new Error('Bedrock Retrieve failed')),
|
|
});
|
|
const [tool] = createKnowledgeBaseTools(errorClient);
|
|
|
|
await expect(
|
|
tool.handler({ query: 'HVAC' }, authorisedCtx)
|
|
).rejects.toThrow('Bedrock Retrieve failed');
|
|
});
|
|
|
|
it('surfaces an unexpected error type without swallowing it', async () => {
|
|
const weirdClient = makeMockClient({
|
|
retrieve: vi.fn().mockRejectedValue('string error'),
|
|
});
|
|
const [tool] = createKnowledgeBaseTools(weirdClient);
|
|
|
|
await expect(
|
|
tool.handler({ query: 'test' }, authorisedCtx)
|
|
).rejects.toBe('string error');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Throttle / retry
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('propagates a ThrottlingException from the client', async () => {
|
|
// Simulate the shape Bedrock SDK throws for throttling.
|
|
const throttleError = Object.assign(new Error('Too many requests'), {
|
|
name: 'ThrottlingException',
|
|
$fault: 'client',
|
|
$retryable: { throttling: true },
|
|
});
|
|
|
|
const throttledClient = makeMockClient({
|
|
retrieve: vi.fn().mockRejectedValue(throttleError),
|
|
});
|
|
const [tool] = createKnowledgeBaseTools(throttledClient);
|
|
|
|
const rejection = await tool
|
|
.handler({ query: 'anything' }, authorisedCtx)
|
|
.catch((e: unknown) => e);
|
|
|
|
expect((rejection as Error).name).toBe('ThrottlingException');
|
|
});
|
|
|
|
it('propagates throttle on first call (retry logic placeholder)', async () => {
|
|
// When retry logic is added (e.g. exponential back-off wrapper), update
|
|
// this test to assert the mock is called N times and eventually succeeds.
|
|
// For now assert the error propagates unchanged so the server layer can
|
|
// apply its own retry strategy.
|
|
const throttleError = Object.assign(new Error('Too many requests'), {
|
|
name: 'ThrottlingException',
|
|
});
|
|
|
|
const client = makeMockClient({
|
|
retrieve: vi.fn().mockRejectedValue(throttleError),
|
|
});
|
|
const [tool] = createKnowledgeBaseTools(client);
|
|
|
|
await expect(
|
|
tool.handler({ query: 'test' }, authorisedCtx)
|
|
).rejects.toMatchObject({ name: 'ThrottlingException' });
|
|
|
|
// Exactly one attempt — no retry implemented yet.
|
|
expect(client.retrieve).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Input schema assertions (contract)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('declares query as a required string in the inputSchema', () => {
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
const schema = tool.inputSchema as {
|
|
required: string[];
|
|
properties: Record<string, { type: string }>;
|
|
};
|
|
|
|
expect(schema.required).toContain('query');
|
|
expect(schema.properties['query'].type).toBe('string');
|
|
});
|
|
|
|
it('declares maxResults as an optional integer in the inputSchema', () => {
|
|
const [tool] = createKnowledgeBaseTools(mockClient);
|
|
const schema = tool.inputSchema as {
|
|
required: string[];
|
|
properties: Record<string, { type: string; minimum: number; maximum: number }>;
|
|
};
|
|
|
|
expect(schema.required).not.toContain('maxResults');
|
|
expect(schema.properties['maxResults'].type).toBe('integer');
|
|
expect(schema.properties['maxResults'].minimum).toBe(1);
|
|
expect(schema.properties['maxResults'].maximum).toBe(20);
|
|
});
|
|
});
|