import { afterEach, describe, expect, test, vi } from 'vitest'; import { createSlidingWindowLimiter, clientIp } from '@/lib/qa/rate-limit'; describe('createSlidingWindowLimiter', () => { test('allows up to max requests per window, then blocks with retryAfterMs', () => { const limiter = createSlidingWindowLimiter({ windowMs: 1000, max: 2 }); const now = 1_000_000; expect(limiter.check('ip-1', now).allowed).toBe(true); expect(limiter.check('ip-1', now + 10).allowed).toBe(true); expect(limiter.check('ip-1', now + 20)).toMatchObject({ allowed: false, remaining: 0 }); // Different key is independent. expect(limiter.check('ip-2', now + 30).allowed).toBe(true); // Oldest hit leaves the window → allowed again. expect(limiter.check('ip-1', now + 1001).allowed).toBe(true); }); test('reports retryAfterMs based on the oldest hit', () => { const limiter = createSlidingWindowLimiter({ windowMs: 1000, max: 1 }); const now = 5_000_000; limiter.check('ip', now); const decision = limiter.check('ip', now + 400); expect(decision.allowed).toBe(false); expect(decision.retryAfterMs).toBe(600); }); test('clear drops all state', () => { const limiter = createSlidingWindowLimiter({ windowMs: 1000, max: 1 }); limiter.check('ip'); limiter.clear(); expect(limiter.check('ip').allowed).toBe(true); }); test('uses one shared overflow bucket instead of growing beyond maxKeys', () => { const limiter = createSlidingWindowLimiter({ windowMs: 60_000, max: 2, maxKeys: 3 }); const now = 1_000_000; expect(limiter.check('ip-1', now).allowed).toBe(true); expect(limiter.check('ip-2', now).allowed).toBe(true); expect(limiter.check('ip-3', now).allowed).toBe(true); expect(limiter.check('ip-4', now + 1).allowed).toBe(true); expect(limiter.check('ip-5', now + 2).allowed).toBe(false); expect(limiter.size()).toBe(3); }); }); describe('clientIp', () => { const headers = new Headers(); const request = { headers, ip: undefined }; afterEach(() => { vi.unstubAllEnvs(); headers.delete('x-forwarded-for'); headers.delete('x-real-ip'); }); test('uses x-forwarded-for first hop only behind an explicitly trusted proxy', () => { vi.stubEnv('LEARNER_RATE_LIMIT_TRUST_PROXY_HEADERS', 'true'); headers.set('x-forwarded-for', '203.0.113.9, 10.0.0.1'); expect(clientIp(request)).toBe('203.0.113.9'); }); test('ignores spoofable forwarding headers by default', () => { headers.set('x-forwarded-for', '203.0.113.9'); headers.set('x-real-ip', '198.51.100.7'); expect(clientIp({ headers, ip: '127.0.0.1' })).toBe('127.0.0.1'); expect(clientIp({ headers, ip: undefined })).toBe('unknown'); }); test('falls back from trusted x-real-ip to request ip then unknown', () => { vi.stubEnv('LEARNER_RATE_LIMIT_TRUST_PROXY_HEADERS', 'true'); headers.set('x-real-ip', '198.51.100.7'); expect(clientIp(request)).toBe('198.51.100.7'); headers.delete('x-real-ip'); expect(clientIp({ headers, ip: '127.0.0.1' })).toBe('127.0.0.1'); expect(clientIp({ headers, ip: undefined })).toBe('unknown'); }); });