Skip to content

Commit de974b8

Browse files
authored
Merge pull request #1318 from Emmanuelchukwunonso/feat/issues-1275-1273-635-497
feat: add request validation/rate limiting, error boundary, and ARIA labels
2 parents 6aa2094 + d2e705a commit de974b8

10 files changed

Lines changed: 339 additions & 49 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
4+
const applyRateLimitMock = vi.fn(() => null);
5+
6+
vi.mock('@/lib/rateLimit', () => ({
7+
applyRateLimit: (...args: unknown[]) => applyRateLimitMock(...args),
8+
getClientIp: vi.fn(() => '127.0.0.1'),
9+
}));
10+
11+
vi.mock('fs', () => ({
12+
default: {
13+
existsSync: vi.fn(() => false),
14+
readFileSync: vi.fn(),
15+
},
16+
}));
17+
18+
const { GET } = await import('./route');
19+
20+
function makeRequest(query: string) {
21+
return new NextRequest(new Request(`http://localhost/api/events${query}`));
22+
}
23+
24+
describe('GET /api/events', () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
applyRateLimitMock.mockReturnValue(null);
28+
});
29+
30+
it('returns 400 for a non-numeric limit', async () => {
31+
const req = makeRequest('?limit=not-a-number');
32+
const res = await GET(req);
33+
const body = await res.json();
34+
35+
expect(res.status).toBe(400);
36+
expect(body.success).toBe(false);
37+
});
38+
39+
it('returns 400 for a negative offset', async () => {
40+
const req = makeRequest('?offset=-1');
41+
const res = await GET(req);
42+
const body = await res.json();
43+
44+
expect(res.status).toBe(400);
45+
expect(body.success).toBe(false);
46+
});
47+
48+
it('returns 400 when limit exceeds the maximum', async () => {
49+
const req = makeRequest('?limit=1000');
50+
const res = await GET(req);
51+
const body = await res.json();
52+
53+
expect(res.status).toBe(400);
54+
expect(body.success).toBe(false);
55+
});
56+
57+
it('defaults limit and offset when omitted', async () => {
58+
const req = makeRequest('');
59+
const res = await GET(req);
60+
61+
expect(res.status).toBe(200);
62+
const body = await res.json();
63+
expect(body.events).toEqual([]);
64+
});
65+
66+
it('returns 429 when the rate limit is exceeded', async () => {
67+
applyRateLimitMock.mockReturnValueOnce(
68+
new Response(JSON.stringify({ success: false, retryAfter: 60 }), {
69+
status: 429,
70+
}) as never,
71+
);
72+
73+
const req = makeRequest('');
74+
const res = await GET(req);
75+
76+
expect(res.status).toBe(429);
77+
});
78+
});

Dechat/dex_with_fiat_frontend/src/app/api/events/route.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,38 @@ import { NextRequest, NextResponse } from 'next/server';
22
import fs from 'fs';
33
import path from 'path';
44
import { ContractEvent } from '../../../types/events';
5+
import { applyRateLimit, getClientIp } from '@/lib/rateLimit';
6+
import { eventsQuerySchema } from '@/lib/apiSchemas';
57

68
const DATA_DIR = path.join(process.cwd(), 'data');
79
const EVENTS_FILE = path.join(DATA_DIR, 'contract-events.json');
10+
const RATE_LIMIT = { maxRequests: 30, windowMs: 60_000 };
811

912
export async function GET(request: NextRequest) {
13+
const ip = getClientIp(request);
14+
const limited = applyRateLimit(ip, '/api/events', RATE_LIMIT);
15+
if (limited) return limited;
16+
1017
try {
1118
const { searchParams } = new URL(request.url);
12-
const limit = parseInt(searchParams.get('limit') || '20', 10);
13-
const offset = parseInt(searchParams.get('offset') || '0', 10);
19+
20+
const validationResult = eventsQuerySchema.safeParse({
21+
limit: searchParams.get('limit') ?? undefined,
22+
offset: searchParams.get('offset') ?? undefined,
23+
});
24+
25+
if (!validationResult.success) {
26+
return NextResponse.json(
27+
{
28+
success: false,
29+
message: 'Validation failed',
30+
errors: validationResult.error.issues,
31+
},
32+
{ status: 400 },
33+
);
34+
}
35+
36+
const { limit, offset } = validationResult.data;
1437

1538
if (!fs.existsSync(EVENTS_FILE)) {
1639
return NextResponse.json({ events: [], total: 0 });
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
4+
const applyRateLimitMock = vi.fn(() => null);
5+
6+
vi.mock('@/lib/rateLimit', () => ({
7+
applyRateLimit: (...args: unknown[]) => applyRateLimitMock(...args),
8+
getClientIp: vi.fn(() => '127.0.0.1'),
9+
}));
10+
11+
vi.mock('@/lib/payout/providers/registry', () => ({
12+
getPayoutProvider: () => ({
13+
checkTransferStatus: vi.fn().mockResolvedValue({ reference: 'ref-123', status: 'success' }),
14+
}),
15+
}));
16+
17+
const { POST } = await import('./route');
18+
19+
function makeRequest(body: unknown) {
20+
return new NextRequest(
21+
new Request('http://localhost/api/transfer-status', {
22+
method: 'POST',
23+
body: JSON.stringify(body),
24+
headers: { 'content-type': 'application/json' },
25+
}),
26+
);
27+
}
28+
29+
describe('POST /api/transfer-status', () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
applyRateLimitMock.mockReturnValue(null);
33+
});
34+
35+
it('returns 400 for malformed JSON body', async () => {
36+
const badReq = new NextRequest(
37+
new Request('http://localhost/api/transfer-status', {
38+
method: 'POST',
39+
body: 'not json{{{',
40+
headers: { 'content-type': 'application/json' },
41+
}),
42+
);
43+
44+
const res = await POST(badReq);
45+
const body = await res.json();
46+
47+
expect(res.status).toBe(400);
48+
expect(body.success).toBe(false);
49+
});
50+
51+
it('returns 400 for validation failures', async () => {
52+
const req = makeRequest({ reference: '' });
53+
const res = await POST(req);
54+
const body = await res.json();
55+
56+
expect(res.status).toBe(400);
57+
expect(body.success).toBe(false);
58+
});
59+
60+
it('returns 200 for a valid request', async () => {
61+
const req = makeRequest({ reference: 'ref-123' });
62+
const res = await POST(req);
63+
const body = await res.json();
64+
65+
expect(res.status).toBe(200);
66+
expect(body.success).toBe(true);
67+
});
68+
69+
it('returns 429 when the rate limit is exceeded', async () => {
70+
applyRateLimitMock.mockReturnValueOnce(
71+
new Response(JSON.stringify({ success: false, retryAfter: 60 }), {
72+
status: 429,
73+
}) as never,
74+
);
75+
76+
const req = makeRequest({ reference: 'ref-123' });
77+
const res = await POST(req);
78+
79+
expect(res.status).toBe(429);
80+
});
81+
});

Dechat/dex_with_fiat_frontend/src/app/api/transfer-status/route.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,41 @@
11
import { NextRequest, NextResponse } from 'next/server';
22

33
import { getPayoutProvider } from '@/lib/payout/providers/registry';
4+
import { applyRateLimit, getClientIp } from '@/lib/rateLimit';
5+
import { transferStatusSchema } from '@/lib/apiSchemas';
6+
7+
const RATE_LIMIT = { maxRequests: 10, windowMs: 60_000 };
48

59
export async function POST(request: NextRequest) {
10+
const ip = getClientIp(request);
11+
const limited = applyRateLimit(ip, '/api/transfer-status', RATE_LIMIT);
12+
if (limited) return limited;
13+
614
try {
7-
const { reference } = await request.json();
15+
let body: unknown;
16+
try {
17+
body = await request.json();
18+
} catch {
19+
return NextResponse.json(
20+
{ success: false, message: 'Request body must be valid JSON' },
21+
{ status: 400 },
22+
);
23+
}
824

9-
if (!reference) {
25+
const validationResult = transferStatusSchema.safeParse(body);
26+
if (!validationResult.success) {
1027
return NextResponse.json(
11-
{ success: false, message: 'Reference is required' },
28+
{
29+
success: false,
30+
message: 'Validation failed',
31+
errors: validationResult.error.issues,
32+
},
1233
{ status: 400 },
1334
);
1435
}
1536

37+
const { reference } = validationResult.data;
38+
1639
const provider = getPayoutProvider();
1740
const data = await provider.checkTransferStatus({ reference });
1841

Dechat/dex_with_fiat_frontend/src/components/ChatHistorySidebar.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,17 @@ export default function ChatHistorySidebar({
876876
<div
877877
className={`theme-border border-t p-4 ${isCollapsed ? 'flex flex-col items-center' : ''}`}
878878
>
879-
<PriceTicker symbols={['XLM', 'ETH', 'BTC']} currency="usd" />
879+
<ErrorBoundary
880+
fallback={
881+
<div className="theme-surface-muted rounded-lg border theme-border p-3">
882+
<p className="theme-text-secondary text-sm text-center py-2">
883+
Prices unavailable
884+
</p>
885+
</div>
886+
}
887+
>
888+
<PriceTicker symbols={['XLM', 'ETH', 'BTC']} currency="usd" />
889+
</ErrorBoundary>
880890

881891
<div
882892
className={`theme-border border-t p-4 ${isCollapsed ? 'flex flex-col items-center' : ''}`}

0 commit comments

Comments
 (0)