sh-mcp/servers/sh-mcp-finance/test/server.test.ts
Adam Moussa a60a5a5794 Add security-weighted test suite + coverage gate; wire tooling
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.
2026-06-26 12:48:26 -04:00

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