diff --git a/src/app/api/ops/validate/route.test.ts b/src/app/api/ops/validate/route.test.ts new file mode 100644 index 0000000..79fd024 --- /dev/null +++ b/src/app/api/ops/validate/route.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { POST } from './route'; + +function request() { + return new Request('http://localhost/api/ops/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capabilityId: 'cap-test', capabilityName: 'Test capability' }), + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('POST /api/ops/validate', () => { + it('fails closed without inventing CAPPO latency when the probe cannot connect', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('connection refused')); + vi.stubGlobal('fetch', fetchMock); + + const response = await POST(request()); + const body = await response.json(); + + expect(response.status).toBe(502); + expect(body.error).toBe('CAPPO health probe failed'); + expect(body.logs).toContain('[CAPPO] Health probe failed. No latency measurement recorded.'); + expect(body.logs.join('\n')).not.toMatch(/latency to cappo-backend/i); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not anchor evidence when CAPPO returns an unhealthy response', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 503 })); + vi.stubGlobal('fetch', fetchMock); + + const response = await POST(request()); + const body = await response.json(); + + expect(response.status).toBe(502); + expect(body.error).toBe('CAPPO health probe unhealthy'); + expect(body.cappo_status).toBe(503); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('anchors only after a successful CAPPO health response', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('{}', { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ evidence_hash: 'evidence-test-hash' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await POST(request()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.anchorHash).toBe('evidence-test-hash'); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const pglRequest = fetchMock.mock.calls[1]; + const pglBody = JSON.parse(String(pglRequest[1]?.body)); + expect(pglBody.latency_ms).toEqual(expect.any(Number)); + }); +}); diff --git a/src/app/api/ops/validate/route.ts b/src/app/api/ops/validate/route.ts index 07a3ee6..bc3e08f 100644 --- a/src/app/api/ops/validate/route.ts +++ b/src/app/api/ops/validate/route.ts @@ -12,20 +12,33 @@ export async function POST(request: Request) { const logs: string[] = []; logs.push(`[cAPI-ops-router] Received ops command for "${capabilityName}" (${capabilityId})`); - // 1. SLA test packet injection — real fetch to cappo-backend to measure latency + // 1. Probe CAPPO and only report latency when an HTTP response was actually observed. const start = Date.now(); + let cappoRes: Response; try { - await fetch('http://cappo-backend-node:8002/health', { signal: AbortSignal.timeout(2000) }).catch(() => {}); - } catch (_e) { - // Latency probe — ignore connection failure; we only want elapsed time + cappoRes = await fetch('http://cappo-backend-node:8002/health', { + signal: AbortSignal.timeout(2000), + }); + } catch (_error) { + logs.push('[CAPPO] Health probe failed. No latency measurement recorded.'); + return NextResponse.json( + { error: 'CAPPO health probe failed', logs }, + { status: 502 }, + ); } - const latency = Date.now() - start; - logs.push(`[sub-agent-beta] SLA probe: latency to cappo-backend = ${latency}ms`); - // 2. Commit cryptographic signature to PGL (GnomLedger) - logs.push(`[sub-agent-gamma] Committing capability validation signature to PGL...`); + const latency = Date.now() - start; + if (!cappoRes.ok) { + logs.push(`[CAPPO] Health probe returned status ${cappoRes.status} after ${latency}ms.`); + return NextResponse.json( + { error: 'CAPPO health probe unhealthy', cappo_status: cappoRes.status, logs }, + { status: 502 }, + ); + } + logs.push(`[CAPPO] Health probe succeeded in ${latency}ms.`); - let anchorHash: string | null = null; + // 2. Commit cryptographic signature to PGL (GnomLedger). + logs.push('[PGL] Committing capability validation signature...'); const pglBaseUrl = process.env.PGL_BASE_URL ?? 'https://pgl.veklom.com'; const pglRes = await fetch(`${pglBaseUrl}/api/tools/mint_settlement_evidence_tool`, { @@ -35,27 +48,27 @@ export async function POST(request: Request) { capabilityId, capabilityName, timestamp: new Date().toISOString(), - latency_ms: latency + latency_ms: latency, }), - signal: AbortSignal.timeout(3000) + signal: AbortSignal.timeout(3000), }); if (!pglRes.ok) { logs.push(`[PGL] Error: returned status ${pglRes.status}. Aborting — no fallback hash.`); return NextResponse.json( { error: 'PGL commitment failed', pgl_status: pglRes.status, logs }, - { status: 502 } + { status: 502 }, ); } const pglData = await pglRes.json(); - anchorHash = pglData.evidence_hash ?? pglData.result?.evidence_hash ?? pglData.response?.evidence_hash ?? null; + const anchorHash = pglData.evidence_hash ?? pglData.result?.evidence_hash ?? pglData.response?.evidence_hash ?? null; if (!anchorHash) { - logs.push(`[PGL] Error: response OK but no evidence_hash returned. Aborting.`); + logs.push('[PGL] Error: response OK but no evidence_hash returned. Aborting.'); return NextResponse.json( { error: 'PGL returned no evidence hash', logs }, - { status: 502 } + { status: 502 }, ); } @@ -64,14 +77,13 @@ export async function POST(request: Request) { return NextResponse.json({ success: true, logs, - anchorHash + anchorHash, }); - - } catch (error: any) { + } catch (error) { console.error('Ops Validate Error:', error); return NextResponse.json( - { error: 'Internal Server Error', details: error.message }, - { status: 500 } + { error: 'Internal Server Error' }, + { status: 500 }, ); } }