81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
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');
|
|
});
|
|
});
|