Skip to content

Commit 0d6baf3

Browse files
authored
feat(frontend): harden banks API and instrument status telemetry (#1288)
## Summary - validate `/api/banks` query parameters with Zod and return a machine-readable `INVALID_REQUEST` error on invalid input - apply the shared rate limiter to `/api/banks`, with unit coverage for quota rejection - emit consent-aware structured telemetry for Paystack payment status and browser connectivity transitions - add `make ci` to run the frontend and contract CI matrix locally ## Rationale Contributors previously had to reconstruct checks from several workflow files. `make ci` provides one local entry point covering the same validation categories: frontend typecheck, lint, build, coverage, E2E, and contract test/build/clippy. ## Testing - `git diff --check` ✅ - Added banks API and status telemetry unit tests - `pnpm typecheck`, `pnpm lint`, and `pnpm test:unit` could not be executed locally because dependency installation exceeded the environment time limit before completion. Closes #1276 Closes #1265 Closes #1206 Closes #1205
1 parent 6bce56a commit 0d6baf3

10 files changed

Lines changed: 276 additions & 10 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
4+
vi.mock('@/lib/telemetry', () => ({
5+
telemetry: {
6+
extractTraceFromHeaders: () => ({ traceId: 'trace', spanId: 'parent' }),
7+
createSpan: () => ({ spanId: 'span' }),
8+
addLog: vi.fn(),
9+
finishSpan: vi.fn(),
10+
setTraceHeaders: vi.fn(),
11+
},
12+
}));
13+
14+
const { GET } = await import('./route');
15+
16+
function request(query = '', ip = '198.51.100.100') {
17+
return new NextRequest(`http://localhost/api/banks${query}`, {
18+
headers: { 'x-forwarded-for': ip },
19+
});
20+
}
21+
22+
describe('GET /api/banks', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
27+
it('returns a machine-readable 400 response for unsupported query parameters', async () => {
28+
const response = await GET(request('?country=ghana'));
29+
30+
expect(response.status).toBe(400);
31+
await expect(response.json()).resolves.toMatchObject({
32+
success: false,
33+
error: { code: 'INVALID_REQUEST' },
34+
});
35+
});
36+
37+
it('rejects requests after the shared rate-limit quota is exhausted', async () => {
38+
const ip = '198.51.100.101';
39+
40+
for (let attempt = 0; attempt < 30; attempt += 1) {
41+
expect((await GET(request('', ip))).status).toBe(200);
42+
}
43+
44+
const response = await GET(request('', ip));
45+
expect(response.status).toBe(429);
46+
await expect(response.json()).resolves.toMatchObject({ success: false });
47+
expect(response.headers.get('Retry-After')).toBe('60');
48+
});
49+
});

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { NextResponse } from 'next/server';
1+
import { NextRequest, NextResponse } from 'next/server';
22
import axios from 'axios';
33
import { telemetry } from '@/lib/telemetry';
44
import { env } from '@/lib/env';
5+
import { banksQuerySchema } from '@/lib/apiSchemas';
6+
import { applyRateLimit, getClientIp } from '@/lib/rateLimit';
57

68
const PAYSTACK_SECRET_KEY = env.PAYSTACK_SECRET_KEY;
9+
const RATE_LIMIT = { maxRequests: 30, windowMs: 60_000 };
10+
11+
export async function GET(request: NextRequest) {
12+
const limited = applyRateLimit(getClientIp(request), '/api/banks', RATE_LIMIT);
13+
if (limited) return limited;
714

8-
export async function GET(request: Request) {
915
const traceContext = telemetry.extractTraceFromHeaders(
1016
request.headers as Headers,
1117
);
@@ -20,6 +26,30 @@ export async function GET(request: Request) {
2026
endpoint: '/api/banks',
2127
});
2228

29+
const query = Object.fromEntries(request.nextUrl.searchParams.entries());
30+
const validationResult = banksQuerySchema.safeParse(query);
31+
if (!validationResult.success) {
32+
telemetry.addLog(span.spanId, 'warn', 'Banks query validation failed', {
33+
errors: validationResult.error.issues,
34+
});
35+
telemetry.finishSpan(span.spanId, { success: false, error: 'Invalid request' });
36+
37+
const response = NextResponse.json(
38+
{
39+
success: false,
40+
error: {
41+
code: 'INVALID_REQUEST',
42+
issues: validationResult.error.issues,
43+
},
44+
},
45+
{ status: 400 },
46+
);
47+
telemetry.setTraceHeaders(response.headers as Headers, traceContext);
48+
return response;
49+
}
50+
51+
const { country } = validationResult.data;
52+
2353
if (!PAYSTACK_SECRET_KEY) {
2454
telemetry.addLog(
2555
span.spanId,
@@ -104,11 +134,11 @@ export async function GET(request: Request) {
104134
// Call real Paystack API to get Nigerian banks
105135
telemetry.addLog(span.spanId, 'info', 'Calling Paystack API', {
106136
endpoint: 'https://api.paystack.co/bank',
107-
country: 'nigeria',
137+
country,
108138
});
109139

110140
const response = await axios.get(
111-
'https://api.paystack.co/bank?country=nigeria',
141+
`https://api.paystack.co/bank?country=${country}`,
112142
{
113143
headers: {
114144
Authorization: `Bearer ${PAYSTACK_SECRET_KEY}`,

Dechat/dex_with_fiat_frontend/src/components/OfflineStatusBanner.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { act, render, screen } from '@testing-library/react';
2+
import { act } from '@testing-library/react';
3+
import { render, screen } from '@testing-library/react';
34
import OfflineStatusBanner from './OfflineStatusBanner';
45
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
56
import { subscribeToQueuedMessageCount } from '@/lib/offlineMessageQueue';

Dechat/dex_with_fiat_frontend/src/components/OfflineStatusBanner.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export default function OfflineStatusBanner() {
3333
// The queue count is owned by `offlineMessageQueue` and published by
3434
// `useChat` as sends are queued and drained; this component only mirrors it.
3535
useEffect(() => {
36-
return subscribeToQueuedMessageCount(setOptimisticPendingCount);
36+
return subscribeToQueuedMessageCount((count) => {
37+
setOptimisticPendingCount(count);
38+
});
3739
}, []);
3840

3941
useEffect(() => {

Dechat/dex_with_fiat_frontend/src/hooks/useOnlineStatus.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useEffect, useState, useCallback } from 'react';
4+
import { chatTelemetry } from '@/lib/chatTelemetry';
45

56
/**
67
* Verify actual internet connectivity by pinging a reliable endpoint.
@@ -40,11 +41,19 @@ export function useOnlineStatus() {
4041
// Set initial state
4142
if (typeof window !== 'undefined') {
4243
setIsOnline(window.navigator.onLine);
44+
chatTelemetry.networkStatus({
45+
status: window.navigator.onLine ? 'online' : 'offline',
46+
source: 'initial',
47+
});
4348
}
4449

4550
const handleOnline = async () => {
4651
const hasConnectivity = await verifyConnectivity();
4752
setIsOnline(hasConnectivity);
53+
chatTelemetry.networkStatus({
54+
status: hasConnectivity ? 'online' : 'offline',
55+
source: 'connectivity-check',
56+
});
4857
if (hasConnectivity) {
4958
setWasOffline(true);
5059
}
@@ -53,6 +62,7 @@ export function useOnlineStatus() {
5362
const handleOffline = () => {
5463
setIsOnline(false);
5564
setWasOffline(true);
65+
chatTelemetry.networkStatus({ status: 'offline', source: 'browser-event' });
5666
};
5767

5868
if (typeof window !== 'undefined') {

Dechat/dex_with_fiat_frontend/src/hooks/usePaystackWebhookStatus.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect } from 'react';
44
import { useToast } from '@/hooks/useToast';
55
import { getOrCreateClientSessionId } from '@/lib/clientSession';
6+
import { chatTelemetry } from '@/lib/chatTelemetry';
67

78
interface PaymentStatusStreamEvent {
89
reference: string;
@@ -33,6 +34,12 @@ export function usePaystackWebhookStatus() {
3334
eventSource.onmessage = (event) => {
3435
try {
3536
const payload = JSON.parse(event.data) as PaymentStatusStreamEvent;
37+
chatTelemetry.paymentStatus({
38+
status: payload.status,
39+
reference: payload.reference,
40+
hasAmount: typeof payload.amount === 'number',
41+
hasFailureReason: Boolean(payload.failureReason),
42+
});
3643
if (payload.status === 'success') {
3744
addToast({
3845
message: 'Payment confirmed!',
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { act, renderHook, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { setTelemetryConsent, type ChatEvent } from '@/lib/chatTelemetry';
4+
import { ThemeProvider } from '@/contexts/ThemeContext';
5+
import { useOnlineStatus } from './useOnlineStatus';
6+
7+
vi.mock('@/hooks/useToast', () => ({ useToast: () => ({ addToast: vi.fn() }) }));
8+
vi.mock('@/lib/clientSession', () => ({
9+
getOrCreateClientSessionId: () => 'session-1',
10+
}));
11+
12+
class MockEventSource {
13+
static latest: MockEventSource | undefined;
14+
onmessage: ((event: MessageEvent) => void) | null = null;
15+
onerror: (() => void) | null = null;
16+
close = vi.fn();
17+
18+
constructor(url: string) {
19+
void url;
20+
MockEventSource.latest = this;
21+
}
22+
}
23+
24+
function captureEvent(): Promise<ChatEvent> {
25+
return new Promise((resolve) => {
26+
window.addEventListener(
27+
'chat:telemetry',
28+
(event) => resolve((event as CustomEvent<ChatEvent>).detail),
29+
{ once: true },
30+
);
31+
});
32+
}
33+
34+
describe('status telemetry hooks', () => {
35+
beforeEach(() => {
36+
localStorage.clear();
37+
setTelemetryConsent(true);
38+
vi.stubGlobal('EventSource', MockEventSource);
39+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
40+
callback(0);
41+
return 1;
42+
});
43+
});
44+
45+
it('emits a structured payment-status event without exposing payment values', async () => {
46+
const { usePaystackWebhookStatus } = await import('./usePaystackWebhookStatus');
47+
renderHook(() => usePaystackWebhookStatus());
48+
const eventPromise = captureEvent();
49+
50+
act(() => {
51+
MockEventSource.latest?.onmessage?.({
52+
data: JSON.stringify({ reference: 'payment-ref', status: 'success', amount: 1200 }),
53+
} as MessageEvent);
54+
});
55+
56+
await expect(eventPromise).resolves.toMatchObject({
57+
name: 'payment_status',
58+
payload: {
59+
reference: 'payment-ref',
60+
status: 'success',
61+
hasAmount: true,
62+
hasFailureReason: false,
63+
},
64+
});
65+
});
66+
67+
it('emits the online state in both light and dark theme documents', async () => {
68+
Object.defineProperty(window.navigator, 'onLine', { configurable: true, value: false });
69+
70+
for (const theme of ['light', 'dark']) {
71+
localStorage.setItem('theme', theme);
72+
const eventPromise = captureEvent();
73+
const { unmount } = renderHook(() => useOnlineStatus(), {
74+
wrapper: ThemeProvider,
75+
});
76+
77+
await expect(eventPromise).resolves.toMatchObject({
78+
name: 'network_status',
79+
payload: { status: 'offline', source: 'initial' },
80+
});
81+
unmount();
82+
}
83+
});
84+
85+
it('emits an offline browser-event transition', async () => {
86+
const { result } = renderHook(() => useOnlineStatus());
87+
await waitFor(() => expect(result.current.isOnline).toBeDefined());
88+
const eventPromise = captureEvent();
89+
90+
act(() => window.dispatchEvent(new Event('offline')));
91+
92+
await expect(eventPromise).resolves.toMatchObject({
93+
name: 'network_status',
94+
payload: { status: 'offline', source: 'browser-event' },
95+
});
96+
});
97+
});

Dechat/dex_with_fiat_frontend/src/lib/apiSchemas.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ export const verifyAccountSchema = z.object({
3030

3131
export type VerifyAccountInput = z.infer<typeof verifyAccountSchema>;
3232

33+
// Schema for the banks endpoint query string. The endpoint currently serves
34+
// Nigerian NUBAN banks only, so rejecting unsupported query parameters keeps
35+
// the public contract explicit rather than silently ignoring user input.
36+
export const banksQuerySchema = z
37+
.object({
38+
country: z.literal('nigeria').default('nigeria'),
39+
})
40+
.strict();
41+
42+
export type BanksQuery = z.infer<typeof banksQuerySchema>;
43+
3344
/**
3445
* Error thrown by {@link fetchWithRetry} when the server answers with a
3546
* non-OK status.
@@ -65,6 +76,11 @@ export interface RetryConfig {
6576
retryableErrors?: (error: unknown) => boolean;
6677
}
6778

79+
interface HttpError extends Error {
80+
status?: number;
81+
response?: Response;
82+
}
83+
6884
/**
6985
* Default retry configuration
7086
*/
@@ -158,8 +174,12 @@ export async function withRetry<T>(
158174
// Check if error is retryable
159175
const isRetryableError = mergedConfig.retryableErrors(error);
160176
const isRetryableStatus =
161-
(error instanceof HttpResponseError || error instanceof Response) &&
162-
mergedConfig.retryableStatusCodes.includes(error.status);
177+
error instanceof Response
178+
? mergedConfig.retryableStatusCodes.includes(error.status)
179+
: error instanceof Error &&
180+
mergedConfig.retryableStatusCodes.includes(
181+
(error as HttpError).status ?? 0,
182+
);
163183

164184
if (!isRetryableError && !isRetryableStatus) {
165185
throw error; // Non-retryable error, throw immediately
@@ -193,7 +213,11 @@ export async function fetchWithRetry(
193213

194214
if (!response.ok) {
195215
// Throw error to trigger retry for non-OK responses
196-
throw new HttpResponseError(response);
216+
const error: HttpError = Object.assign(
217+
new Error(`HTTP ${response.status}: ${response.statusText}`),
218+
{ status: response.status, response },
219+
);
220+
throw error;
197221
}
198222

199223
return response;

Dechat/dex_with_fiat_frontend/src/lib/chatTelemetry.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ export type ChatEventName =
1313
| 'bridge_open'
1414
| 'tx_confirm'
1515
| 'fiat_payout_step'
16-
| 'avatar_color_check';
16+
| 'avatar_color_check'
17+
| 'payment_status'
18+
| 'network_status';
1719

1820
export interface ChatEvent<P extends object = Record<string, unknown>> {
1921
/** Normalized event name. */
@@ -80,6 +82,18 @@ export interface AvatarColorTelemetryPayload {
8082
avatarTextColor?: string;
8183
}
8284

85+
export interface PaymentStatusTelemetryPayload {
86+
status: 'success' | 'failed' | 'reversed' | 'pending' | 'cancelled';
87+
reference: string;
88+
hasAmount: boolean;
89+
hasFailureReason: boolean;
90+
}
91+
92+
export interface NetworkStatusTelemetryPayload {
93+
status: 'online' | 'offline';
94+
source: 'initial' | 'browser-event' | 'connectivity-check';
95+
}
96+
8397
export interface AccessibleAvatarColorTelemetryPayload
8498
extends AvatarColorTelemetryPayload {
8599
avatarTextColor: string;
@@ -450,6 +464,14 @@ export const chatTelemetry = {
450464
emit('fiat_payout_step', payload);
451465
},
452466

467+
paymentStatus(payload: PaymentStatusTelemetryPayload): void {
468+
emit('payment_status', payload);
469+
},
470+
471+
networkStatus(payload: NetworkStatusTelemetryPayload): void {
472+
emit('network_status', payload);
473+
},
474+
453475
/**
454476
* Emit an `avatar_color_check` event that records whether the avatar
455477
* foreground/background colour pair meets WCAG AA contrast (4.5:1).

0 commit comments

Comments
 (0)