RateLimitService implements fixed-window counters as one atomic
PostgreSQL upsert (race-safe under concurrency, proven by test), with
opportunistic sweeping of expired windows and an explicit reset for
successful-login scenarios. The global RateLimitGuard applies
@RateLimit({scope, limit, windowSeconds}) per client IP and answers
429 with Retry-After; main.ts trusts the single Caddy hop so req.ip
is the real client.
Closes #11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
53 lines
2.0 KiB
TypeScript
53 lines
2.0 KiB
TypeScript
import { afterAll, describe, expect, it } from 'vitest';
|
|
|
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { RateLimitService } from './rate-limit.service';
|
|
|
|
describe.skipIf(!hasTestDb)('RateLimitService (database)', () => {
|
|
const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!;
|
|
const service = hasTestDb ? new RateLimitService(prisma) : null!;
|
|
const scope = `test-${uniqueSuffix()}`;
|
|
|
|
afterAll(async () => {
|
|
if (!hasTestDb) return;
|
|
await prisma.rateLimit.deleteMany({ where: { key: { startsWith: scope } } });
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
it('allows up to the limit and blocks beyond it', async () => {
|
|
for (let i = 0; i < 3; i += 1) {
|
|
expect((await service.hit(scope, 'ip:1', 3, 60)).allowed).toBe(true);
|
|
}
|
|
const blocked = await service.hit(scope, 'ip:1', 3, 60);
|
|
expect(blocked.allowed).toBe(false);
|
|
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('counts concurrent hits without losing any', async () => {
|
|
const results = await Promise.all(
|
|
Array.from({ length: 20 }, () => service.hit(scope, 'ip:2', 10, 60)),
|
|
);
|
|
expect(results.filter((r) => r.allowed)).toHaveLength(10);
|
|
});
|
|
|
|
it('rolls the window over after it expires', async () => {
|
|
expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(true);
|
|
expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(false);
|
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(true);
|
|
});
|
|
|
|
it('sweeps counters from long-ended windows', async () => {
|
|
await prisma.rateLimit.create({
|
|
data: {
|
|
key: `${scope}:ip:old`,
|
|
windowStart: new Date(Date.now() - 48 * 60 * 60 * 1000),
|
|
count: 5,
|
|
},
|
|
});
|
|
await service.cleanupExpired();
|
|
expect(await prisma.rateLimit.findUnique({ where: { key: `${scope}:ip:old` } })).toBeNull();
|
|
});
|
|
});
|