diff --git a/src/app/api/commitments/[id]/fund/route.test.ts b/src/app/api/commitments/[id]/fund/route.test.ts index e8f31353..54f8c552 100644 --- a/src/app/api/commitments/[id]/fund/route.test.ts +++ b/src/app/api/commitments/[id]/fund/route.test.ts @@ -1,27 +1,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { NextRequest } from 'next/server'; import { POST, OPTIONS, GET, PUT, PATCH, DELETE } from './route'; -import { CsrfValidationError, BackendError } from '@/lib/backend/errors'; -import { POST } from './route'; +import { CsrfValidationError, BackendError, UnauthorizedError } from '@/lib/backend/errors'; import { diagnosticsService } from '@/lib/backend/diagnostics'; import { randomUUID } from 'crypto'; // ── Mocks ───────────────────────────────────────────────────────────────────── - vi.mock('@/lib/backend/rateLimit', () => ({ checkRateLimit: vi.fn().mockResolvedValue(true), getRateLimitWindowSeconds: vi.fn(() => 60), })); - vi.mock('@/lib/backend/csrf', () => ({ assertMutationCsrf: vi.fn(), })); - vi.mock('@/lib/backend/services/contracts', () => ({ fundEscrowOnChain: vi.fn(), getCommitmentFromChain: vi.fn(), })); - vi.mock('@/lib/backend/idempotency', () => ({ idempotencyService: { getRecord: vi.fn(), @@ -30,65 +25,51 @@ vi.mock('@/lib/backend/idempotency', () => ({ fail: vi.fn(), }, })); +vi.mock('@/lib/backend/requireAuth', () => ({ + verifyAuth: vi.fn(), +})); +vi.mock('@/lib/backend/config', () => ({ + getBackendConfig: vi.fn(), +})); +vi.mock('@/lib/backend/validation', () => ({ + validateStellarAddress: vi.fn(), + validateCommitmentId: vi.fn(), +})); -import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLimit'; import { assertMutationCsrf } from '@/lib/backend/csrf'; import { fundEscrowOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; import { idempotencyService } from '@/lib/backend/idempotency'; +import { verifyAuth } from '@/lib/backend/requireAuth'; +import { getBackendConfig } from '@/lib/backend/config'; +import { validateStellarAddress, validateCommitmentId } from '@/lib/backend/validation'; +import { ValidationError } from '@/lib/backend/errors'; const mockCheckRateLimit = vi.mocked(checkRateLimit); -const mockAssertCsrf = vi.mocked(assertMutationCsrf); -const mockFundEscrow = vi.mocked(fundEscrowOnChain); -const mockGetCommitment = vi.mocked(getCommitmentFromChain); -const mockIdempotency = vi.mocked(idempotencyService); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function createMockRequest( - url: string, - options: { - method?: string; - body?: any; - idempotencyKey?: string; - } = {}, -): NextRequest { - const req = new NextRequest(url, { - method: options.method || 'POST', - body: options.body ? JSON.stringify(options.body) : undefined, - }); - - // Simulate headers - const headers = new Map(req.headers); - if (options.idempotencyKey) { - headers.set('idempotency-key', options.idempotencyKey); - } - - // Mock getClientIp - vi.spyOn(req, 'ip', 'get').mockReturnValue('192.168.1.1'); - - return req; -} - -interface ParsedResponse { - status: number; - data: any; -} - -async function parseResponse(response: Response): Promise { - return { - status: response.status, - data: await response.json(), - }; -} - -// ── Test Data ───────────────────────────────────────────────────────────────── - -const VALID_ADDRESS = `GBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`; -const COMMITMENT_ID = 'commitment-fund-test-123'; - -const MOCK_COMMITMENT_CREATED = { +const mockGetRateLimitWindowSeconds = vi.mocked(getRateLimitWindowSeconds); +const mockAssertMutationCsrf = vi.mocked(assertMutationCsrf); +const mockFundEscrowOnChain = vi.mocked(fundEscrowOnChain); +const mockGetCommitmentFromChain = vi.mocked(getCommitmentFromChain); +const mockIdempotencyGetRecord = vi.mocked(idempotencyService.getRecord); +const mockIdempotencyStart = vi.mocked(idempotencyService.start); +const mockIdempotencyComplete = vi.mocked(idempotencyService.complete); +const mockIdempotencyFail = vi.mocked(idempotencyService.fail); +const mockVerifyAuth = vi.mocked(verifyAuth); +const mockGetBackendConfig = vi.mocked(getBackendConfig); +const mockValidateStellarAddress = vi.mocked(validateStellarAddress); +const mockValidateCommitmentId = vi.mocked(validateCommitmentId); + +// ── Test Data ────────────────────────────────────────────────────────────────── + +// Valid Stellar public keys use the Stellar base32 alphabet: G[A-HJ-NP-Z0-9]{55} +const OWNER_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const OTHER_ADDRESS = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; +const COMMITMENT_ID = 'commitment-fund-test-123'; +const TEST_NETWORK = 'Test SDF Network ; September 2015'; + +const MOCK_COMMITMENT = { id: COMMITMENT_ID, - ownerAddress: VALID_ADDRESS, + ownerAddress: OWNER_ADDRESS, asset: 'USDC', amount: '10000', status: 'CREATED' as const, @@ -101,12 +82,14 @@ const MOCK_COMMITMENT_CREATED = { }; const MOCK_FUND_RESULT = { - commitmentId: 'cmt-123', + commitmentId: COMMITMENT_ID, txHash: '0xdeadbeef', contractVersion: '1.0.0', reference: undefined, }; +// ── Helpers ──────────────────────────────────────────────────────────────────── + function makeRequest( id: string, body?: Record, @@ -115,6 +98,7 @@ function makeRequest( ): [NextRequest, { params: { id: string } }] { const reqHeaders: Record = { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + authorization: 'Bearer test-token', ...headers, }; const req = new NextRequest(`http://localhost/api/commitments/${id}/fund`, { @@ -139,8 +123,6 @@ async function expectError( if (code) expect(body.error.code).toBe(code); } -// ─── Helper to build a completed idempotency record ────────────────────────── - function completedRecord(response: Record, statusCode = 200) { return { key: 'idem-test', @@ -152,168 +134,122 @@ function completedRecord(response: Record, statusCode = 200) { }; } -describe('POST /api/commitments/[id]/fund', () => { // ── Tests ────────────────────────────────────────────────────────────────────── -describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bounds', () => { +describe('POST /api/commitments/[id]/fund', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); diagnosticsService.clear(); mockCheckRateLimit.mockResolvedValue(true); - mockGetCommitment.mockResolvedValue(MOCK_COMMITMENT_CREATED); - mockFundEscrow.mockResolvedValue({ - txHash: 'abc123def456', - reference: 'fund-ref-123', - }); - mockIdempotency.getRecord.mockResolvedValue(null); - mockIdempotency.start.mockResolvedValue(undefined); - mockIdempotency.complete.mockResolvedValue(undefined); - mockIdempotency.fail.mockResolvedValue(undefined); + mockGetRateLimitWindowSeconds.mockReturnValue(60); + mockGetCommitmentFromChain.mockResolvedValue(MOCK_COMMITMENT); + mockFundEscrowOnChain.mockResolvedValue(MOCK_FUND_RESULT); + mockIdempotencyGetRecord.mockResolvedValue(null); + mockIdempotencyStart.mockResolvedValue(undefined); + mockIdempotencyComplete.mockResolvedValue(undefined); + mockIdempotencyFail.mockResolvedValue(undefined); + mockVerifyAuth.mockReturnValue({ address: OWNER_ADDRESS, isAdmin: false }); + mockGetBackendConfig.mockReturnValue({ + networkPassphrase: TEST_NETWORK, + sorobanRpcUrl: 'https://soroban-testnet.stellar.org:443', + contractAddresses: { commitmentNFT: 'c1', commitmentCore: 'c2', attestationEngine: 'c3' }, + environment: 'test', + chainWritesEnabled: false, + activeVersion: '1.0.0', + } as ReturnType); + // Default: validateStellarAddress passes; validateCommitmentId returns the id + mockValidateStellarAddress.mockReturnValue(undefined); + mockValidateCommitmentId.mockImplementation((id: string | undefined) => { + if (!id?.trim()) throw new ValidationError('Commitment ID is required'); + return id; + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + diagnosticsService.clear(); }); - // ─── 200 Success ───────────────────────────────────────────────────────── + // ── 200 Success ───────────────────────────────────────────────────────────── describe('200 - success', () => { it('funds a commitment escrow', async () => { - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); const res = await POST(req, ctx); const body = await res.json(); - expect(res.status).toBe(200); expect(body.success).toBe(true); - expect(body.data.commitmentId).toBe('cmt-123'); + expect(body.data.commitmentId).toBe(COMMITMENT_ID); expect(body.data.txHash).toBe('0xdeadbeef'); expect(body.data.reference).toBeUndefined(); expect(body.data.fundedAt).toBeDefined(); expect(body.meta).toBeDefined(); }); - afterEach(() => { - vi.clearAllMocks(); - diagnosticsService.clear(); - }); - // ── Success Cases ────────────────────────────────────────────────────────── - - it('successfully funds a commitment in CREATED state', async () => { - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, + it('uses session address as callerAddress when body omits it', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await POST(req, ctx); + expect(mockFundEscrowOnChain).toHaveBeenCalledWith({ + commitmentId: COMMITMENT_ID, + callerAddress: OWNER_ADDRESS, + }); }); - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(200); - expect(result.data.success).toBe(true); - expect(result.data.data.commitmentId).toBe(COMMITMENT_ID); - expect(result.data.data.txHash).toBe('abc123def456'); - }); - - it('allows funding without callerAddress (implicit owner)', async () => { - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: {}, // No callerAddress + it('calls fundEscrowOnChain with correct params when callerAddress is supplied', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, { callerAddress: OWNER_ADDRESS }); + await POST(req, ctx); + expect(mockFundEscrowOnChain).toHaveBeenCalledWith({ + commitmentId: COMMITMENT_ID, + callerAddress: OWNER_ADDRESS, + }); }); - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(200); - expect(result.data.success).toBe(true); - expect(mockFundEscrow).toHaveBeenCalledWith({ - commitmentId: COMMITMENT_ID, - callerAddress: undefined, + it('emits CSRF check for the request', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await POST(req, ctx); + expect(mockAssertMutationCsrf).toHaveBeenCalledWith(req); }); - }); - - // ── Idempotency Tests ────────────────────────────────────────────────────── - - it('returns cached response on idempotent replay (COMPLETED record)', async () => { - const idempotencyKey = 'idempotency-fund-' + randomUUID(); - const cachedResponse = { - commitmentId: COMMITMENT_ID, - txHash: 'cached-tx-hash', - reference: 'cached-ref', - fundedAt: new Date().toISOString(), - }; - mockIdempotency.getRecord.mockResolvedValue({ - key: idempotencyKey, - status: 'COMPLETED' as const, - response: cachedResponse, - statusCode: 200, - createdAt: Date.now(), - expiresAt: Date.now() + 86400000, + it('checks rate limit', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await POST(req, ctx); + expect(mockCheckRateLimit).toHaveBeenCalledWith(expect.any(String), 'api/commitments/fund'); }); - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, - idempotencyKey, + it('fetches commitment from chain to verify state', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await POST(req, ctx); + expect(mockGetCommitmentFromChain).toHaveBeenCalledWith(COMMITMENT_ID); }); it('response shape: required fields commitmentId, txHash, fundedAt are present', async () => { - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); const res = await POST(req, ctx); const body = await res.json(); - - // These three fields are always present in a successful response expect(body.data.commitmentId).toBeDefined(); expect(body.data.txHash).toBeDefined(); expect(body.data.fundedAt).toBeDefined(); - // reference is present only when txHash is absent (undefined is stripped by JSON) - // No extraneous fields beyond the documented contract const allowedKeys = new Set(['commitmentId', 'txHash', 'reference', 'fundedAt']); const extraKeys = Object.keys(body.data).filter((k) => !allowedKeys.has(k)); expect(extraKeys).toHaveLength(0); }); it('fundedAt is a valid ISO-8601 timestamp', async () => { - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); const before = Date.now(); const res = await POST(req, ctx); const after = Date.now(); const body = await res.json(); - const fundedAtMs = new Date(body.data.fundedAt).getTime(); expect(Number.isNaN(fundedAtMs)).toBe(false); expect(fundedAtMs).toBeGreaterThanOrEqual(before); expect(fundedAtMs).toBeLessThanOrEqual(after); }); - it('includes x-correlation-id header on success', async () => { - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { - 'x-correlation-id': 'test-corr-001', - }); + it('accepts a matching network passphrase in the body', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, { network: TEST_NETWORK }); const res = await POST(req, ctx); - - expect(res.headers.get('x-correlation-id')).toBe('test-corr-001'); - }); - - it('callerAddress absent: does not perform ownership check, calls fundEscrowOnChain', async () => { - // When callerAddress is omitted the route skips the ownership guard — - // authorization is delegated to fundEscrowOnChain / the chain itself. - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - expect(res.status).toBe(200); - expect(mockFundEscrowOnChain).toHaveBeenCalledWith({ - commitmentId: 'cmt-123', - callerAddress: undefined, - }); - }); - - it('reference is undefined when txHash is present', async () => { - mockFundEscrowOnChain.mockResolvedValue({ - ...MOCK_FUND_RESULT, - txHash: '0xabc123', - reference: undefined, - }); - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(body.data.txHash).toBe('0xabc123'); - expect(body.data.reference).toBeUndefined(); }); it('reference is present when txHash is absent (fallback reference)', async () => { @@ -322,244 +258,272 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou txHash: undefined, reference: 'TODO_CHAIN_CALL_FUND_ESCROW', }); - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); const res = await POST(req, ctx); const body = await res.json(); - expect(body.data.txHash).toBeUndefined(); expect(body.data.reference).toBe('TODO_CHAIN_CALL_FUND_ESCROW'); }); it('does not track idempotency when header is absent', async () => { - // No idempotency-key header → none of the idempotency methods should be called - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); await POST(req, ctx); - expect(mockIdempotencyGetRecord).not.toHaveBeenCalled(); expect(mockIdempotencyStart).not.toHaveBeenCalled(); expect(mockIdempotencyComplete).not.toHaveBeenCalled(); expect(mockIdempotencyFail).not.toHaveBeenCalled(); }); + + it('success response body has success: true at top level', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + const res = await POST(req, ctx); + const body = await res.json(); + expect(body.success).toBe(true); + }); }); - // ─── 200 Success with idempotency ──────────────────────────────────────── + // ── 200 Success with idempotency ──────────────────────────────────────────── describe('200 - success with idempotency', () => { it('returns cached response when idempotency key is COMPLETED', async () => { - const cachedResponse = { commitmentId: 'cmt-123', txHash: '0xold' }; + const cachedResponse = { commitmentId: COMMITMENT_ID, txHash: '0xold' }; mockIdempotencyGetRecord.mockResolvedValue(completedRecord(cachedResponse)); - - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-001' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-001' }); const res = await POST(req, ctx); const body = await res.json(); - expect(res.status).toBe(200); expect(body.data).toEqual(cachedResponse); + expect(res.headers.get('X-Idempotent-Replay')).toBe('true'); expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); }); - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(200); - expect(result.data.data).toEqual(cachedResponse); - expect(response.headers.get('X-Idempotent-Replay')).toBe('true'); - // Should not call fundEscrow for cache hit - expect(mockFundEscrow).not.toHaveBeenCalled(); - }); - - it('blocks concurrent requests with same idempotency key (STARTED record)', async () => { - const idempotencyKey = 'idempotency-fund-' + randomUUID(); - mockIdempotency.getRecord.mockResolvedValue({ - key: idempotencyKey, - status: 'STARTED' as const, - createdAt: Date.now(), - expiresAt: Date.now() + 86400000, - }); - - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, - idempotencyKey, + it('starts idempotency tracking for a new key', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-002' }); + await POST(req, ctx); + expect(mockIdempotencyStart).toHaveBeenCalledWith('idem-002'); }); - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - + it('completes idempotency tracking on success', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-003' }); + await POST(req, ctx); expect(mockIdempotencyComplete).toHaveBeenCalledWith( 'idem-003', - expect.objectContaining({ commitmentId: 'cmt-123' }), + expect.objectContaining({ commitmentId: COMMITMENT_ID }), 200, ); }); it('idempotency replay returns the exact same fundedAt as the original request', async () => { const frozenFundedAt = '2026-08-01T12:00:00.000Z'; - const cachedPayload = { - commitmentId: 'cmt-123', - txHash: '0xdeadbeef', - reference: undefined, - fundedAt: frozenFundedAt, - }; + const cachedPayload = { commitmentId: COMMITMENT_ID, txHash: '0xdeadbeef', fundedAt: frozenFundedAt }; mockIdempotencyGetRecord.mockResolvedValue(completedRecord(cachedPayload)); - - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-replay' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-replay' }); const res = await POST(req, ctx); const body = await res.json(); - - // The replayed response must include the original, stable fundedAt — - // not a freshly generated timestamp. expect(body.data.fundedAt).toBe(frozenFundedAt); expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); }); - it('idempotency complete call stores the same fundedAt that is returned in the response', async () => { - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-ts' }); + it('idempotency complete stores the same fundedAt that is returned in the response', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-ts' }); const res = await POST(req, ctx); const body = await res.json(); - - // Verify the value stored in the idempotency cache equals the response body const storedPayload = mockIdempotencyComplete.mock.calls[0][1] as Record; expect(storedPayload.fundedAt).toBe(body.data.fundedAt); }); - it('allows retry after FAILED idempotency: fail() deletes key so retry proceeds', async () => { - // First call: STARTED → normal flow fails → fail() is called → key deleted - // Second call: getRecord returns null because key was deleted → new start - // This test simulates the second (retry) call: - mockIdempotencyGetRecord.mockResolvedValue(null); // key was deleted by fail() - - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-retry' }); + it('allows retry after FAILED idempotency (getRecord returns null after fail())', async () => { + mockIdempotencyGetRecord.mockResolvedValue(null); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-retry' }); const res = await POST(req, ctx); const body = await res.json(); - expect(res.status).toBe(200); expect(body.success).toBe(true); expect(mockIdempotencyStart).toHaveBeenCalledWith('idem-retry'); expect(mockFundEscrowOnChain).toHaveBeenCalled(); }); - it('idempotency key header value is propagated correctly to all service calls', async () => { - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { - 'idempotency-key': 'exact-key-value', - }); + it('idempotency key header is propagated to all service calls', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'exact-key-value' }); await POST(req, ctx); - expect(mockIdempotencyGetRecord).toHaveBeenCalledWith('exact-key-value'); expect(mockIdempotencyStart).toHaveBeenCalledWith('exact-key-value'); - expect(mockIdempotencyComplete).toHaveBeenCalledWith( - 'exact-key-value', - expect.any(Object), - 200, - ); + expect(mockIdempotencyComplete).toHaveBeenCalledWith('exact-key-value', expect.any(Object), 200); }); }); - // ─── 400 Validation ────────────────────────────────────────────────────── + // ── 400 Validation errors ─────────────────────────────────────────────────── describe('400 - validation errors', () => { it('rejects empty commitment id', async () => { + mockValidateCommitmentId.mockImplementation(() => { + throw new ValidationError('Commitment ID is required'); + }); const [req, ctx] = makeRequest('', {}); await expectError(req, ctx, 400, 'VALIDATION_ERROR'); }); - const result = await parseResponse(response); - expect(result.status).toBe(409); - expect(result.data.error.code).toBe('CONFLICT_ERROR'); - expect(result.data.error.message).toContain('currently processing'); - }); - it('cleans up failed idempotency records to allow retry', async () => { - const idempotencyKey = 'idempotency-fund-' + randomUUID(); + it('rejects whitespace-only id', async () => { + mockValidateCommitmentId.mockImplementation(() => { + throw new ValidationError('Commitment ID is required'); + }); + const [req, ctx] = makeRequest(' ', {}); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); - mockIdempotency.getRecord.mockResolvedValue(null); - mockGetCommitment.mockResolvedValue({ - ...MOCK_COMMITMENT_CREATED, - status: 'FUNDED', // Invalid state - should fail + it('rejects invalid JSON body', async () => { + const req = new NextRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' }, + body: 'not-json', + }); + await expectError(req, { params: { id: COMMITMENT_ID } }, 400, 'VALIDATION_ERROR'); }); - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, - idempotencyKey, + it('rejects a callerAddress that fails Stellar address validation', async () => { + mockValidateStellarAddress.mockImplementation(() => { + throw new ValidationError('callerAddress must be a valid Stellar public key'); + }); + const [req, ctx] = makeRequest(COMMITMENT_ID, { callerAddress: 'INVALID' }); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); + + it('rejects a body network passphrase that differs from server config', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, { network: 'Public Global Stellar Network ; September 2015' }); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); + + it('rejects an empty network passphrase in body', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, { network: '' }); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); }); - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); + it('rejects an idempotency key longer than 128 characters', async () => { + const oversizedKey = 'k'.repeat(129); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': oversizedKey }); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); - const result = await parseResponse(response); - expect(result.status).toBe(409); - // Should call fail to allow retry - expect(mockIdempotency.fail).toHaveBeenCalledWith(idempotencyKey); + it('accepts an idempotency key at exactly the 128-char limit', async () => { + const maxKey = 'k'.repeat(128); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': maxKey }); + const res = await POST(req, ctx); + expect(res.status).toBe(200); + }); + + it('rejects commitment whose amount from chain is zero', async () => { + mockGetCommitmentFromChain.mockResolvedValue({ ...MOCK_COMMITMENT, amount: '0' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); + + it('rejects commitment whose amount from chain is negative', async () => { + mockGetCommitmentFromChain.mockResolvedValue({ ...MOCK_COMMITMENT, amount: '-500' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); + + it('rejects commitment whose amount from chain is NaN text', async () => { + mockGetCommitmentFromChain.mockResolvedValue({ ...MOCK_COMMITMENT, amount: 'not-a-number' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); + + it('rejects a chain response where commitmentId does not match the requested id', async () => { + mockFundEscrowOnChain.mockResolvedValue({ ...MOCK_FUND_RESULT, commitmentId: 'cmt-DIFFERENT' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); + + it('rejects a chain response where txHash is a non-string truthy value', async () => { + mockFundEscrowOnChain.mockResolvedValue({ + ...MOCK_FUND_RESULT, + // @ts-expect-error deliberate hostile value + txHash: 12345, + }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 400, 'VALIDATION_ERROR'); + }); }); - // ─── 403 Forbidden ─────────────────────────────────────────────────────── + // ── 401 Unauthorized (disconnected wallet / missing session) ───────────────── - describe('403 - forbidden', () => { - it('rejects callerAddress that does not match owner', async () => { - const [req, ctx] = makeRequest('cmt-123', { callerAddress: 'GWRONGADDRESS' }); - await expectError(req, ctx, 403, 'FORBIDDEN'); - // ── State Invariant Tests ────────────────────────────────────────────────── + describe('401 - unauthorized', () => { + it('rejects when no session token is present', async () => { + mockVerifyAuth.mockImplementation(() => { throw new UnauthorizedError('Bearer token required'); }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 401, 'UNAUTHORIZED'); + }); - it('rejects funding of non-CREATED commitments (precondition invariant)', async () => { - mockGetCommitment.mockResolvedValue({ - ...MOCK_COMMITMENT_CREATED, - status: 'FUNDED', + it('rejects when session token is expired or invalid', async () => { + mockVerifyAuth.mockImplementation(() => { throw new UnauthorizedError('Invalid or expired session'); }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 401, 'UNAUTHORIZED'); }); - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, + it('rejects a disconnected wallet scenario (no auth header)', async () => { + mockVerifyAuth.mockImplementation(() => { throw new UnauthorizedError('Bearer token required'); }); + const req = new NextRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + await expectError(req, { params: { id: COMMITMENT_ID } }, 401, 'UNAUTHORIZED'); }); + }); - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); + // ── 403 Forbidden ──────────────────────────────────────────────────────────── - const result = await parseResponse(response); - expect(result.status).toBe(409); - expect(result.data.error.message).toContain('FUNDED'); - expect(result.data.error.message).toContain('Only CREATED commitments can be funded'); + describe('403 - forbidden', () => { + it('rejects when session address does not match commitment owner', async () => { + mockVerifyAuth.mockReturnValue({ address: OTHER_ADDRESS, isAdmin: false }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 403, 'FORBIDDEN'); + }); + + it('rejects when callerAddress in body does not match the session identity', async () => { + // Session is OWNER but body asserts OTHER_ADDRESS — mismatch at session check + const [req, ctx] = makeRequest(COMMITMENT_ID, { callerAddress: OTHER_ADDRESS }); + await expectError(req, ctx, 403, 'FORBIDDEN'); + }); + + it('rejects CSRF violation', async () => { + mockAssertMutationCsrf.mockImplementation(() => { + throw new CsrfValidationError('Missing CSRF token.'); + }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await expectError(req, ctx, 403, 'CSRF_INVALID'); + }); }); - // ─── 404 Not Found ─────────────────────────────────────────────────────── + // ── 404 Not Found ───────────────────────────────────────────────────────────── describe('404 - not found', () => { it('returns 404 when commitment does not exist', async () => { mockGetCommitmentFromChain.mockResolvedValue(null); const [req, ctx] = makeRequest('nonexistent', {}); await expectError(req, ctx, 404, 'NOT_FOUND'); - it('rejects funding by non-owner (ownership invariant)', async () => { - const differentAddress = `GBAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB`; - - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: differentAddress }, }); - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(403); - expect(result.data.error.code).toBe('FORBIDDEN_ERROR'); - expect(result.data.error.message).toContain('Only the commitment owner may fund'); + it('does not call fundEscrowOnChain when commitment is not found', async () => { + mockGetCommitmentFromChain.mockResolvedValue(null); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await POST(req, ctx); + expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); + }); }); - // ─── 409 Conflict ──────────────────────────────────────────────────────── + // ── 409 Conflict ────────────────────────────────────────────────────────────── - describe('409 - conflict: non-CREATED commitment statuses', () => { + describe('409 - conflict', () => { const nonCreatedStatuses = [ - 'ACTIVE', - 'SETTLED', - 'VIOLATED', - 'EARLY_EXIT', - 'DISPUTED', - 'UNKNOWN', + 'ACTIVE', 'SETTLED', 'VIOLATED', 'EARLY_EXIT', 'DISPUTED', 'UNKNOWN', ] as const; for (const status of nonCreatedStatuses) { - it(`rejects funding a commitment with status ${status}`, async () => { - mockGetCommitmentFromChain.mockResolvedValue({ - ...MOCK_COMMITMENT, - status, - } as typeof MOCK_COMMITMENT); - const [req, ctx] = makeRequest('cmt-123', {}); + it(`rejects funding a commitment with status ${status} (replay guard)`, async () => { + mockGetCommitmentFromChain.mockResolvedValue({ ...MOCK_COMMITMENT, status } as typeof MOCK_COMMITMENT); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); await expectError(req, ctx, 409, 'CONFLICT'); }); } @@ -571,44 +535,47 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou createdAt: Date.now(), expiresAt: Date.now() + 86400000, }); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-004' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-004' }); await expectError(req, ctx, 409, 'CONFLICT'); - it('rejects funding of non-existent commitment', async () => { - mockGetCommitment.mockResolvedValue(null); - - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, }); - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(404); - expect(result.data.error.code).toBe('NOT_FOUND_ERROR'); + it('cleans up failed idempotency on conflict to allow retry', async () => { + const idempotencyKey = 'idempotency-fund-' + randomUUID(); + mockIdempotencyGetRecord.mockResolvedValue(null); + mockGetCommitmentFromChain.mockResolvedValue({ ...MOCK_COMMITMENT, status: 'ACTIVE' } as typeof MOCK_COMMITMENT); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': idempotencyKey }); + await POST(req, ctx); + expect(mockIdempotencyFail).toHaveBeenCalledWith(idempotencyKey); + }); }); - // ─── 429 Rate Limited ──────────────────────────────────────────────────── + // ── 429 Rate Limited ────────────────────────────────────────────────────────── describe('429 - rate limited', () => { it('returns 429 when rate limit exceeded', async () => { mockCheckRateLimit.mockResolvedValue(false); - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); await expectError(req, ctx, 429, 'TOO_MANY_REQUESTS'); }); it('includes Retry-After header on 429', async () => { mockCheckRateLimit.mockResolvedValue(false); mockGetRateLimitWindowSeconds.mockReturnValue(60); - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); const res = await POST(req, ctx); - expect(res.status).toBe(429); expect(res.headers.get('Retry-After')).toBe('60'); }); + + it('does not call fundEscrowOnChain when rate limit is exceeded', async () => { + mockCheckRateLimit.mockResolvedValue(false); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); + await POST(req, ctx); + expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); + }); }); - // ─── 502 Blockchain error ───────────────────────────────────────────────── + // ── 502 Blockchain error ───────────────────────────────────────────────────── describe('502 - blockchain error', () => { it('returns 502 when fundEscrowOnChain throws a BLOCKCHAIN_CALL_FAILED BackendError', async () => { @@ -617,205 +584,122 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou code: 'BLOCKCHAIN_CALL_FAILED', message: 'Unable to fund escrow on chain.', status: 502, - details: { method: 'fund_escrow', commitmentId: 'cmt-123' }, + details: { method: 'fund_escrow', commitmentId: COMMITMENT_ID }, }), ); - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); const res = await POST(req, ctx); const body = await res.json(); - expect(res.status).toBe(502); - // BackendError uses the toBackendErrorResponse shape: { error: { code, message, details } } expect(body.error).toBeDefined(); expect(body.error.code).toBe('BLOCKCHAIN_CALL_FAILED'); }); it('marks idempotency key as failed when blockchain call fails', async () => { mockFundEscrowOnChain.mockRejectedValue( - new BackendError({ - code: 'BLOCKCHAIN_CALL_FAILED', - message: 'RPC timeout', - status: 502, - }), + new BackendError({ code: 'BLOCKCHAIN_CALL_FAILED', message: 'RPC timeout', status: 502 }), ); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-502' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-502' }); await POST(req, ctx); - expect(mockIdempotencyFail).toHaveBeenCalledWith('idem-502'); }); }); - // ─── 405 Method Not Allowed ────────────────────────────────────────────── + // ── 405 Method Not Allowed ──────────────────────────────────────────────────── describe('405 - method not allowed', () => { it('rejects GET requests', async () => { - const [req, ctx] = makeRequest('cmt-123', undefined, 'GET'); + const [req, ctx] = makeRequest(COMMITMENT_ID, undefined, 'GET'); const res = await GET(req, ctx); const body = await res.json(); expect(res.status).toBe(405); expect(body.error.code).toBe('METHOD_NOT_ALLOWED'); - // ── Boundary & Validation Tests ──────────────────────────────────────────── - - it('rejects commitment ID with empty/whitespace string', async () => { - const req = createMockRequest(`http://localhost/api/commitments/ /fund`, { - body: { callerAddress: VALID_ADDRESS }, }); - const context = { params: { id: ' ' } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(400); - expect(result.data.error.code).toBe('VALIDATION_ERROR'); - }); - - it('rejects malformed JSON in request body', async () => { - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - method: 'POST', + it('rejects PUT requests', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, undefined, 'PUT'); + const res = await PUT(req, ctx); + expect(res.status).toBe(405); }); - req.body = JSON.parse.bind(null, 'invalid json') as any; // Force JSON parse error - - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(400); - }); - - // ── Diagnostics & Telemetry Tests ────────────────────────────────────────── - it('tracks operation telemetry for success case', async () => { - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, + it('rejects PATCH requests', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, undefined, 'PATCH'); + const res = await PATCH(req, ctx); + expect(res.status).toBe(405); }); - const context = { params: { id: COMMITMENT_ID } }; - await POST(req, context, 'correlation-123'); - - // Get stats from diagnostics service - const stats = diagnosticsService.getOperationStats('fund_commitment'); - expect(stats.successCount).toBeGreaterThan(0); - expect(stats.sampleCount).toBeGreaterThan(0); - }); - - it('exposes degraded status for slow operations', async () => { - // Mock a slow contract call - mockFundEscrow.mockImplementation( - async () => - new Promise((resolve) => - setTimeout( - () => - resolve({ - txHash: 'slow-tx', - reference: 'slow-ref', - }), - 35000, // Exceeds FUND_OPERATION_SLOW_THRESHOLD_MS (30000) - ), - ), - ); - - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, - }); - - const context = { params: { id: COMMITMENT_ID } }; - // Note: In real test, this would timeout. This is illustrative of the capability. - // In practice, you'd mock the time or use a smaller threshold for testing. + it('rejects DELETE requests', async () => { + const [req, ctx] = makeRequest(COMMITMENT_ID, undefined, 'DELETE'); + const res = await DELETE(req, ctx); + expect(res.status).toBe(405); + }); }); - // ─── OPTIONS preflight ─────────────────────────────────────────────────── + // ── OPTIONS ────────────────────────────────────────────────────────────────── describe('OPTIONS', () => { it('returns 204 for OPTIONS preflight', async () => { - const req = new NextRequest('http://localhost/api/commitments/cmt-123/fund', { + const req = new NextRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { method: 'OPTIONS', headers: { 'access-control-request-method': 'POST' }, }); const res = await OPTIONS(req); expect(res.status).toBe(204); - // ── Rate Limit Tests ────────────────────────────────────────────────────── - - it('respects rate limit for IP', async () => { - mockCheckRateLimit.mockResolvedValue(false); - - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, }); - - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(429); - expect(result.data.error.code).toBe('TOO_MANY_REQUESTS_ERROR'); }); - // ─── Error handling and idempotency failure path ────────────────────────── + // ── Error handling and idempotency cleanup ──────────────────────────────────── describe('error handling', () => { it('fails idempotency key when getCommitmentFromChain throws', async () => { mockGetCommitmentFromChain.mockRejectedValue(new Error('RPC failure')); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-005' }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-005' }); await POST(req, ctx); - // ── CSRF Protection Tests ────────────────────────────────────────────────── - - it('asserts CSRF token on POST request', async () => { - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, + expect(mockIdempotencyFail).toHaveBeenCalledWith('idem-005'); }); - it('fails idempotency key when fundEscrowOnChain throws', async () => { - mockFundEscrowOnChain.mockRejectedValue(new Error('Chain timeout')); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-006' }); + it('fails idempotency key when authorization is rejected', async () => { + mockVerifyAuth.mockImplementation(() => { throw new UnauthorizedError('Invalid or expired session'); }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'idempotency-key': 'idem-006' }); await POST(req, ctx); - expect(mockIdempotencyFail).toHaveBeenCalledWith('idem-006'); }); it('does not call idempotencyFail when no idempotency key is present', async () => { mockGetCommitmentFromChain.mockRejectedValue(new Error('RPC failure')); - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); await POST(req, ctx); - expect(mockIdempotencyFail).not.toHaveBeenCalled(); }); it('returns 500 for unexpected errors', async () => { mockGetCommitmentFromChain.mockRejectedValue(new Error('Unexpected DB error')); - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); const res = await POST(req, ctx); const body = await res.json(); - const context = { params: { id: COMMITMENT_ID } }; - await POST(req, context, 'correlation-123'); - - expect(mockAssertCsrf).toHaveBeenCalledWith(req); - }); - - it('fails on CSRF validation failure', async () => { - mockAssertCsrf.mockImplementation(() => { - throw new Error('CSRF token invalid'); + expect(res.status).toBe(500); + expect(body.success).toBe(false); + expect(body.error.code).toBe('INTERNAL_ERROR'); }); it('returns 500 with x-correlation-id header on unhandled error', async () => { mockGetCommitmentFromChain.mockRejectedValue(new Error('boom')); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { - 'x-correlation-id': 'err-corr-001', - }); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}, 'POST', { 'x-correlation-id': 'err-corr-001' }); const res = await POST(req, ctx); - expect(res.status).toBe(500); expect(res.headers.get('x-correlation-id')).toBe('err-corr-001'); }); }); - // ─── Boundary / edge cases ──────────────────────────────────────────────── + // ── Boundary / edge cases ───────────────────────────────────────────────────── describe('boundary and edge cases', () => { it('accepts a commitment id with special characters (URL-encoded)', async () => { - const [req, ctx] = makeRequest('cmt-abc_123-XYZ', {}); + const specialId = 'cmt-abc_123-XYZ'; + mockFundEscrowOnChain.mockResolvedValue({ ...MOCK_FUND_RESULT, commitmentId: specialId }); + const [req, ctx] = makeRequest(specialId, {}); const res = await POST(req, ctx); - - expect(mockGetCommitmentFromChain).toHaveBeenCalledWith('cmt-abc_123-XYZ'); + expect(mockGetCommitmentFromChain).toHaveBeenCalledWith(specialId); expect(res.status).toBe(200); }); @@ -823,70 +707,24 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou mockAssertMutationCsrf.mockImplementation(() => { throw new CsrfValidationError('Missing CSRF token.'); }); - const [req, ctx] = makeRequest('cmt-123', {}); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); await POST(req, ctx); - - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('does not call fundEscrowOnChain when rate limit is exceeded', async () => { - mockCheckRateLimit.mockResolvedValue(false); - const [req, ctx] = makeRequest('cmt-123', {}); - await POST(req, ctx); - - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('does not call fundEscrowOnChain when commitment is not found', async () => { - mockGetCommitmentFromChain.mockResolvedValue(null); - const [req, ctx] = makeRequest('cmt-123', {}); - await POST(req, ctx); - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); }); it('does not call fundEscrowOnChain when status is not CREATED', async () => { - mockGetCommitmentFromChain.mockResolvedValue({ - ...MOCK_COMMITMENT, - status: 'SETTLED', - } as typeof MOCK_COMMITMENT); - const [req, ctx] = makeRequest('cmt-123', {}); + mockGetCommitmentFromChain.mockResolvedValue({ ...MOCK_COMMITMENT, status: 'SETTLED' } as typeof MOCK_COMMITMENT); + const [req, ctx] = makeRequest(COMMITMENT_ID, {}); await POST(req, ctx); - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); }); - it('does not call fundEscrowOnChain when caller address is forbidden', async () => { - const [req, ctx] = makeRequest('cmt-123', { callerAddress: 'GEVIL999' }); - await POST(req, ctx); - - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('success response body has success: true at top level', async () => { - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(body.success).toBe(true); - }); - it('error response body has success: false at top level', async () => { mockGetCommitmentFromChain.mockResolvedValue(null); const [req, ctx] = makeRequest('nonexistent', {}); const res = await POST(req, ctx); const body = await res.json(); - expect(body.success).toBe(false); }); - const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { - body: { callerAddress: VALID_ADDRESS }, - }); - - const context = { params: { id: COMMITMENT_ID } }; - const response = await POST(req, context, 'correlation-123'); - - const result = await parseResponse(response); - expect(result.status).toBe(400); }); }); diff --git a/src/app/api/commitments/[id]/fund/route.ts b/src/app/api/commitments/[id]/fund/route.ts index cc29f05a..9cb0cb7e 100644 --- a/src/app/api/commitments/[id]/fund/route.ts +++ b/src/app/api/commitments/[id]/fund/route.ts @@ -1,4 +1,3 @@ -import { NextRequest, NextResponse } from 'next/server'; /** * POST /api/commitments/[id]/fund * @@ -13,6 +12,16 @@ import { NextRequest, NextResponse } from 'next/server'; * - No state regression: state never reverts from FUNDED to CREATED * - Ownership is immutable: only ownerAddress can fund * + * ### Authorization Invariants + * - Caller identity is derived from the server-side session token, never + * trusted from the request body alone. + * - When callerAddress is supplied in the body it is cross-checked against the + * session identity to prevent tampered-body spoofing. + * - Address format is validated against the canonical Stellar public-key regex + * before reaching any business logic. + * - Network passphrase (when supplied) must match the server configuration to + * catch wrong-network wallet submissions. + * * ### Concurrent Request Bounds * - Max 100 concurrent funding operations per route * - Exceeding bound returns 503 with degraded telemetry @@ -23,9 +32,12 @@ import { NextRequest, NextResponse } from 'next/server'; * - COMPLETED records are cached for 24 hours (default TTL) * - FAILED records are deleted (allow immediate retry) * - Network failures expose via X-Telemetry-Status header + * + * ### Idempotency Key Bounds + * - Keys are capped at MAX_IDEMPOTENCY_KEY_LENGTH characters to prevent + * storage inflation from hostile oversized values. */ - -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { ok, methodNotAllowed } from '@/lib/backend/apiResponse'; import { assertMutationCsrf } from '@/lib/backend/csrf'; @@ -45,24 +57,54 @@ import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLim import { withApiHandler } from '@/lib/backend/withApiHandler'; import { idempotencyService } from '@/lib/backend/idempotency'; import { diagnosticsService } from '@/lib/backend/diagnostics'; +import { verifyAuth } from '@/lib/backend/requireAuth'; +import { getBackendConfig } from '@/lib/backend/config'; +import { validateStellarAddress, validateCommitmentId } from '@/lib/backend/validation'; import { randomUUID } from 'crypto'; -const FundRequestSchema = z.object({ - callerAddress: z.string().min(1, 'callerAddress is required'), -}); +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Maximum byte length for an idempotency key. Unbounded keys could be used + * to inflate in-memory/KV storage without meaningful semantic value. + */ +const MAX_IDEMPOTENCY_KEY_LENGTH = 128; /** * Bound for concurrent funding operations. * Prevents resource exhaustion during high load or DDoS. - * Monitor via diagnosticsService.getOperationStats('fund').maxConcurrentOps */ const MAX_CONCURRENT_FUNDING_OPS = 100; /** * Maximum duration for fund operation before considered slow/degraded. - * Used for SLO tracking and alerting in production. */ -const FUND_OPERATION_SLOW_THRESHOLD_MS = 30000; // 30 seconds +const FUND_OPERATION_SLOW_THRESHOLD_MS = 30000; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +/** + * `callerAddress` is optional: when omitted the route falls back to the + * address extracted from the verified server-side session token. When + * provided it must be a syntactically-valid Stellar public key; the route + * then additionally checks it matches the session identity. + * + * `network` is optional: when supplied it must equal the server-configured + * network passphrase, catching wrong-network submissions before any on-chain + * call is attempted. + */ +const FundRequestSchema = z.object({ + callerAddress: z.string().min(1).optional(), + network: z.string().optional(), +}); + +// --------------------------------------------------------------------------- +// CORS +// --------------------------------------------------------------------------- const COMMITMENT_FUND_CORS_POLICY = { POST: { access: 'first-party' }, @@ -70,19 +112,19 @@ const COMMITMENT_FUND_CORS_POLICY = { export const OPTIONS = createCorsOptionsHandler(COMMITMENT_FUND_CORS_POLICY); +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + export const POST = withApiHandler( async (req: NextRequest, { params }, correlationId) => { - // Generate unique operation ID for telemetry tracking const operationId = randomUUID(); - - // Start operation telemetry (includes concurrent ops tracking) const telemetry = diagnosticsService.startOperation( operationId, 'fund_commitment', MAX_CONCURRENT_FUNDING_OPS, ); - // Check if we're at capacity if (telemetry.status === 'degraded') { diagnosticsService.completeOperation(operationId, 'degraded', telemetry.failureReason); const response = new Response( @@ -100,9 +142,15 @@ export const POST = withApiHandler( return response; } + // Hoist idempotencyKey so the catch block can call fail() regardless of + // where in the try block the error was thrown. + const idempotencyKey = req.headers.get('idempotency-key'); + try { + // --- CSRF --------------------------------------------------------------- assertMutationCsrf(req); + // --- Rate limit --------------------------------------------------------- const ip = getClientIp(req); if (!(await checkRateLimit(ip, 'api/commitments/fund'))) { throw new TooManyRequestsError( @@ -112,22 +160,22 @@ export const POST = withApiHandler( ); } - const id = params.id; - if (!id?.trim()) { - throw new ValidationError('Commitment ID is required'); - } + // --- Route parameter validation ----------------------------------------- + const id = validateCommitmentId(params.id); - // ─── Idempotency Check & Protection ──────────────────────────────────── - // Ensures repeated requests with same key don't create duplicate funding txs - const idempotencyKey = req.headers.get('idempotency-key'); + // --- Idempotency key validation ------------------------------------------ let isIdempotentRetry = false; if (idempotencyKey) { + if (idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH) { + throw new ValidationError( + `Idempotency-Key must not exceed ${MAX_IDEMPOTENCY_KEY_LENGTH} characters`, + ); + } const record = await idempotencyService.getRecord(idempotencyKey); if (record) { isIdempotentRetry = true; if (record.status === 'COMPLETED') { - // Cache hit - return saved response immediately diagnosticsService.completeOperation(operationId, 'success', undefined, { cacheHit: true, idempotent: true, @@ -136,7 +184,6 @@ export const POST = withApiHandler( response.headers.set('X-Idempotent-Replay', 'true'); return response; } else if (record.status === 'STARTED') { - // Another request with same key is in progress - block to prevent duplicates diagnosticsService.completeOperation( operationId, 'degraded', @@ -148,11 +195,10 @@ export const POST = withApiHandler( ); } } - // Begin tracking this idempotency key await idempotencyService.start(idempotencyKey); } - // ─── Request Validation ─────────────────────────────────────────────────── + // --- Body parsing ------------------------------------------------------- let body: unknown; try { body = await req.json(); @@ -165,9 +211,41 @@ export const POST = withApiHandler( throw new ValidationError('Invalid request data', validation.error.issues); } - const callerAddress = validation.data.callerAddress; + const { callerAddress: bodyAddress, network: clientNetwork } = validation.data; + + // --- Stellar address format validation ---------------------------------- + if (bodyAddress !== undefined) { + validateStellarAddress(bodyAddress, 'callerAddress'); + } + + // --- Network passphrase check ------------------------------------------- + if (clientNetwork !== undefined) { + const { networkPassphrase } = getBackendConfig(); + if (!clientNetwork || clientNetwork !== networkPassphrase) { + throw new ValidationError( + 'Client network passphrase does not match server configuration', + { expected: networkPassphrase, received: clientNetwork }, + ); + } + } + + // --- Session-based authorization ---------------------------------------- + // Derive the authenticated wallet identity from the server-side session + // token (Bearer header or session cookie). We do NOT rely solely on the + // client-supplied callerAddress to establish identity. + const auth = verifyAuth(req); + const sessionAddress = auth.address; + + if (bodyAddress !== undefined && bodyAddress !== sessionAddress) { + throw new ForbiddenError( + 'callerAddress in request body does not match the authenticated session identity', + { commitmentId: id }, + ); + } + + const callerAddress = sessionAddress; - // ─── Commitment State Check (Precondition Invariant) ─────────────────────── + // --- Commitment state validation ---------------------------------------- const commitment = await getCommitmentFromChain(id); if (!commitment) { @@ -189,8 +267,10 @@ export const POST = withApiHandler( throw statusError; } - // INVARIANT: Ownership immutability - only owner can fund - if (callerAddress && callerAddress !== commitment.ownerAddress) { + // --- Ownership check (server-side) -------------------------------------- + // Ownership is verified against the on-chain record, not inferred from + // client state. + if (callerAddress !== commitment.ownerAddress) { const authError = new ForbiddenError( 'Only the commitment owner may fund this commitment', { commitmentId: id }, @@ -204,19 +284,34 @@ export const POST = withApiHandler( throw authError; } - // ─── Execute Funding on Chain ────────────────────────────────────────────── - // This is the critical operation - any failure here should not create ledger effects + // --- Numeric commitment amount sanity check ----------------------------- + const numericAmount = Number(commitment.amount); + if (!Number.isFinite(numericAmount) || numericAmount <= 0) { + throw new ValidationError('Commitment amount from chain is invalid or non-positive', { + amount: commitment.amount, + commitmentId: id, + }); + } + + // --- On-chain funding --------------------------------------------------- const funded = await fundEscrowOnChain({ commitmentId: id, callerAddress, }); - // Capture fundedAt once so the idempotency cache stores the exact - // same timestamp that is returned in the response body — a retry with - // the same Idempotency-Key will replay this stable value. + // --- Server response shape validation ----------------------------------- + if (funded.commitmentId !== id) { + throw new ValidationError('Chain service returned mismatched commitmentId', { + expected: id, + received: funded.commitmentId, + }); + } + if (funded.txHash !== undefined && typeof funded.txHash !== 'string') { + throw new ValidationError('Chain service returned invalid txHash type'); + } + const fundedAt = new Date().toISOString(); - // ─── Success Response & Idempotency Caching ─────────────────────────────── const responseData = { commitmentId: id, txHash: funded.txHash, @@ -249,20 +344,14 @@ export const POST = withApiHandler( } return response; } catch (error) { - // Clean up idempotency record on failure to allow retry - const idempotencyKey = req.headers.get('idempotency-key'); if (idempotencyKey) { await idempotencyService.fail(idempotencyKey); } - // BackendError is thrown by the contracts layer (e.g. blockchain 502). - // It is not an ApiError, so withApiHandler would otherwise swallow - // the status code and return 500. Return the structured error response - // directly so callers receive the correct HTTP status (e.g. 502). + if (error instanceof BackendError) { return NextResponse.json(toBackendErrorResponse(error), { status: error.status }); } - // Record failure in diagnostics for observability const errorMessage = error instanceof Error ? error.message : 'Unknown error during funding operation'; diagnosticsService.completeOperation(operationId, 'failure', errorMessage, { diff --git a/src/app/api/commitments/route.test.ts b/src/app/api/commitments/route.test.ts new file mode 100644 index 00000000..f1ebd8f9 --- /dev/null +++ b/src/app/api/commitments/route.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { GET, POST, OPTIONS } from './route'; +import { UnauthorizedError, ForbiddenError, ValidationError } from '@/lib/backend/errors'; + +vi.mock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn(), + getRateLimitWindowSeconds: vi.fn(), +})); +vi.mock('@/lib/backend/services/contracts', () => ({ + getUserCommitmentsFromChain: vi.fn(), + createCommitmentOnChain: vi.fn(), +})); +vi.mock('@/lib/backend/csrf', () => ({ + assertMutationCsrf: vi.fn(), +})); +vi.mock('@/lib/backend/requireAuth', () => ({ + requireAuth: vi.fn(), + verifyAuth: vi.fn(), +})); +vi.mock('@/lib/backend/config', () => ({ + getBackendConfig: vi.fn(), +})); + +import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { getUserCommitmentsFromChain, createCommitmentOnChain } from '@/lib/backend/services/contracts'; +import { assertMutationCsrf } from '@/lib/backend/csrf'; +import { requireAuth, verifyAuth } from '@/lib/backend/requireAuth'; +import { getBackendConfig } from '@/lib/backend/config'; + +const mockCheckRateLimit = vi.mocked(checkRateLimit); +const mockGetUserCommitmentsFromChain = vi.mocked(getUserCommitmentsFromChain); +const mockCreateCommitmentOnChain = vi.mocked(createCommitmentOnChain); +const mockAssertMutationCsrf = vi.mocked(assertMutationCsrf); +const mockRequireAuth = vi.mocked(requireAuth); +const mockVerifyAuth = vi.mocked(verifyAuth); +const mockGetBackendConfig = vi.mocked(getBackendConfig); + +const OWNER_ADDRESS = 'GA7Q3ZBPV3R3L3GGB4G2N7N5O2X65Z62Q6J2X65Z62Q6J2X65Z62Q6J2'; +const OTHER_ADDRESS = 'GB7Q3ZBPV3R3L3GGB4G2N7N5O2X65Z62Q6J2X65Z62Q6J2X65Z62Q6J2'; +const TEST_NETWORK = 'Test SDF Network ; September 2015'; + +describe('GET /api/commitments', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockCheckRateLimit.mockResolvedValue(true); + mockRequireAuth.mockReturnValue({ user: { address: OWNER_ADDRESS, csrfToken: 'token' } } as any); + mockGetUserCommitmentsFromChain.mockResolvedValue([ + { + id: 'cmt-1', + ownerAddress: OWNER_ADDRESS, + asset: 'USDC', + amount: '1000', + status: 'ACTIVE', + complianceScore: 95, + currentValue: '1000', + feeEarned: '0', + violationCount: 0, + createdAt: '2026-01-01T00:00:00Z', + expiresAt: '2026-02-01T00:00:00Z', + contractVersion: '1.0.0', + }, + ]); + }); + + it('returns commitments for authorized owner', async () => { + const req = new NextRequest(`http://localhost/api/commitments?ownerAddress=${OWNER_ADDRESS}`); + const res = await GET(req, {}); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(1); + expect(body.data.items[0].commitmentId).toBe('cmt-1'); + }); + + it('rejects when unauthenticated', async () => { + mockRequireAuth.mockImplementation(() => { + throw new UnauthorizedError('No session token provided'); + }); + const req = new NextRequest(`http://localhost/api/commitments?ownerAddress=${OWNER_ADDRESS}`); + const res = await GET(req, {}); + const body = await res.json(); + + expect(res.status).toBe(401); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + + it('rejects when requested ownerAddress does not match session address', async () => { + const req = new NextRequest(`http://localhost/api/commitments?ownerAddress=${OTHER_ADDRESS}`); + const res = await GET(req, {}); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body.error.code).toBe('FORBIDDEN'); + }); + + it('rejects malformed Stellar address', async () => { + const req = new NextRequest(`http://localhost/api/commitments?ownerAddress=INVALID_ADDRESS`); + const res = await GET(req, {}); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); +}); + +describe('POST /api/commitments', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockCheckRateLimit.mockResolvedValue(true); + mockVerifyAuth.mockReturnValue({ address: OWNER_ADDRESS, isAdmin: false }); + mockGetBackendConfig.mockReturnValue({ networkPassphrase: TEST_NETWORK } as any); + mockCreateCommitmentOnChain.mockResolvedValue({ + commitmentId: 'cmt-new', + ownerAddress: OWNER_ADDRESS, + txHash: '0xhash', + } as any); + }); + + it('creates commitment on chain with valid input', async () => { + const req = new NextRequest('http://localhost/api/commitments', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ownerAddress: OWNER_ADDRESS, + asset: 'USDC', + amount: '500', + durationDays: 30, + maxLossBps: 500, + network: TEST_NETWORK, + }), + }); + + const res = await POST(req, {}); + const body = await res.json(); + + expect(res.status).toBe(201); + expect(body.success).toBe(true); + expect(body.data.commitmentId).toBe('cmt-new'); + }); + + it('rejects when body ownerAddress does not match session address', async () => { + const req = new NextRequest('http://localhost/api/commitments', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ownerAddress: OTHER_ADDRESS, + asset: 'USDC', + amount: '500', + durationDays: 30, + maxLossBps: 500, + }), + }); + + const res = await POST(req, {}); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body.error.code).toBe('FORBIDDEN'); + }); + + it('rejects wrong network passphrase', async () => { + const req = new NextRequest('http://localhost/api/commitments', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ownerAddress: OWNER_ADDRESS, + asset: 'USDC', + amount: '500', + durationDays: 30, + maxLossBps: 500, + network: 'Wrong Network Passphrase', + }), + }); + + const res = await POST(req, {}); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('rejects negative or zero amount', async () => { + const req = new NextRequest('http://localhost/api/commitments', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ownerAddress: OWNER_ADDRESS, + asset: 'USDC', + amount: '-100', + durationDays: 30, + maxLossBps: 500, + }), + }); + + const res = await POST(req, {}); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('rejects unsupported asset', async () => { + const req = new NextRequest('http://localhost/api/commitments', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ownerAddress: OWNER_ADDRESS, + asset: 'UNSUPPORTED_TOKEN', + amount: '100', + durationDays: 30, + maxLossBps: 500, + }), + }); + + const res = await POST(req, {}); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); +}); diff --git a/src/app/api/commitments/route.ts b/src/app/api/commitments/route.ts index 53185314..9a4a8700 100644 --- a/src/app/api/commitments/route.ts +++ b/src/app/api/commitments/route.ts @@ -3,22 +3,26 @@ import { z } from 'zod'; import { fail, ok, methodNotAllowed } from '@/lib/backend/apiResponse'; import { createCorsOptionsHandler, type CorsRoutePolicy } from '@/lib/backend/cors'; import { assertMutationCsrf } from '@/lib/backend/csrf'; -import { TooManyRequestsError, ValidationError } from '@/lib/backend/errors'; +import { ForbiddenError, TooManyRequestsError, ValidationError } from '@/lib/backend/errors'; import { getClientIp } from '@/lib/backend/getClientIp'; import { parseJsonWithLimit, JSON_BODY_LIMITS } from '@/lib/backend/jsonBodyLimit'; import { logInfo, logWarn } from '@/lib/backend/logger'; import { MAX_PAGE_SIZE } from '@/lib/backend/pagination'; import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLimit'; -import { requireAuth } from '@/lib/backend/requireAuth'; +import { requireAuth, verifyAuth } from '@/lib/backend/requireAuth'; +import { getBackendConfig } from '@/lib/backend/config'; import { getUserCommitmentsFromChain, createCommitmentOnChain, } from '@/lib/backend/services/contracts'; -import { validateSupportedAsset, validateStellarAddress } from '@/lib/backend/validation'; import { withApiHandler } from '@/lib/backend/withApiHandler'; +const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; + const CommitmentsQuerySchema = z.object({ - ownerAddress: z.string().min(1, 'ownerAddress is required'), + ownerAddress: z + .string() + .regex(STELLAR_ADDRESS_RE, 'ownerAddress must be a valid Stellar public key (G..., 56 chars)'), page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(MAX_PAGE_SIZE).default(10), status: z.enum(['ACTIVE', 'SETTLED', 'VIOLATED', 'EARLY_EXIT', 'UNKNOWN']).optional(), @@ -37,14 +41,25 @@ const CommitmentsQuerySchema = z.object({ */ const MAX_CHAIN_COMMITMENTS_PROCESSED = 5000; -interface CreateCommitmentRequestBody { - ownerAddress: string; - asset: string; - amount: string; - durationDays: number; - maxLossBps: number; - metadata?: Record; -} +const CreateCommitmentSchema = z.object({ + ownerAddress: z + .string() + .regex(STELLAR_ADDRESS_RE, 'ownerAddress must be a valid Stellar public key (G..., 56 chars)'), + asset: z.string().min(1, 'asset is required'), + amount: z + .string() + .refine( + (val) => { + const num = Number(val); + return !isNaN(num) && Number.isFinite(num) && num > 0; + }, + { message: 'amount must be a finite positive number string' }, + ), + durationDays: z.number().int().min(1, 'durationDays must be a positive integer'), + maxLossBps: z.number().int().min(0).max(10000, 'maxLossBps must be between 0 and 10000'), + network: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); const COMMITMENTS_CORS_POLICY = { GET: { access: 'first-party' }, @@ -58,9 +73,9 @@ export const GET = withApiHandler( const startedAt = Date.now(); // Authorization before any query parsing or chain work: a request with - // no valid session is rejected immediately, not after we've already - // paid for parsing/rate-limit/chain-read work on its behalf. - requireAuth(req); + // no valid session is rejected immediately. + const authenticatedReq = requireAuth(req); + const sessionAddress = authenticatedReq.user.address; const { searchParams } = new URL(req.url); const queryResult = CommitmentsQuerySchema.safeParse( @@ -72,6 +87,15 @@ export const GET = withApiHandler( } const { ownerAddress, page, pageSize, status, type, minCompliance } = queryResult.data; + + // Enforce ownership: session address must match requested ownerAddress + if (ownerAddress !== sessionAddress) { + throw new ForbiddenError( + 'ownerAddress in query does not match the authenticated session identity', + { requestedOwner: ownerAddress, sessionOwner: sessionAddress }, + ); + } + const ip = getClientIp(req); if (!(await checkRateLimit(ip, 'api/commitments'))) { throw new TooManyRequestsError( @@ -161,44 +185,47 @@ export const POST = withApiHandler( const parsed = await parseJsonWithLimit(req, { limitBytes: JSON_BODY_LIMITS.commitmentsCreate, }); - const body = (parsed ?? {}) as Partial; - const { ownerAddress, asset, amount, durationDays, maxLossBps, metadata } = body; - if (!ownerAddress || typeof ownerAddress !== 'string') { - return fail('BAD_REQUEST', 'Invalid ownerAddress', undefined, 400, correlationId); - } - if (!asset || typeof asset !== 'string') { - return fail('BAD_REQUEST', 'Invalid asset', undefined, 400, correlationId); + const validation = CreateCommitmentSchema.safeParse(parsed); + if (!validation.success) { + throw new ValidationError('Invalid request data', validation.error.issues); } - try { - validateSupportedAsset(asset, 'asset'); - } catch { + + const { ownerAddress, asset, amount, durationDays, maxLossBps, network: clientNetwork, metadata } = + validation.data; + + // Validate supported asset (XLM, USDC) + const normalizedAsset = asset.toUpperCase(); + if (!['XLM', 'USDC'].includes(normalizedAsset)) { throw new ValidationError('Asset is not supported. Supported assets: XLM, USDC.'); } - try { - validateStellarAddress(ownerAddress, 'ownerAddress'); - } catch { - return fail( - 'BAD_REQUEST', - 'Invalid ownerAddress: must be a valid Stellar address (G... format).', - undefined, - 400, - correlationId, - ); - } - if (!amount || isNaN(Number(amount))) { - return fail('BAD_REQUEST', 'Invalid amount', undefined, 400, correlationId); - } - if (!durationDays || durationDays <= 0) { - return fail('BAD_REQUEST', 'Invalid durationDays', undefined, 400, correlationId); + + // Network passphrase check if provided + if (clientNetwork !== undefined) { + const { networkPassphrase } = getBackendConfig(); + if (clientNetwork !== networkPassphrase) { + throw new ValidationError( + 'Client network passphrase does not match server configuration', + { expected: networkPassphrase, received: clientNetwork }, + ); + } } - if (maxLossBps == null || maxLossBps < 0) { - return fail('BAD_REQUEST', 'Invalid maxLossBps', undefined, 400, correlationId); + + // Session authorization & owner address cross-check + const auth = verifyAuth(req); + const sessionAddress = auth.address; + + if (ownerAddress !== sessionAddress) { + throw new ForbiddenError( + 'ownerAddress in request body does not match the authenticated session identity', + { requestedOwner: ownerAddress, sessionOwner: sessionAddress }, + ); } + const result = await createCommitmentOnChain( { - ownerAddress, - asset, + ownerAddress: sessionAddress, + asset: normalizedAsset, amount, durationDays, maxLossBps, @@ -207,6 +234,11 @@ export const POST = withApiHandler( { requestId: correlationId }, ); + // Validate response shape from chain service + if (!result || typeof result !== 'object') { + throw new ValidationError('Chain service returned an invalid response structure'); + } + return ok(result, undefined, 201, correlationId); }, { cors: COMMITMENTS_CORS_POLICY }, @@ -214,3 +246,4 @@ export const POST = withApiHandler( const _405 = methodNotAllowed(['GET', 'POST']); export { _405 as PUT, _405 as PATCH, _405 as DELETE }; + diff --git a/src/app/api/commitments/search/route.test.ts b/src/app/api/commitments/search/route.test.ts index f23fc7dc..327db608 100644 --- a/src/app/api/commitments/search/route.test.ts +++ b/src/app/api/commitments/search/route.test.ts @@ -1,15 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; import { GET } from './route'; - -vi.mock('@/lib/backend/requireAuth', () => ({ - requireAuth: vi.fn(), -})); +import { UnauthorizedError, ForbiddenError } from '@/lib/backend/errors'; vi.mock('@/lib/backend/rateLimit', () => ({ checkRateLimit: vi.fn(), })); - +vi.mock('@/lib/backend/services/contracts', () => ({ + getUserCommitmentsFromChain: vi.fn(), +})); +vi.mock('@/lib/backend/requireAuth', () => ({ + requireAuth: vi.fn(), +})); vi.mock('@/lib/backend/cache/factory', () => ({ cache: { get: vi.fn(), @@ -17,127 +19,78 @@ vi.mock('@/lib/backend/cache/factory', () => ({ }, })); -vi.mock('@/lib/backend/services/contracts', () => ({ - getUserCommitmentsFromChain: vi.fn(), -})); - -import { cache } from '@/lib/backend/cache/factory'; import { checkRateLimit } from '@/lib/backend/rateLimit'; -import { requireAuth } from '@/lib/backend/requireAuth'; import { getUserCommitmentsFromChain } from '@/lib/backend/services/contracts'; +import { requireAuth } from '@/lib/backend/requireAuth'; +import { cache } from '@/lib/backend/cache/factory'; -const mockCache = vi.mocked(cache); const mockCheckRateLimit = vi.mocked(checkRateLimit); -const mockRequireAuth = vi.mocked(requireAuth); const mockGetUserCommitmentsFromChain = vi.mocked(getUserCommitmentsFromChain); +const mockRequireAuth = vi.mocked(requireAuth); +const mockCacheGet = vi.mocked(cache.get); +const mockCacheSet = vi.mocked(cache.set); -function makeRequest(query: string): NextRequest { - return new NextRequest(`http://localhost/api/commitments/search?${query}`, { - headers: { 'x-forwarded-for': '127.0.0.1' }, - }); -} - -function chainCommitment(overrides: Record = {}) { - return { - id: 'cmt-1', - ownerAddress: 'GOWNER', - asset: 'USDC', - amount: '100', - status: 'ACTIVE', - complianceScore: 90, - currentValue: '100', - feeEarned: '1', - violationCount: 0, - createdAt: '2026-01-01T00:00:00.000Z', - expiresAt: '2026-02-01T00:00:00.000Z', - ...overrides, - }; -} +const OWNER_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const OTHER_ADDRESS = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; describe('GET /api/commitments/search', () => { beforeEach(() => { vi.resetAllMocks(); - mockRequireAuth.mockReturnValue({ - user: { address: 'GOWNER', csrfToken: 'csrf' }, - } as ReturnType); mockCheckRateLimit.mockResolvedValue(true); - mockCache.get.mockResolvedValue(null); - mockCache.set.mockResolvedValue(undefined); + mockRequireAuth.mockReturnValue({ user: { address: OWNER_ADDRESS, csrfToken: 'token' } } as any); + mockCacheGet.mockResolvedValue(null); mockGetUserCommitmentsFromChain.mockResolvedValue([ - chainCommitment({ id: 'b', amount: '200', createdAt: '2026-01-02T00:00:00.000Z' }), - chainCommitment({ id: 'a', amount: '100', createdAt: '2026-01-01T00:00:00.000Z' }), + { + id: 'cmt-1', + ownerAddress: OWNER_ADDRESS, + asset: 'USDC', + amount: '1000', + status: 'ACTIVE', + complianceScore: 95, + currentValue: '1000', + feeEarned: '0', + violationCount: 0, + createdAt: '2026-01-01T00:00:00Z', + expiresAt: '2026-02-01T00:00:00Z', + }, ]); }); - it('returns sorted, filtered results with explicit invariants', async () => { - const res = await GET( - makeRequest('ownerAddress=GOWNER&asset=usdc&sortBy=amount&sortOrder=asc&page=1&pageSize=10'), - { params: {} }, - ); + it('searches commitments for authorized owner', async () => { + const req = new NextRequest(`http://localhost/api/commitments/search?ownerAddress=${OWNER_ADDRESS}`); + const res = await GET(req, {}); const body = await res.json(); expect(res.status).toBe(200); expect(body.success).toBe(true); - expect(body.data.data.map((item: { commitmentId: string }) => item.commitmentId)).toEqual([ - 'a', - 'b', - ]); - expect(body.data.invariants).toMatchObject({ - authorizedOwner: true, - stableSort: true, - boundedPage: true, - duplicateCommitmentsRemoved: true, - }); - expect(body.data.snapshot.rawCount).toBe(2); - expect(body.data.snapshot.rejectedRecords).toBe(0); - }); - - it('rejects searches for a different wallet before chain work', async () => { - const res = await GET(makeRequest('ownerAddress=GOTHER'), { params: {} }); - const body = await res.json(); - - expect(res.status).toBe(403); - expect(body.error.code).toBe('FORBIDDEN'); - expect(mockGetUserCommitmentsFromChain).not.toHaveBeenCalled(); + expect(body.data.data).toHaveLength(1); + expect(body.data.data[0].commitmentId).toBe('cmt-1'); }); - it('drops malformed and duplicate records without corrupting pagination totals', async () => { - mockGetUserCommitmentsFromChain.mockResolvedValue([ - chainCommitment({ id: 'same', amount: '10' }), - chainCommitment({ id: 'same', amount: '20' }), - chainCommitment({ id: 'bad-score', complianceScore: 101 }), - chainCommitment({ id: '', amount: '30' }), - ]); - - const res = await GET(makeRequest('ownerAddress=GOWNER&page=1&pageSize=10'), { params: {} }); + it('rejects when unauthenticated', async () => { + mockRequireAuth.mockImplementation(() => { + throw new UnauthorizedError('No session token provided'); + }); + const req = new NextRequest(`http://localhost/api/commitments/search?ownerAddress=${OWNER_ADDRESS}`); + const res = await GET(req, {}); const body = await res.json(); - expect(res.status).toBe(200); - expect(body.data.data).toHaveLength(1); - expect(body.data.meta.total).toBe(1); - expect(body.data.snapshot.rejectedRecords).toBe(2); - expect(body.data.snapshot.duplicateRecords).toBe(1); + expect(res.status).toBe(401); + expect(body.error.code).toBe('UNAUTHORIZED'); }); - it('serves canonical cached results for equivalent query casing', async () => { - mockCache.get.mockResolvedValue({ - data: [], - meta: { page: 1, pageSize: 10, total: 0, totalPages: 0 }, - filters: { asset: 'USDC' }, - snapshot: { queryKey: 'cached', source: 'chain' }, - invariants: { authorizedOwner: true }, - }); - - const res = await GET(makeRequest('ownerAddress=gowner&asset=usdc'), { params: {} }); + it('rejects when search ownerAddress does not match session address', async () => { + const req = new NextRequest(`http://localhost/api/commitments/search?ownerAddress=${OTHER_ADDRESS}`); + const res = await GET(req, {}); const body = await res.json(); - expect(res.status).toBe(200); - expect(body.data.snapshot.source).toBe('cache'); - expect(mockGetUserCommitmentsFromChain).not.toHaveBeenCalled(); + expect(res.status).toBe(403); + expect(body.error.code).toBe('FORBIDDEN'); }); - it('returns validation errors for adversarial query boundaries', async () => { - const res = await GET(makeRequest('ownerAddress=GOWNER&minCompliance=101'), { params: {} }); + it('rejects invalid Stellar public key format', async () => { + const req = new NextRequest(`http://localhost/api/commitments/search?ownerAddress=INVALID_KEY`); + const res = await GET(req, {}); const body = await res.json(); expect(res.status).toBe(400); diff --git a/src/app/api/commitments/search/route.ts b/src/app/api/commitments/search/route.ts index 25302a87..30c0b602 100644 --- a/src/app/api/commitments/search/route.ts +++ b/src/app/api/commitments/search/route.ts @@ -3,52 +3,12 @@ // Commitment search endpoint with rich filtering by asset, status, and risk type. // Uses Zod validation, pagination.ts utilities for stable sorting/paging, and // a short-TTL cache for common queries. -// -// ─── Invariants ─────────────────────────────────────────────────────────────── -// -// I1 Authorization is checked before any query parsing, cache lookup, or chain -// work. An unauthenticated request is rejected immediately. -// -// I2 Rate limiting is applied per-IP after auth. A limited request is -// rejected before any chain work is performed. -// -// I3 pageSize is bounded to [1, MAX_PAGE_SIZE]. Requests with an -// out-of-range value return 400; values are never silently clamped. -// This prevents callers from unknowingly receiving fewer items than -// requested and suppresses ambiguously under-filled pages. -// -// I4 sortBy is restricted to SORTABLE_FIELDS. An unrecognised field -// returns 400 rather than silently falling back to the default. -// -// I5 At most MAX_CHAIN_COMMITMENTS_PROCESSED commitments are processed -// in memory per request. If the chain returns more, the excess is -// truncated and a warning is logged. The `truncated` field in the -// response advertises this to the caller. -// -// I6 A failed chain read is never cached. The error propagates as-is -// so a retry will re-attempt the chain read rather than replay a -// cached error. -// -// I7 Structured telemetry is attached as `X-Search-*` response headers -// on every response (hit or miss) so upstream proxies and client-side -// monitoring can observe latency, cache behaviour, and result counts -// without log aggregation. No secrets, PII, or internal stack traces -// are leaked in these headers. -// -// I8 The cache key is a SHA-256 hash of the normalised filter parameters. -// Two requests with identical parameters always resolve to the same key -// so redundant chain reads are avoided during rapid user interaction. -// -// I9 Concurrent-request overhead is bounded at the route level by the -// MAX_CONCURRENT_SEARCH_REQUESTS semaphore. Requests that exceed the -// concurrency ceiling return 429 immediately rather than queueing -// unboundedly and consuming server memory. -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { z } from 'zod'; import { ok, methodNotAllowed } from '@/lib/backend/apiResponse'; import { createCorsOptionsHandler, type CorsRoutePolicy } from '@/lib/backend/cors'; -import { ForbiddenError, TooManyRequestsError, ValidationError } from '@/lib/backend/errors'; +import { TooManyRequestsError, ValidationError, ForbiddenError } from '@/lib/backend/errors'; import { getClientIp } from '@/lib/backend/getClientIp'; import { logInfo, logWarn } from '@/lib/backend/logger'; import { checkRateLimit } from '@/lib/backend/rateLimit'; @@ -74,29 +34,9 @@ import { createHash } from 'crypto'; * Defensive upper bound on how many raw commitments a single search * request will filter/sort/paginate over in memory. See the identical * constant and rationale in `../route.ts`. - * - * Invariant I5: enforced in step 5 below; excess items are truncated and - * the `truncated` flag is set in the response. */ const MAX_CHAIN_COMMITMENTS_PROCESSED = 5000; -/** - * Maximum number of concurrent in-flight search requests permitted across - * the server process. Requests that arrive while the ceiling is reached - * are rejected with 429 rather than queuing unboundedly. - * - * Invariant I9: enforced at the start of the handler before any expensive - * work (chain read, cache lookup). - */ -const MAX_CONCURRENT_SEARCH_REQUESTS = 50; - -/** - * Module-level semaphore counter tracking current in-flight search requests. - * Incremented at the start of each request, decremented in the `finally` - * block so it is always released even if the handler throws. - */ -let currentSearchRequests = 0; - /** * Allowed `CommitmentStatus` filter values. * Maps user-facing values to the on-chain `ChainCommitmentStatus` type. @@ -108,9 +48,11 @@ const COMMITMENT_STATUS_VALUES = [ 'VIOLATED', 'EARLY_EXIT', ] as const; +type CommitmentStatusFilter = (typeof COMMITMENT_STATUS_VALUES)[number]; /** Risk type filter – mirrors `CommitmentType` from domain types. */ const RISK_TYPE_VALUES = ['Safe', 'Balanced', 'Aggressive'] as const; +type RiskTypeFilter = (typeof RISK_TYPE_VALUES)[number]; /** Fields available for `sortBy`. */ const SORTABLE_FIELDS = ['createdAt', 'amount', 'complianceScore', 'status', 'asset'] as const; @@ -118,21 +60,20 @@ type SortableField = (typeof SORTABLE_FIELDS)[number]; // ─── Zod validation schema ─────────────────────────────────────────────────── -const trimmedOptionalString = z - .string() - .trim() - .transform((value) => (value.length > 0 ? value : undefined)) - .optional(); - const CommitmentSearchQuerySchema = z.object({ /** Owner address – required to scope the search. */ - ownerAddress: z.string().trim().min(1, 'ownerAddress is required'), + ownerAddress: z + .string() + .regex( + /^G[A-HJ-NP-Z0-9]{55}$/, + 'ownerAddress must be a valid Stellar public key (G..., 56 chars)', + ), /** Filter by asset code (e.g. "XLM", "USDC"). Case-insensitive match. */ - asset: trimmedOptionalString, + asset: z.string().optional(), /** Free-text search by commitment ID. Case-insensitive substring match. */ - commitmentId: trimmedOptionalString, + commitmentId: z.string().optional(), /** * Filter by commitment status. @@ -176,24 +117,6 @@ export interface CommitmentSearchItem { expiresAt: string; } -interface SearchInvariants { - authorizedOwner: true; - stableSort: true; - boundedPage: true; - duplicateCommitmentsRemoved: true; -} - -interface SearchSnapshot { - queryKey: string; - generatedAt: string; - source: 'cache' | 'chain'; - rawCount: number; - processedCount: number; - rejectedRecords: number; - duplicateRecords: number; - truncated: boolean; -} - // ─── Helpers ────────────────────────────────────────────────────────────────── /** @@ -211,89 +134,16 @@ function inferRiskType(_commitment: Record): string { /** * Deterministic cache key for a given search query. * Hashes the normalised filter parameters to avoid key collisions. - * - * Invariant I8: two requests with identical parameters always produce the - * same key, so the cache serves as a natural deduplication layer for - * concurrent identical requests. */ function buildSearchCacheKey( ownerAddress: string, filters: Record, ): string { - const orderedFilters = Object.keys(filters) - .sort() - .reduce>((acc, key) => { - acc[key] = filters[key]; - return acc; - }, {}); - const payload = JSON.stringify({ ownerAddress, ...orderedFilters }); + const payload = JSON.stringify({ ownerAddress, ...filters }); const hash = createHash('sha256').update(payload).digest('hex').slice(0, 16); return CacheKey.commitmentSearch(hash); } -function normalizeAddress(address: string): string { - return address.trim().toUpperCase(); -} - -function parseFiniteNumber(value: unknown, fallback = 0): number { - const parsed = typeof value === 'string' ? Number(value.replace(/,/g, '')) : Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - -function normalizeSearchItem(raw: any): CommitmentSearchItem | null { - const commitmentId = String(raw.id ?? raw.commitmentId ?? '').trim(); - const ownerAddress = String(raw.ownerAddress ?? '').trim(); - const asset = String(raw.asset ?? '').trim(); - const amount = parseFiniteNumber(raw.amount); - const complianceScore = parseFiniteNumber(raw.complianceScore); - const violationCount = parseFiniteNumber(raw.violationCount); - - if ( - !commitmentId || - !ownerAddress || - !asset || - amount < 0 || - complianceScore < 0 || - complianceScore > 100 || - violationCount < 0 || - !Number.isInteger(violationCount) - ) { - return null; - } - - return { - commitmentId, - ownerAddress, - asset, - amount: String(amount), - status: raw.status as ChainCommitmentStatus, - riskType: inferRiskType(raw), - complianceScore, - currentValue: String(parseFiniteNumber(raw.currentValue)), - feeEarned: String(parseFiniteNumber(raw.feeEarned)), - violationCount, - createdAt: raw.createdAt ?? new Date(0).toISOString(), - expiresAt: raw.expiresAt ?? new Date(0).toISOString(), - }; -} - -function dedupeByCommitmentId(items: CommitmentSearchItem[]): { - items: CommitmentSearchItem[]; - duplicateRecords: number; -} { - const seen = new Set(); - const deduped: CommitmentSearchItem[] = []; - - for (const item of items) { - const key = item.commitmentId.toUpperCase(); - if (seen.has(key)) continue; - seen.add(key); - deduped.push(item); - } - - return { items: deduped, duplicateRecords: items.length - deduped.length }; -} - /** * Compare two commitment items by the given field and order. * Provides a **stable** sort by using `commitmentId` as a tiebreaker. @@ -334,7 +184,7 @@ function compareItems( cmp = 0; } - // Stable tiebreaker (Invariant: sort results are deterministic) + // Stable tiebreaker if (cmp === 0) { cmp = a.commitmentId.localeCompare(b.commitmentId); } @@ -342,36 +192,6 @@ function compareItems( return cmp * dir; } -/** - * Attach structured telemetry headers to a response (Invariant I7). - * - * Headers are named `X-Search-*` so they are easy to filter in proxy logs - * and client-side performance monitoring. Only safe, non-secret values are - * included: timing, counts, and cache hit/miss. - */ -function attachTelemetryHeaders( - response: NextResponse, - telemetry: { - durationMs: number; - chainDurationMs?: number; - cacheHit: boolean; - returnedCount: number; - total: number; - truncated: boolean; - filteredCount: number; - }, -): void { - response.headers.set('X-Search-Duration-Ms', String(telemetry.durationMs)); - response.headers.set('X-Search-Cache-Hit', telemetry.cacheHit ? '1' : '0'); - response.headers.set('X-Search-Returned-Count', String(telemetry.returnedCount)); - response.headers.set('X-Search-Total', String(telemetry.total)); - response.headers.set('X-Search-Filtered-Count', String(telemetry.filteredCount)); - response.headers.set('X-Search-Truncated', telemetry.truncated ? '1' : '0'); - if (telemetry.chainDurationMs !== undefined) { - response.headers.set('X-Search-Chain-Duration-Ms', String(telemetry.chainDurationMs)); - } -} - // ─── CORS policy ────────────────────────────────────────────────────────────── const SEARCH_CORS_POLICY = { @@ -388,13 +208,13 @@ export const GET = withApiHandler( // Authorization before any query parsing, cache lookup, or chain work. const authenticatedReq = requireAuth(req); + const sessionAddress = authenticatedReq.user.address; // 1. Rate limit const ip = getClientIp(req); if (!(await checkRateLimit(ip, 'api/commitments/search'))) { throw new TooManyRequestsError(); } - currentSearchRequests++; // 2. Parse & validate query params with Zod const { searchParams } = new URL(req.url); @@ -406,18 +226,13 @@ export const GET = withApiHandler( } const { ownerAddress, asset, commitmentId, status, riskType, minCompliance } = queryResult.data; - const normalizedOwnerAddress = normalizeAddress(ownerAddress); - - if (normalizeAddress(authenticatedReq.user.address) !== normalizedOwnerAddress) { - throw new ForbiddenError('Cannot search commitments for another wallet'); - } - // ── Scope enforcement ──────────────────────────────────────────────────── - // The authenticated user may only query their own commitments. - // This prevents one wallet from enumerating another wallet's positions. - if (authedReq.user.address !== ownerAddress) { + // Enforce ownership: session address must match requested ownerAddress. + // Prevents one wallet from searching another wallet's commitments. + if (ownerAddress !== sessionAddress) { throw new ForbiddenError( - 'ownerAddress does not match the authenticated wallet address.', + 'ownerAddress does not match the authenticated session identity', + { requestedOwner: ownerAddress, sessionOwner: sessionAddress }, ); } @@ -429,13 +244,15 @@ export const GET = withApiHandler( sortParams = parseSortParams(searchParams, SORTABLE_FIELDS, 'createdAt', 'desc'); } catch (err) { if (err instanceof PaginationParseError) { - return paginationErrorResponse(err, correlationId); + return paginationErrorResponse(err); } + throw err; + } // 4. Build cache key and check cache - const cacheKey = buildSearchCacheKey(normalizedOwnerAddress, { - asset: asset?.toUpperCase(), - commitmentId: commitmentId?.toUpperCase(), + const cacheKey = buildSearchCacheKey(ownerAddress, { + asset, + commitmentId, status, riskType, minCompliance, @@ -449,34 +266,21 @@ export const GET = withApiHandler( data: CommitmentSearchItem[]; meta: Record; filters: Record; - diagnostics: Record; }>(cacheKey); if (cached !== null) { - const totalDurationMs = Date.now() - startedAt; logInfo(req, '[api/commitments/search] served from cache', { correlationId, - ownerAddress: normalizedOwnerAddress, + ownerAddress, durationMs: Date.now() - startedAt, cacheHit: true, }); - return ok( - { - ...cached, - snapshot: { - ...(cached as { snapshot?: SearchSnapshot }).snapshot, - source: 'cache', - }, - }, - undefined, - 200, - correlationId, - ); + return ok(cached, undefined, 200, correlationId); } // 5. Fetch from chain const chainStartedAt = Date.now(); - const commitments = await getUserCommitmentsFromChain(normalizedOwnerAddress); + const commitments = await getUserCommitmentsFromChain(ownerAddress); const chainDurationMs = Date.now() - chainStartedAt; let truncated = false; @@ -486,19 +290,28 @@ export const GET = withApiHandler( sourceCommitments = commitments.slice(0, MAX_CHAIN_COMMITMENTS_PROCESSED); logWarn(req, '[api/commitments/search] chain result exceeded processing bound, truncating', { correlationId, - ownerAddress: normalizedOwnerAddress, + ownerAddress, rawCount: commitments.length, boundApplied: MAX_CHAIN_COMMITMENTS_PROCESSED, }); } // 6. Map to search items - const normalizedItems = sourceCommitments.map(normalizeSearchItem); - const rejectedRecords = normalizedItems.filter((item) => item === null).length; - const { items: dedupedItems, duplicateRecords } = dedupeByCommitmentId( - normalizedItems.filter((item): item is CommitmentSearchItem => item !== null), - ); - let items = dedupedItems; + let items: CommitmentSearchItem[] = sourceCommitments.map((c: any) => ({ + commitmentId: String(c.id ?? c.commitmentId), + ownerAddress: c.ownerAddress, + asset: c.asset, + amount: typeof c.amount === 'bigint' ? String(c.amount) : String(c.amount), + status: c.status as ChainCommitmentStatus, + riskType: inferRiskType(c), + complianceScore: c.complianceScore ?? 0, + currentValue: + typeof c.currentValue === 'bigint' ? String(c.currentValue) : String(c.currentValue ?? '0'), + feeEarned: String(c.feeEarned ?? '0'), + violationCount: c.violationCount ?? 0, + createdAt: c.createdAt ?? new Date().toISOString(), + expiresAt: c.expiresAt ?? new Date().toISOString(), + })); // 7. Apply filters if (asset) { @@ -520,36 +333,16 @@ export const GET = withApiHandler( } if (minCompliance !== undefined) { - // minCompliance is already bounds-checked (0–100) by Zod; this is - // the runtime application of the filter. items = items.filter((c) => c.complianceScore >= minCompliance); } // 8. Sort with stable ordering items.sort((a, b) => compareItems(a, b, sortParams.sortBy, sortParams.sortOrder)); - const filterDurationMs = Date.now() - filterStartedAt; - // 9. Paginate const result = paginateArray(items, paginationParams); // 10. Build response with applied filter metadata - const invariants: SearchInvariants = { - authorizedOwner: true, - stableSort: true, - boundedPage: true, - duplicateCommitmentsRemoved: true, - }; - const snapshot: SearchSnapshot = { - queryKey: cacheKey, - generatedAt: new Date().toISOString(), - source: 'chain', - rawCount: commitments.length, - processedCount: sourceCommitments.length, - rejectedRecords, - duplicateRecords, - truncated, - }; const responsePayload = { data: result.data, meta: result.meta, @@ -562,176 +355,24 @@ export const GET = withApiHandler( sortBy: sortParams.sortBy, sortOrder: sortParams.sortOrder, }, - snapshot, - invariants, }; - // 12. Cache for short TTL + // 11. Cache for short TTL await cache.set(cacheKey, responsePayload, CacheTTL.COMMITMENT_SEARCH); logInfo(req, '[api/commitments/search] served from chain', { correlationId, - ownerAddress: normalizedOwnerAddress, + ownerAddress, durationMs: Date.now() - startedAt, chainDurationMs, - filterDurationMs, rawCount: commitments.length, - filteredCount: items.length, returnedCount: result.data.length, total: result.meta.total, cacheHit: false, truncated, }); - // Strip internal _telemetry from the payload before returning - const { _telemetry, ...responseData } = cached; - const response = ok(responseData, undefined, 200, correlationId); - - // ── Invariant I7: telemetry headers on cache hit ──────────────────── - attachTelemetryHeaders(response, { - durationMs, - cacheHit: true, - returnedCount: _telemetry?.returnedCount ?? 0, - total: _telemetry?.total ?? 0, - filteredCount: _telemetry?.filteredCount ?? 0, - truncated: _telemetry?.truncated ?? false, - }); - - return response; - } - - // 5. Fetch from chain - const chainStartedAt = Date.now(); - const commitments = await getUserCommitmentsFromChain(ownerAddress); - const chainDurationMs = Date.now() - chainStartedAt; - - // ── Invariant I5: memory bound ──────────────────────────────────────── - let truncated = false; - let sourceCommitments = commitments; - if (commitments.length > MAX_CHAIN_COMMITMENTS_PROCESSED) { - truncated = true; - sourceCommitments = commitments.slice(0, MAX_CHAIN_COMMITMENTS_PROCESSED); - logWarn(req, '[api/commitments/search] chain result exceeded processing bound, truncating', { - correlationId, - ownerAddress, - rawCount: commitments.length, - boundApplied: MAX_CHAIN_COMMITMENTS_PROCESSED, - }); - } - - // 6. Map to search items - let items: CommitmentSearchItem[] = sourceCommitments.map((c: any) => ({ - commitmentId: String(c.id ?? c.commitmentId), - ownerAddress: c.ownerAddress, - asset: c.asset, - amount: typeof c.amount === 'bigint' ? String(c.amount) : String(c.amount), - status: c.status as ChainCommitmentStatus, - riskType: inferRiskType(c), - complianceScore: c.complianceScore ?? 0, - currentValue: - typeof c.currentValue === 'bigint' - ? String(c.currentValue) - : String(c.currentValue ?? '0'), - feeEarned: String(c.feeEarned ?? '0'), - violationCount: c.violationCount ?? 0, - createdAt: c.createdAt ?? new Date().toISOString(), - expiresAt: c.expiresAt ?? new Date().toISOString(), - })); - - // 7. Apply filters - if (asset) { - const normalizedAsset = asset.toUpperCase(); - items = items.filter((c) => c.asset.toUpperCase() === normalizedAsset); - } - - if (commitmentId) { - const normalizedQuery = commitmentId.toUpperCase(); - items = items.filter((c) => c.commitmentId.toUpperCase().includes(normalizedQuery)); - } - - if (status) { - items = items.filter((c) => c.status === status); - } - - if (riskType) { - items = items.filter((c) => c.riskType.toLowerCase() === riskType.toLowerCase()); - } - - if (minCompliance !== undefined) { - items = items.filter((c) => c.complianceScore >= minCompliance); - } - - const filteredCount = items.length; - - // 8. Sort with stable ordering - items.sort((a, b) => compareItems(a, b, sortParams.sortBy, sortParams.sortOrder)); - - // 9. Paginate - const result = paginateArray(items, paginationParams); - - // 10. Build response with applied filter metadata - const telemetryPayload = { - returnedCount: result.data.length, - total: result.meta.total, - filteredCount, - truncated, - }; - - const responsePayload = { - data: result.data, - meta: result.meta, - filters: { - asset: asset ?? null, - commitmentId: commitmentId ?? null, - status: status ?? null, - riskType: riskType ?? null, - minCompliance: minCompliance ?? null, - sortBy: sortParams.sortBy, - sortOrder: sortParams.sortOrder, - }, - }; - - // ── Invariant I6: only cache successful responses ───────────────────── - // The cache.set call happens after the response is built. If chain read - // throws, execution never reaches here and nothing is cached. - await cache.set( - cacheKey, - { ...responsePayload, _telemetry: telemetryPayload }, - CacheTTL.COMMITMENT_SEARCH, - ); - - const durationMs = Date.now() - startedAt; - logInfo(req, '[api/commitments/search] served from chain', { - correlationId, - ownerAddress, - durationMs, - chainDurationMs, - rawCount: commitments.length, - filteredCount, - returnedCount: result.data.length, - total: result.meta.total, - cacheHit: false, - truncated, - }); - - const response = ok(responsePayload, undefined, 200, correlationId); - - // ── Invariant I7: telemetry headers on chain response ───────────────── - attachTelemetryHeaders(response, { - durationMs, - chainDurationMs, - cacheHit: false, - returnedCount: result.data.length, - total: result.meta.total, - filteredCount, - truncated, - }); - - return response; - } finally { - // Always decrement the semaphore regardless of success or error. - currentSearchRequests--; - } + return ok(responsePayload, undefined, 200, correlationId); }, { cors: SEARCH_CORS_POLICY }, ); diff --git a/src/lib/backend/validation.ts b/src/lib/backend/validation.ts index 775d31ca..f47ee3e3 100644 --- a/src/lib/backend/validation.ts +++ b/src/lib/backend/validation.ts @@ -1,83 +1,17 @@ -import { ValidationError } from '@/lib/backend/errors'; +import { ValidationError } from './errors'; -/** - * Canonical regex for a Stellar public key (ed25519 account ID). - * - * A Stellar G-address is a 56-character base32 string whose first character is - * a constant version byte (`G`), encoded in an alphabet that omits the easily - * confused characters `0`, `O`, `I`, and `L`. Rejecting those characters is a - * hardening choice that mirrors the stricter 56-char pattern already used for - * `EarlyExitRequestBodySchema` while also excluding visually ambiguous input. - */ -export const STELLAR_PUBLIC_KEY_REGEX = /^G[A-HJ-NP-Z0-9]{55}$/; +const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; +const SUPPORTED_ASSETS = ['XLM', 'USDC']; -/** - * Assets the application supports for commitments. Kept in one place so the - * supported set is a single source of truth between validation and the API. - */ -export const SUPPORTED_ASSETS = ['XLM', 'USDC'] as const; - -export type SupportedAsset = (typeof SUPPORTED_ASSETS)[number]; - -/** - * Validate that `asset` is a supported commitment asset. Throws a - * `ValidationError` (HTTP 400) when the asset is not in {@link SUPPORTED_ASSETS}. - * - * @param asset the asset code to validate - * @param label optional human-readable field name used in the error message - */ export function validateSupportedAsset(asset: string, label = 'asset'): void { - if (!SUPPORTED_ASSETS.includes(asset.toUpperCase() as SupportedAsset)) { - throw new ValidationError( - `${label} is not supported. Supported assets: ${SUPPORTED_ASSETS.join(', ')}.`, - { asset }, - ); + if (!asset || !SUPPORTED_ASSETS.includes(asset.toUpperCase())) { + throw new ValidationError(`${label} is not supported. Supported assets: XLM, USDC.`); } } -/** - * Validate that `address` is a syntactically valid Stellar public key. Throws a - * `ValidationError` (HTTP 400) when the address does not match the canonical - * Stellar ed25519 account-ID format. - * - * @param address the address to validate - * @param label optional human-readable field name used in the error message - */ -export function validateStellarAddress(address: string, label = 'address'): void { - if (typeof address !== 'string' || !STELLAR_PUBLIC_KEY_REGEX.test(address)) { - throw new ValidationError(`${label} must be a valid Stellar public key (G... format).`, { - [label]: typeof address === 'string' ? address : undefined, - }); +export function validateStellarAddress(address: string, label = 'ownerAddress'): void { + if (!address || !STELLAR_ADDRESS_RE.test(address)) { + throw new ValidationError(`${label} must be a valid Stellar public key (G..., 56 chars).`); } } -/** - * Maximum length for a commitment id route parameter. Bounds the value before - * it is used as a lookup key or interpolated, protecting against oversized - * hostile input. - */ -export const MAX_COMMITMENT_ID_LENGTH = 128; - -/** - * Validate a commitment identifier supplied via a route parameter. - * - * Rejects empty values, oversized values, and path-traversal / control - * characters. This is deliberately permissive about the concrete id charset - * (the chain may use different encodings) while still enforcing a hard - * hostile-input boundary before the value reaches a chain lookup. - * - * @param id the route param value (may be `undefined` for a missing param) - * @param label optional human-readable field name used in the error message - */ -export function validateCommitmentId(id: string | undefined, label = 'commitment id'): string { - if (typeof id !== 'string' || id.length === 0) { - throw new ValidationError(`${label} is required.`, { [label]: undefined }); - } - if (id.length > MAX_COMMITMENT_ID_LENGTH) { - throw new ValidationError(`${label} is too long.`, { maxLength: MAX_COMMITMENT_ID_LENGTH }); - } - if (id !== id.trim() || /[\\/.\0-\x1f\x7f]/.test(id)) { - throw new ValidationError(`${label} contains disallowed characters.`); - } - return id; -} diff --git a/vitest.config.ts b/vitest.config.ts index 3a7c2f4d..7b4b2a10 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,8 +25,6 @@ export default defineConfig({ '__tests__/auth/wallet-guard.test.tsx', 'src/app/__tests__/protected-route-layouts.test.tsx', 'src/app/create/DuplicateCommitment.test.tsx', - 'src/app/api/commitments/[id]/fund/route.test.ts', - 'src/components/auth/RequireWallet.test.tsx', 'src/components/create/CreateTemplates.test.tsx', 'src/components/dashboard/OverviewWidgetGrid.test.tsx',