Files
TREK/server/tests/unit/nest/rate-limit.service.test.ts
T
2026-10-06 21:50:59 +02:00

78 lines
3.1 KiB
TypeScript

/**
* RateLimitService — the per-IP buckets every auth-adjacent route throttles on.
* The limit behaviour is the legacy one line for line; what is worth pinning
* here is that the maps do not grow forever, because the keys come from
* proxy headers and are therefore attacker-influenced.
*/
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
import { describe, it, expect } from 'vitest';
const WINDOW = 15 * 60 * 1000;
/** The private bucket map, which is the only place the housekeeping is visible. */
function bucketSize(service: RateLimitService, bucket: string): number {
const buckets = (service as unknown as { buckets: Map<string, Map<string, unknown>> }).buckets;
return buckets.get(bucket)?.size ?? 0;
}
describe('RateLimitService', () => {
it('allows up to max attempts in a window, then refuses', () => {
const s = new RateLimitService();
const now = Date.now();
for (let i = 0; i < 5; i++) expect(s.check('login', '1.2.3.4', 5, WINDOW, now)).toBe(true);
expect(s.check('login', '1.2.3.4', 5, WINDOW, now)).toBe(false);
});
it('starts a fresh window once the old one elapsed', () => {
const s = new RateLimitService();
const now = Date.now();
for (let i = 0; i < 5; i++) s.check('login', '1.2.3.4', 5, WINDOW, now);
expect(s.check('login', '1.2.3.4', 5, WINDOW, now + WINDOW)).toBe(true);
});
it('drops expired records instead of keeping one per address that ever knocked', () => {
const s = new RateLimitService();
const now = Date.now();
for (let i = 0; i < 100; i++) s.check('login', `10.0.0.${i}`, 5, WINDOW, now);
expect(bucketSize(s, 'login')).toBe(100);
// One request a window later sweeps the hundred stale keys and leaves its own.
s.check('login', '10.0.0.0', 5, WINDOW, now + WINDOW);
expect(bucketSize(s, 'login')).toBe(1);
});
it('sweeps each bucket on its own clock, so a busy bucket cannot starve a quiet one', () => {
const s = new RateLimitService();
const now = Date.now();
s.check('login', '1.2.3.4', 5, WINDOW, now);
for (let i = 0; i < 3; i++) s.check('mfa', `10.0.0.${i}`, 5, WINDOW, now + WINDOW * (i + 1));
expect(bucketSize(s, 'login')).toBe(1);
s.check('login', '5.6.7.8', 5, WINDOW, now + WINDOW);
expect(bucketSize(s, 'login')).toBe(1);
});
it('keeps a record that is still inside its window when the sweep runs', () => {
const s = new RateLimitService();
const now = Date.now();
s.check('login', '1.2.3.4', 5, WINDOW, now);
s.check('login', '5.6.7.8', 5, WINDOW, now + WINDOW);
// The second key was written after the sweep, the first one was expired.
expect(bucketSize(s, 'login')).toBe(1);
expect(s.check('login', '1.2.3.4', 5, WINDOW, now + WINDOW)).toBe(true);
});
it('reset clears a single bucket, or all of them', () => {
const s = new RateLimitService();
const now = Date.now();
s.check('login', '1.2.3.4', 5, WINDOW, now);
s.check('mfa', '1.2.3.4', 5, WINDOW, now);
s.reset('login');
expect(bucketSize(s, 'login')).toBe(0);
expect(bucketSize(s, 'mfa')).toBe(1);
s.reset();
expect(bucketSize(s, 'mfa')).toBe(0);
});
});