Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import '@testing-library/jest-dom'

// Next server modules expect Fetch API constructors in the Jest environment.
if (typeof globalThis.Request === 'undefined') globalThis.Request = class {};
if (typeof globalThis.Response === 'undefined') globalThis.Response = class {};
if (typeof globalThis.Headers === 'undefined') globalThis.Headers = class {};
import 'jest-axe/extend-expect'
import { configure } from '@testing-library/react'

Expand Down
73 changes: 73 additions & 0 deletions src/lib/__tests__/rateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
jest.mock('viem', () => ({ defineChain: (config: unknown) => config }));
jest.mock('@/config/env/schema', () => ({
validateEnv: () => ({
RATE_LIMIT_WINDOW_MS: 60000,
RATE_LIMIT_MAX_REQUESTS: 2,
RATE_LIMIT_MAX_REQUESTS_PER_WALLET: 2,
}),
}));
jest.mock('next/server', () => ({
NextResponse: {
json: jest.fn((body, init) => ({
status: init?.status ?? 200,
headers: new Headers(init?.headers),
body,
})),
},
}));

import {
createRateLimitResponse,
rateLimitByIP,
stopRateLimitCleanup,
withRateLimit,
} from '../rateLimit';

afterAll(() => stopRateLimitCleanup());

const request = (ip: string) => ({ headers: new Headers({ 'x-forwarded-for': ip }) }) as never;

describe('rate limiting', () => {
beforeEach(() => {
process.env.RATE_LIMIT_WINDOW_MS = '60000';
process.env.RATE_LIMIT_MAX_REQUESTS = '2';
});

it('allows requests until the configured limit and rejects the next one', async () => {
const first = await rateLimitByIP(request('rate-limit-test'));
const second = await rateLimitByIP(request('rate-limit-test'));
const third = await rateLimitByIP(request('rate-limit-test'));

expect(first).toMatchObject({ success: true, remaining: 1, limit: 2 });
expect(second).toMatchObject({ success: true, remaining: 0, limit: 2 });
expect(third).toMatchObject({ success: false, remaining: 0, limit: 2 });
expect(third.retryAfter).toBeGreaterThanOrEqual(1);
});

it('tracks different IPs independently', async () => {
expect((await rateLimitByIP(request('one'))).success).toBe(true);
expect((await rateLimitByIP(request('two'))).success).toBe(true);
});

it('passes through the handler and adds rate-limit headers', async () => {
const handler = withRateLimit(async () => ({ headers: new Headers(), status: 200 }) as never);
const response = await handler(request('pass-through'));

expect(response.status).toBe(200);
expect(response.headers.get('X-RateLimit-Limit')).toBe('2');
expect(response.headers.get('X-RateLimit-Remaining')).toBe('1');
});

it('returns a 429 response when the limit result is rejected', async () => {
const response = createRateLimitResponse({
success: false,
limit: 2,
remaining: 0,
resetTime: Date.now() + 10000,
retryAfter: 10,
});

expect(response.status).toBe(429);
expect(response.headers.get('Retry-After')).toBe('10');
});
});
38 changes: 38 additions & 0 deletions src/lib/__tests__/walletConnectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const request = jest.fn();

beforeEach(() => {
request.mockReset();
(window as Window & { ethereum?: unknown }).ethereum = {
request, isMetaMask: true, isCoinbaseWallet: true,
};
delete (window as Window & { __MOCK_WALLETCONNECT__?: unknown }).__MOCK_WALLETCONNECT__;
});

describe('wallet connectors', () => {
it('maps MetaMask account and hexadecimal chain id', async () => {
request.mockResolvedValueOnce(['0xmeta']).mockResolvedValueOnce('0x89');
const { connectMetaMaskWallet } = await import('../walletConnectors/metamask');
await expect(connectMetaMaskWallet()).resolves.toEqual({ address: '0xmeta', chainId: 137 });
});

it('maps Coinbase account and chain id', async () => {
request.mockResolvedValueOnce(['0xcoinbase']).mockResolvedValueOnce('0x1');
const { connectCoinbaseWallet } = await import('../walletConnectors/coinbase');
await expect(connectCoinbaseWallet()).resolves.toEqual({ address: '0xcoinbase', chainId: 1 });
});

it('uses the WalletConnect E2E mock hook', async () => {
(window as Window & { __MOCK_WALLETCONNECT__?: unknown }).__MOCK_WALLETCONNECT__ = {
address: '0xmock', chainId: 10,
};
jest.mock('@/utils/logger', () => ({ logger: { debug: jest.fn(), error: jest.fn() } }));
const { connectWalletConnectWallet } = await import('../walletConnectors/walletconnect');
await expect(connectWalletConnectWallet()).resolves.toEqual({ address: '0xmock', chainId: 10 });
});

it('normalizes provider rejection errors', async () => {
request.mockRejectedValueOnce({ code: 4001 });
const { connectMetaMaskWallet } = await import('../walletConnectors/metamask');
await expect(connectMetaMaskWallet()).rejects.toThrow('You rejected the connection request');
});
});
52 changes: 52 additions & 0 deletions src/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
jest.mock('next/server', () => ({
NextResponse: {
next: jest.fn(() => ({
headers: {
values: {} as Record<string, string>,
set(name: string, value: string) { this.values[name] = value; },
get(name: string) { return this.values[name] ?? null; },
},
})),
},
}));
jest.mock('@/lib/initRedisCache', () => ({ initRedisCacheSystem: jest.fn() }));
jest.mock('@/utils/logger', () => ({ logger: { info: jest.fn(), error: jest.fn() } }));

import { middleware } from './middleware';
import { NextResponse } from 'next/server';

const makeRequest = (pathname: string, accept = 'text/html') => ({
nextUrl: { pathname },
headers: new Headers({ accept }),
}) as never;

describe('middleware CSP', () => {
const original = process.env;

beforeEach(() => {
process.env = { ...original, CSP_ENFORCE: 'true', NODE_ENV: 'production' };
});

afterAll(() => {
process.env = original;
});

it('adds a CSP header and nonce for HTML requests', async () => {
const response = await middleware(makeRequest('/'));
expect(response).toBeDefined();
expect(NextResponse.next).toHaveBeenCalled();
});

it('generates a unique nonce per request', async () => {
await middleware(makeRequest('/one'));
await middleware(makeRequest('/two'));
expect(NextResponse.next).toHaveBeenCalled();
});

it('skips CSP for API and non-HTML requests', async () => {
const apiResponse = await middleware(makeRequest('/api/test'));
const assetResponse = await middleware(makeRequest('/page', 'application/json'));
expect(apiResponse.headers.get('Content-Security-Policy')).toBeNull();
expect(assetResponse.headers.get('Content-Security-Policy')).toBeNull();
});
});
Loading