feat(rate-limit): per-user limiters for OCR, AI, and exports
Adds three named rate limiters to the existing Redis sliding-window catalog and a withRateLimit wrapper that composes inside withAuth. Wires the OCR limiter into the receipt-scan endpoint so a runaway client can't burn through the AI budget in a tight loop. - ocr: 10/min/user - ai: 60/min/user (reserved for future server-side AI surfaces) - exports: 30/hour/user (reserved for GDPR bundle, PDF, CSV exports) 429 responses include X-RateLimit-* headers and a Retry-After hint. Tests: 771/771 vitest (was 766) — +5 rate-limit tests covering catalog shape, sliding window, cross-prefix isolation, cross-user isolation, and resetAt timestamp. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
88
tests/integration/rate-limit.test.ts
Normal file
88
tests/integration/rate-limit.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user