Files
openmaic/OpenMAIC/tests/qa/rate-limit.test.ts
2026-08-16 14:58:47 +08:00

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');
});
});