From 93d808ee87544947abcd1cab65af92834ab46fe7 Mon Sep 17 00:00:00 2001 From: Henry Eulam Eliazar Date: Thu, 27 Aug 2026 15:40:07 +0000 Subject: [PATCH] test: cover rate limits env middleware and wallet connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #934\nCloses #935\nCloses #936\nCloses #937\n\n🤖 Generated with Codebuff\nCo-Authored-By: Codebuff --- jest.setup.js | 5 ++ src/lib/__tests__/rateLimit.test.ts | 73 ++++++++++++++++++++++ src/lib/__tests__/walletConnectors.test.ts | 38 +++++++++++ src/middleware.test.ts | 52 +++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 src/lib/__tests__/rateLimit.test.ts create mode 100644 src/lib/__tests__/walletConnectors.test.ts create mode 100644 src/middleware.test.ts diff --git a/jest.setup.js b/jest.setup.js index 3ea5e2ee..a0516bc3 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -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' diff --git a/src/lib/__tests__/rateLimit.test.ts b/src/lib/__tests__/rateLimit.test.ts new file mode 100644 index 00000000..603a9b3e --- /dev/null +++ b/src/lib/__tests__/rateLimit.test.ts @@ -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'); + }); +}); diff --git a/src/lib/__tests__/walletConnectors.test.ts b/src/lib/__tests__/walletConnectors.test.ts new file mode 100644 index 00000000..8fdf236f --- /dev/null +++ b/src/lib/__tests__/walletConnectors.test.ts @@ -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'); + }); +}); diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 00000000..67dc29a6 --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,52 @@ +jest.mock('next/server', () => ({ + NextResponse: { + next: jest.fn(() => ({ + headers: { + values: {} as Record, + 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(); + }); +});