Implement Redis rate limiting middleware for spam endpoints (FRE-4507)

- Add ioredis dependency to API package
- Create Redis connection utility (apps/api/src/config/redis.ts)
- Create Redis-backed spam rate limit middleware with per-minute and daily limits
- Create spam classification routes (SMS, number reputation, call analysis, feedback)
- Register middleware and routes in API server
- Add 7 passing tests for rate limit enforcement
- Update vitest config with required env vars

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-29 20:54:39 -04:00
parent 7928465a58
commit 3aead0d7bb
9 changed files with 1207 additions and 8 deletions

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { RedisRateLimiter } from '../middleware/spam-rate-limit.middleware';
import { redis } from '../config/redis';
describe('RedisRateLimiter', () => {
const testKey = 'test-client';
const limiter = new RedisRateLimiter();
beforeAll(async () => {
await redis.connect();
});
afterAll(async () => {
await redis.quit();
});
beforeEach(async () => {
await redis.del('spamshield:ratelimit:test-client');
await redis.del('spamshield:ratelimit:daily:test-client');
});
afterEach(async () => {
await redis.del('spamshield:ratelimit:test-client');
await redis.del('spamshield:ratelimit:daily:test-client');
});
describe('checkLimit (per-minute)', () => {
it('should allow requests within the limit', async () => {
const result = await limiter.checkLimit(testKey, 60, 10);
expect(result.remaining).toBe(9);
expect(result.retryAfter).toBeUndefined();
});
it('should decrement remaining on each request', async () => {
const result1 = await limiter.checkLimit(testKey, 60, 10);
const result2 = await limiter.checkLimit(testKey, 60, 10);
expect(result1.remaining).toBe(9);
expect(result2.remaining).toBe(8);
});
it('should exceed limit after max requests', async () => {
for (let i = 0; i < 10; i++) {
await limiter.checkLimit(testKey, 60, 10);
}
const result = await limiter.checkLimit(testKey, 60, 10);
expect(result.remaining).toBe(0);
expect(result.retryAfter).toBeGreaterThan(0);
});
it('should return retry-after when limit is exceeded', async () => {
for (let i = 0; i < 10; i++) {
await limiter.checkLimit(testKey, 60, 10);
}
const result = await limiter.checkLimit(testKey, 60, 10);
expect(result.retryAfter).toBeGreaterThan(0);
expect(result.retryAfter).toBeLessThanOrEqual(60000);
});
});
describe('checkDailyLimit', () => {
it('should allow requests within daily limit', async () => {
const result = await limiter.checkDailyLimit(testKey, 100);
expect(result.remaining).toBe(99);
expect(result.retryAfter).toBeUndefined();
});
it('should exceed daily limit after max requests', async () => {
for (let i = 0; i < 100; i++) {
await limiter.checkDailyLimit(testKey, 100);
}
const result = await limiter.checkDailyLimit(testKey, 100);
expect(result.remaining).toBe(0);
expect(result.retryAfter).toBeGreaterThan(0);
});
});
describe('reset', () => {
it('should clear the rate limit counter', async () => {
await limiter.checkLimit(testKey, 60, 10);
await limiter.checkLimit(testKey, 60, 10);
await limiter.reset(testKey);
const result = await limiter.checkLimit(testKey, 60, 10);
expect(result.remaining).toBe(9);
});
});
});