mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 06:58:59 +00:00
Add tests for the highest-risk surface (build-plan §5, design.md §7.3): tool-hiding, server-side scope enforcement (incl. forced hidden calls), audience binding, input-schema validation, finance redaction on egress, audit emission with hashed args, prompt-injection regression (tool output is data), rate limiting, MCP conformance (in-memory transport round-trip), OpenAPI 3.1 validity, and local-auth safety. Add HTTP integration tests (supertest) for both servers and per-package dev-client tests. 405 tests pass. Wire the coverage gate into vitest.config.ts: 80% overall, with per-file thresholds on the auth + dispatch crown jewels; exclude deferred real client stubs, entrypoints, cdk apps, and aws-only config from the gate (documented). Extend eslint flat config + add .prettierignore to cover servers/. Commit the updated package-lock.json.
116 lines
4 KiB
TypeScript
116 lines
4 KiB
TypeScript
/**
|
|
* sh-mcp-finance integration tests — the fully composed finance server (build-plan §5).
|
|
*
|
|
* Exercises finance redaction on egress, audit emission, audience binding, and
|
|
* server-side scope enforcement through the real HTTP path with the in-memory
|
|
* payments/qbo dev clients (design.md §2.5, §7.3).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, vi, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import type { Express } from 'express';
|
|
|
|
import { buildFinanceApp } from '../src/app.js';
|
|
import type { FinanceConfig } from '../src/config.js';
|
|
|
|
const config: FinanceConfig = { env: 'local', port: 0, audience: 'sh-mcp-finance' };
|
|
|
|
let app: Express;
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
const auditLines: string[] = [];
|
|
|
|
beforeAll(() => {
|
|
// Capture the ConsoleAuditLogger output to assert audit emission.
|
|
logSpy = vi.spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
|
auditLines.push(String(msg));
|
|
});
|
|
({ app } = buildFinanceApp(config));
|
|
});
|
|
afterAll(() => {
|
|
logSpy.mockRestore();
|
|
});
|
|
|
|
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
|
|
|
|
describe('sh-mcp-finance server', () => {
|
|
it('GET /healthz ok', async () => {
|
|
const res = await request(app).get('/healthz');
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('GET /openapi.json lists the 4 finance tools', async () => {
|
|
const res = await request(app).get('/openapi.json');
|
|
expect(res.status).toBe(200);
|
|
expect(Object.keys(res.body.paths).sort()).toEqual([
|
|
'/tools/lookup_payment_by_check',
|
|
'/tools/lookup_payment_by_invoice',
|
|
'/tools/lookup_payment_by_vendor',
|
|
'/tools/search_vendors',
|
|
]);
|
|
});
|
|
|
|
it('REDACTION ON EGRESS: bank/routing/card masked, non-sensitive intact', async () => {
|
|
const res = await request(app)
|
|
.post('/tools/lookup_payment_by_vendor')
|
|
.set(auth('dev-finance'))
|
|
.send({ vendor: 'Harbor' });
|
|
expect(res.status).toBe(200);
|
|
const payment = res.body.payments[0];
|
|
expect(payment.vendor).toContain('Harbor');
|
|
expect(payment.bankAccountNumber).toBe('[REDACTED]');
|
|
expect(payment.bankRoutingNumber).toBe('[REDACTED]');
|
|
expect(payment.cardNumber).toBe('[REDACTED]');
|
|
// No raw sensitive value anywhere in the response body.
|
|
const serialized = JSON.stringify(res.body);
|
|
expect(serialized).not.toMatch(/\b\d{12,19}\b/);
|
|
});
|
|
|
|
it('AUDIT: a finance call emits a structured audit record with hashed args', async () => {
|
|
auditLines.length = 0;
|
|
await request(app)
|
|
.post('/tools/lookup_payment_by_vendor')
|
|
.set(auth('dev-finance'))
|
|
.send({ vendor: 'TopSecretVendor' });
|
|
const auditRec = auditLines
|
|
.map((l) => {
|
|
try {
|
|
return JSON.parse(l) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.find((r) => r && r['kind'] === 'audit');
|
|
expect(auditRec).toBeTruthy();
|
|
expect(auditRec!['tool']).toBe('lookup_payment_by_vendor');
|
|
expect(auditRec!['decision']).toBe('allow');
|
|
expect(String(auditRec!['argsHash'])).toMatch(/^[0-9a-f]{64}$/);
|
|
// The raw vendor name never appears in the audit line.
|
|
expect(JSON.stringify(auditRec)).not.toContain('TopSecretVendor');
|
|
});
|
|
|
|
it('search_vendors masks taxId on egress', async () => {
|
|
const res = await request(app)
|
|
.post('/tools/search_vendors')
|
|
.set(auth('dev-finance'))
|
|
.send({ query: 'Harbor' });
|
|
expect(res.status).toBe(200);
|
|
for (const v of res.body.vendors) {
|
|
if ('taxId' in v) expect(v.taxId).toBe('[REDACTED]');
|
|
}
|
|
});
|
|
|
|
it('AUDIENCE BINDING: an ops principal is rejected by the finance server (→401)', async () => {
|
|
const res = await request(app)
|
|
.post('/tools/lookup_payment_by_vendor')
|
|
.set(auth('dev-ops-only'))
|
|
.send({ vendor: 'Harbor' });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects an unauthenticated finance call (→401)', async () => {
|
|
const res = await request(app)
|
|
.post('/tools/lookup_payment_by_vendor')
|
|
.send({ vendor: 'Harbor' });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|