89 lines
3.1 KiB
TypeScript
89 lines
3.1 KiB
TypeScript
|
|
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||
|
|
|
||
|
|
import { redis } from '@/lib/redis';
|
||
|
|
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||
|
|
|
||
|
|
const TEST_USER = `rl-test-${crypto.randomUUID()}`;
|
||
|
|
|
||
|
|
async function clearTestKeys() {
|
||
|
|
// Wipe any keys created during this test run.
|
||
|
|
for (const cfg of Object.values(rateLimiters)) {
|
||
|
|
await redis.del(`rl:${cfg.keyPrefix}:${TEST_USER}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
await clearTestKeys();
|
||
|
|
});
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
await clearTestKeys();
|
||
|
|
await redis.quit();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('rate-limit catalog', () => {
|
||
|
|
it('exposes the canonical names with the documented limits', () => {
|
||
|
|
expect(rateLimiters.ocr).toMatchObject({ max: 10, windowMs: 60_000 });
|
||
|
|
expect(rateLimiters.ai).toMatchObject({ max: 60, windowMs: 60_000 });
|
||
|
|
expect(rateLimiters.exports).toMatchObject({ max: 30, windowMs: 60 * 60_000 });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('checkRateLimit (sliding window)', () => {
|
||
|
|
it('allows up to `max` calls in a window then refuses', async () => {
|
||
|
|
const cfg = rateLimiters.ocr;
|
||
|
|
let lastResult;
|
||
|
|
for (let i = 0; i < cfg.max; i++) {
|
||
|
|
lastResult = await checkRateLimit(TEST_USER, cfg);
|
||
|
|
expect(lastResult.allowed).toBe(true);
|
||
|
|
}
|
||
|
|
expect(lastResult!.remaining).toBe(0);
|
||
|
|
|
||
|
|
const overflow = await checkRateLimit(TEST_USER, cfg);
|
||
|
|
expect(overflow.allowed).toBe(false);
|
||
|
|
expect(overflow.remaining).toBe(0);
|
||
|
|
expect(overflow.limit).toBe(cfg.max);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('isolates limits across keyPrefixes (ocr vs ai vs exports)', async () => {
|
||
|
|
// Burn through ocr limit.
|
||
|
|
for (let i = 0; i < rateLimiters.ocr.max; i++) {
|
||
|
|
await checkRateLimit(TEST_USER, rateLimiters.ocr);
|
||
|
|
}
|
||
|
|
const ocrOverflow = await checkRateLimit(TEST_USER, rateLimiters.ocr);
|
||
|
|
expect(ocrOverflow.allowed).toBe(false);
|
||
|
|
|
||
|
|
// ai limit is independent.
|
||
|
|
const ai = await checkRateLimit(TEST_USER, rateLimiters.ai);
|
||
|
|
expect(ai.allowed).toBe(true);
|
||
|
|
|
||
|
|
// exports limit is independent.
|
||
|
|
const exp = await checkRateLimit(TEST_USER, rateLimiters.exports);
|
||
|
|
expect(exp.allowed).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('isolates limits across users (one user hitting cap does not affect another)', async () => {
|
||
|
|
const otherUser = `rl-test-${crypto.randomUUID()}`;
|
||
|
|
try {
|
||
|
|
for (let i = 0; i < rateLimiters.ocr.max; i++) {
|
||
|
|
await checkRateLimit(TEST_USER, rateLimiters.ocr);
|
||
|
|
}
|
||
|
|
const overflowSelf = await checkRateLimit(TEST_USER, rateLimiters.ocr);
|
||
|
|
expect(overflowSelf.allowed).toBe(false);
|
||
|
|
|
||
|
|
const otherFirst = await checkRateLimit(otherUser, rateLimiters.ocr);
|
||
|
|
expect(otherFirst.allowed).toBe(true);
|
||
|
|
expect(otherFirst.remaining).toBe(rateLimiters.ocr.max - 1);
|
||
|
|
} finally {
|
||
|
|
await redis.del(`rl:${rateLimiters.ocr.keyPrefix}:${otherUser}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports a sensible resetAt in the future', async () => {
|
||
|
|
const before = Date.now();
|
||
|
|
const r = await checkRateLimit(TEST_USER, rateLimiters.ocr);
|
||
|
|
expect(r.resetAt).toBeGreaterThanOrEqual(before + rateLimiters.ocr.windowMs - 1000);
|
||
|
|
expect(r.resetAt).toBeLessThanOrEqual(Date.now() + rateLimiters.ocr.windowMs + 1000);
|
||
|
|
});
|
||
|
|
});
|