/** * Rate Limiter Unit Tests * * Tests for the central rate limiting module. * Covers in-memory store, IP extraction, header generation, and Redis fallback. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' // Mock Redis before importing the module vi.mock('ioredis', () => ({ default: vi.fn().mockImplementation(() => ({ incr: vi.fn(), pexpire: vi.fn(), pttl: vi.fn(), keys: vi.fn(), del: vi.fn(), quit: vi.fn(), on: vi.fn(), })), })) // Mock the redis module vi.mock('@/lib/redis', () => ({ getRedisClient: vi.fn(() => null), isRedisAvailable: vi.fn(() => false), })) import { createRateLimiter, getClientIp, rateLimitHeaders, publicApiLimiter, authLimiter, searchLimiter, } from '@/lib/security/rate-limiter' describe('Rate Limiter', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) describe('createRateLimiter', () => { it('creates a limiter with custom configuration', () => { const limiter = createRateLimiter({ name: 'test-limiter', maxRequests: 10, windowMs: 60000, }) expect(limiter).toBeDefined() expect(limiter.check).toBeDefined() expect(typeof limiter.check).toBe('function') }) it('allows requests within limit', async () => { const limiter = createRateLimiter({ name: 'test-allow', maxRequests: 5, windowMs: 60000, }) const result = await limiter.check('test-ip-1') expect(result.allowed).toBe(true) expect(result.remaining).toBe(4) // 5 - 1 }) it('tracks requests per identifier', async () => { const limiter = createRateLimiter({ name: 'test-track', maxRequests: 3, windowMs: 60000, }) // First request const result1 = await limiter.check('user-a') expect(result1.remaining).toBe(2) // Second request same user const result2 = await limiter.check('user-a') expect(result2.remaining).toBe(1) // Different user should have full quota const result3 = await limiter.check('user-b') expect(result3.remaining).toBe(2) }) it('blocks requests exceeding limit', async () => { const limiter = createRateLimiter({ name: 'test-block', maxRequests: 3, windowMs: 60000, }) // Exhaust the limit await limiter.check('blocked-ip') await limiter.check('blocked-ip') await limiter.check('blocked-ip') // Next request should be blocked const result = await limiter.check('blocked-ip') expect(result.allowed).toBe(false) expect(result.remaining).toBe(0) expect(result.retryAfter).toBeGreaterThan(0) }) it('resets after window expires', async () => { const windowMs = 60000 const limiter = createRateLimiter({ name: 'test-reset', maxRequests: 2, windowMs, }) // Exhaust the limit await limiter.check('reset-ip') await limiter.check('reset-ip') const blockedResult = await limiter.check('reset-ip') expect(blockedResult.allowed).toBe(false) // Advance time past window vi.advanceTimersByTime(windowMs + 1000) // Should be allowed again const result = await limiter.check('reset-ip') expect(result.allowed).toBe(true) expect(result.remaining).toBe(1) }) it('provides correct resetIn value', async () => { const windowMs = 60000 const limiter = createRateLimiter({ name: 'test-resetin', maxRequests: 5, windowMs, }) const result = await limiter.check('resetin-ip') // resetIn should be close to windowMs expect(result.resetIn).toBeLessThanOrEqual(windowMs) expect(result.resetIn).toBeGreaterThan(windowMs - 1000) }) }) describe('getClientIp', () => { it('extracts IP from x-forwarded-for header', () => { const headers = new Headers() headers.set('x-forwarded-for', '203.0.113.50, 70.41.3.18, 150.172.238.178') const ip = getClientIp(headers) expect(ip).toBe('203.0.113.50') }) it('extracts IP from x-real-ip header', () => { const headers = new Headers() headers.set('x-real-ip', '192.168.1.100') const ip = getClientIp(headers) expect(ip).toBe('192.168.1.100') }) it('prefers x-forwarded-for over x-real-ip', () => { const headers = new Headers() headers.set('x-forwarded-for', '10.0.0.1') headers.set('x-real-ip', '10.0.0.2') const ip = getClientIp(headers) expect(ip).toBe('10.0.0.1') }) it('returns unknown for missing headers', () => { const headers = new Headers() const ip = getClientIp(headers) expect(ip).toBe('unknown') }) it('trims whitespace from forwarded IPs', () => { const headers = new Headers() headers.set('x-forwarded-for', ' 192.168.1.1 , 10.0.0.1') const ip = getClientIp(headers) expect(ip).toBe('192.168.1.1') }) }) describe('rateLimitHeaders', () => { it('generates correct headers for allowed request', () => { const result = { allowed: true, remaining: 25, resetIn: 45000, } const headers = rateLimitHeaders(result, 30) as Record expect(headers['X-RateLimit-Limit']).toBe('30') expect(headers['X-RateLimit-Remaining']).toBe('25') expect(headers['X-RateLimit-Reset']).toBeDefined() }) it('includes Retry-After for blocked request', () => { const result = { allowed: false, remaining: 0, resetIn: 30000, retryAfter: 30, } const headers = rateLimitHeaders(result, 10) as Record expect(headers['Retry-After']).toBe('30') expect(headers['X-RateLimit-Remaining']).toBe('0') }) it('calculates reset timestamp correctly', () => { const result = { allowed: true, remaining: 5, resetIn: 60000, } const headers = rateLimitHeaders(result, 10) as Record const resetValue = headers['X-RateLimit-Reset'] // The reset value should be a number (either timestamp or seconds) expect(resetValue).toBeDefined() expect(parseInt(resetValue, 10)).toBeGreaterThan(0) }) }) describe('Predefined Limiters', () => { it('publicApiLimiter allows 60 requests per minute', async () => { const result = await publicApiLimiter.check('public-test') expect(result.allowed).toBe(true) expect(result.remaining).toBe(59) }) it('authLimiter allows 5 requests per 15 minutes', async () => { const result = await authLimiter.check('auth-test') expect(result.allowed).toBe(true) expect(result.remaining).toBe(4) }) it('searchLimiter allows 30 requests per minute', async () => { const result = await searchLimiter.check('search-test') expect(result.allowed).toBe(true) expect(result.remaining).toBe(29) }) }) describe('Memory Store Cleanup', () => { it('removes expired entries', async () => { const windowMs = 1000 // 1 second window const limiter = createRateLimiter({ name: 'cleanup-test', maxRequests: 10, windowMs, }) // Create entries await limiter.check('cleanup-ip-1') await limiter.check('cleanup-ip-2') // Advance past expiry (2x window + cleanup interval) vi.advanceTimersByTime(windowMs * 2 + 300001) // New requests should have full quota (entries cleaned up) const result = await limiter.check('cleanup-ip-1') expect(result.remaining).toBe(9) }) }) describe('Edge Cases', () => { it('handles empty identifier', async () => { const limiter = createRateLimiter({ name: 'empty-id-test', maxRequests: 5, windowMs: 60000, }) const result = await limiter.check('') expect(result.allowed).toBe(true) }) it('handles special characters in identifier', async () => { const limiter = createRateLimiter({ name: 'special-char-test', maxRequests: 5, windowMs: 60000, }) const result = await limiter.check('user@example.com:192.168.1.1') expect(result.allowed).toBe(true) expect(result.remaining).toBe(4) }) it('handles concurrent requests', async () => { const limiter = createRateLimiter({ name: 'concurrent-test', maxRequests: 10, windowMs: 60000, }) // Fire multiple requests concurrently const promises = Array.from({ length: 5 }, () => limiter.check('concurrent-ip')) const results = await Promise.all(promises) // All should be allowed, remaining should decrement results.forEach((result) => { expect(result.allowed).toBe(true) }) // Check total consumed const finalResult = await limiter.check('concurrent-ip') expect(finalResult.remaining).toBe(4) // 10 - 5 - 1 = 4 }) }) })