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