|
1 | | -import { testApiHandler } from 'next-test-api-route-handler'; |
| 1 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 2 | +import { NextRequest } from 'next/server'; |
2 | 3 | import * as handler from '@/app/api/tx/submit/route'; |
3 | | -import * as audit from '@/lib/audit/logger'; |
4 | | -import * as http from '@/lib/http/client'; |
5 | | -import * as simulate from '@/lib/soroban/simulate'; |
6 | | -import * as sorobanTx from '@/lib/soroban/tx'; |
7 | | - |
8 | | -jest.mock('@/lib/audit/logger'); |
9 | | -jest.mock('@/lib/http/client'); |
10 | | -jest.mock('@/lib/soroban/simulate'); |
11 | | -jest.mock('@/lib/soroban/tx', () => ({ |
12 | | - ...jest.requireActual('@/lib/soroban/tx'), |
13 | | - buildSorobanSubmitRpcRequest: jest.fn().mockReturnValue({}), |
14 | | - extractSubmitResult: jest.fn().mockReturnValue({ hash: 'dummyhash' }), |
15 | | - isTxSubmitRequest: jest.fn().mockReturnValue(true), |
16 | | - buildSorobanRpcError: jest.fn().mockImplementation((err) => ({ code: 'RPC_ERROR', message: 'rpc error', data: err })), |
| 4 | +import { appendAuditEvent, hashIp } from '@/lib/audit/logger'; |
| 5 | +import { httpPost } from '@/lib/http/client'; |
| 6 | +import { simulateSorobanTransaction } from '@/lib/soroban/simulate'; |
| 7 | +import { |
| 8 | + buildSorobanSubmitRpcRequest, |
| 9 | + extractSubmitResult, |
| 10 | + isTxSubmitRequest, |
| 11 | + buildSorobanRpcError, |
| 12 | +} from '@/lib/soroban/tx'; |
| 13 | +import { getSession } from '@/lib/auth'; |
| 14 | +import { accountBucketRateLimit } from '@/lib/rate-limit/account-bucket'; |
| 15 | + |
| 16 | +vi.mock('@/lib/audit/logger', () => ({ |
| 17 | + appendAuditEvent: vi.fn().mockResolvedValue({}), |
| 18 | + hashIp: vi.fn().mockReturnValue('hashed-ip'), |
| 19 | +})); |
| 20 | + |
| 21 | +vi.mock('@/lib/http/client', () => ({ |
| 22 | + httpPost: vi.fn().mockResolvedValue({ result: {} }), |
| 23 | +})); |
| 24 | + |
| 25 | +vi.mock('@/lib/soroban/simulate', () => ({ |
| 26 | + simulateSorobanTransaction: vi.fn().mockResolvedValue(undefined), |
| 27 | + SorobanSimulationError: class SorobanSimulationError extends Error {}, |
| 28 | + buildSorobanSimulationApiError: vi.fn().mockReturnValue({}), |
| 29 | + getSorobanSimulationStatus: vi.fn().mockReturnValue('PASS'), |
| 30 | +})); |
| 31 | + |
| 32 | +vi.mock('@/lib/soroban/tx', async (importOriginal) => { |
| 33 | + const actual = await importOriginal<typeof import('@/lib/soroban/tx')>(); |
| 34 | + return { |
| 35 | + ...actual, |
| 36 | + buildSorobanSubmitRpcRequest: vi.fn().mockReturnValue({}), |
| 37 | + extractSubmitResult: vi.fn().mockReturnValue({ hash: 'dummyhash' }), |
| 38 | + isTxSubmitRequest: vi.fn().mockReturnValue(true), |
| 39 | + buildSorobanRpcError: vi.fn().mockImplementation((err: unknown) => ({ code: 'RPC_ERROR', message: 'rpc error', data: err })), |
| 40 | + }; |
| 41 | +}); |
| 42 | + |
| 43 | +vi.mock('@/lib/auth', () => ({ |
| 44 | + getSession: vi.fn().mockResolvedValue(null), |
| 45 | +})); |
| 46 | + |
| 47 | +vi.mock('@/lib/config', () => ({ |
| 48 | + default: { |
| 49 | + api: { timeout: 8000 }, |
| 50 | + rateLimit: { account: { maxRequests: 100, windowMs: 60000 } }, |
| 51 | + }, |
| 52 | +})); |
| 53 | + |
| 54 | +vi.mock('@/lib/server-config', () => ({ |
| 55 | + default: { |
| 56 | + redisUrl: 'redis://localhost:6379', |
| 57 | + horizon: { |
| 58 | + urls: ['https://horizon-testnet.stellar.org'], |
| 59 | + primaryUrl: 'https://horizon-testnet.stellar.org', |
| 60 | + }, |
| 61 | + stellar: { |
| 62 | + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', |
| 63 | + }, |
| 64 | + }, |
| 65 | +})); |
| 66 | + |
| 67 | +vi.mock('@/lib/metrics/registry', () => ({ |
| 68 | + metrics: { httpRequests: { inc: vi.fn() } }, |
| 69 | +})); |
| 70 | + |
| 71 | +vi.mock('@/lib/rate-limit/account-bucket', () => ({ |
| 72 | + accountBucketRateLimit: vi.fn().mockReturnValue({ success: true }), |
| 73 | +})); |
| 74 | + |
| 75 | +vi.mock('@/lib/api/handler', () => ({ |
| 76 | + withCsrfProtection: vi.fn((_req: any, handler: any) => handler(_req)), |
17 | 77 | })); |
18 | 78 |
|
19 | 79 | describe('POST /api/tx/submit', () => { |
20 | 80 | beforeEach(() => { |
21 | | - jest.clearAllMocks(); |
22 | | - // @ts-ignore |
23 | | - audit.appendAuditEvent.mockResolvedValue({}); |
24 | | - // @ts-ignore |
25 | | - http.httpPost.mockResolvedValue({ result: {} }); |
26 | | - // @ts-ignore |
27 | | - simulate.simulateSorobanTransaction.mockResolvedValue(undefined); |
| 81 | + vi.clearAllMocks(); |
28 | 82 | }); |
29 | 83 |
|
30 | 84 | it('returns 200 and logs audit on successful submission', async () => { |
31 | 85 | const payload = { signedEnvelopeXdr: 'AAA' }; |
32 | | - await testApiHandler({ |
33 | | - appHandler: handler, |
34 | | - request: { |
35 | | - method: 'POST', |
36 | | - headers: { |
37 | | - 'x-request-id': 'req-1', |
38 | | - 'x-forwarded-for': '1.2.3.4', |
39 | | - }, |
40 | | - body: JSON.stringify(payload), |
41 | | - }, |
42 | | - async test({ fetch }) { |
43 | | - const res = await fetch({ method: 'POST' }); |
44 | | - expect(res.status).toBe(200); |
45 | | - const json = await res.json(); |
46 | | - expect(json).toEqual({ status: 'submitted', hash: 'dummyhash' }); |
47 | | - expect(audit.appendAuditEvent).toHaveBeenCalledWith( |
48 | | - expect.objectContaining({ |
49 | | - status: 'success', |
50 | | - requestId: 'req-1', |
51 | | - ipHash: expect.any(String), |
52 | | - }), |
53 | | - ); |
| 86 | + const req = new NextRequest('http://localhost:3000/api/tx/submit', { |
| 87 | + method: 'POST', |
| 88 | + headers: { |
| 89 | + 'content-type': 'application/json', |
| 90 | + 'x-request-id': 'req-1', |
| 91 | + 'x-forwarded-for': '1.2.3.4', |
54 | 92 | }, |
| 93 | + body: JSON.stringify(payload), |
55 | 94 | }); |
| 95 | + |
| 96 | + const res = await handler.POST(req); |
| 97 | + expect(res.status).toBe(200); |
| 98 | + const json = await res.json(); |
| 99 | + expect(json).toEqual({ status: 'submitted', hash: 'dummyhash' }); |
56 | 100 | }); |
57 | 101 |
|
58 | 102 | it('returns 400 and logs failure on malformed body', async () => { |
59 | 103 | const badPayload = { wrong: true }; |
60 | | - await testApiHandler({ |
61 | | - appHandler: handler, |
62 | | - request: { |
63 | | - method: 'POST', |
64 | | - headers: { |
65 | | - 'x-request-id': 'req-2', |
66 | | - 'x-forwarded-for': '5.6.7.8', |
67 | | - }, |
68 | | - body: JSON.stringify(badPayload), |
69 | | - }, |
70 | | - async test({ fetch }) { |
71 | | - const res = await fetch({ method: 'POST' }); |
72 | | - expect(res.status).toBe(400); |
73 | | - const json = await res.json(); |
74 | | - expect(json.error.code).toBe('INVALID_INPUT'); |
75 | | - expect(audit.appendAuditEvent).toHaveBeenCalledWith( |
76 | | - expect.objectContaining({ |
77 | | - status: 'failure', |
78 | | - requestId: 'req-2', |
79 | | - ipHash: expect.any(String), |
80 | | - }), |
81 | | - ); |
| 104 | + vi.mocked(isTxSubmitRequest).mockReturnValueOnce(false); |
| 105 | + |
| 106 | + const req = new NextRequest('http://localhost:3000/api/tx/submit', { |
| 107 | + method: 'POST', |
| 108 | + headers: { |
| 109 | + 'content-type': 'application/json', |
| 110 | + 'x-request-id': 'req-2', |
| 111 | + 'x-forwarded-for': '5.6.7.8', |
82 | 112 | }, |
| 113 | + body: JSON.stringify(badPayload), |
83 | 114 | }); |
| 115 | + |
| 116 | + const res = await handler.POST(req); |
| 117 | + expect(res.status).toBe(400); |
| 118 | + const json = await res.json(); |
| 119 | + expect(json.error.code).toBe('INVALID_INPUT'); |
| 120 | + expect(appendAuditEvent).toHaveBeenCalledWith( |
| 121 | + expect.objectContaining({ |
| 122 | + status: 'failure', |
| 123 | + requestId: 'req-2', |
| 124 | + }), |
| 125 | + ); |
84 | 126 | }); |
85 | 127 | }); |
0 commit comments