cms.c2sgmbh/tests/unit/security/rate-limiter.unit.spec.ts
Martin Porwoll 47d4825f77 revert: downgrade to Next.js 15.5.9 for Payload compatibility
Payload CMS 3.68.4 doesn't officially support Next.js 16 yet.
Reverting to 15.5.9 restores full compatibility.

Changes:
- Revert next: 16.0.10 → 15.5.9
- Revert eslint-config-next: 16.0.10 → 15.5.9
- Revert proxy.ts → middleware.ts (Next.js 15 convention)
- Restore eslint config in next.config.mjs
- Remove turbopack config (not needed for Next.js 15)

Test fixes (TypeScript errors):
- Fix MockPayloadRequest interface (remove PayloadRequest extension)
- Add Where type imports to access control tests
- Fix headers type casting in rate-limiter tests
- Fix localization type guard in i18n tests
- Add type property to post creation in search tests
- Fix nodemailer mock typing in email tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 10:07:39 +00:00

337 lines
8.9 KiB
TypeScript

/**
* 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<string, string>
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<string, string>
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<string, string>
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
})
})
})