mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-06 22:52:04 +00:00
117 lines
4 KiB
TypeScript
117 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);
|
||
|
|
});
|
||
|
|
});
|