From bb052d0402f46651b2c79dbdec1dae5698e9b84c Mon Sep 17 00:00:00 2001 From: hrishu802 Date: Thu, 17 Sep 2026 16:11:17 +0530 Subject: [PATCH 1/3] feat(api): add API rate limiting and abuse protection --- .env.example | 17 + apps/api/package.json | 2 +- apps/api/src/app.ts | 18 +- apps/api/src/config/env.ts | 44 +- apps/api/src/plugins/rate-limit.ts | 370 ++++++++++++++- apps/api/test/rate-limit.test.ts | 739 +++++++++++++++++++++++++++++ 6 files changed, 1160 insertions(+), 30 deletions(-) create mode 100644 apps/api/test/rate-limit.test.ts diff --git a/.env.example b/.env.example index db921c8..77b1de2 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,26 @@ NODE_ENV=development # Postgres Database connection string DATABASE_URL="postgresql://postgres:postgres@localhost:5432/interrupt_iq?schema=public" +# Redis Distributed Cache & Rate Limiter +REDIS_URL="redis://localhost:6379" + # JWT token signature secret JWT_SECRET="generate-a-secure-random-token-key-for-production" +# Rate Limiting Configuration +RATE_LIMIT_ENABLED=true +RATE_LIMIT_GLOBAL_MAX=100 +RATE_LIMIT_GLOBAL_WINDOW_MS=60000 +RATE_LIMIT_AUTH_MAX=10 +RATE_LIMIT_EVENTS_MAX=120 +RATE_LIMIT_DECISION_MAX=60 +RATE_LIMIT_RETRIEVAL_MAX=20 +RATE_LIMIT_CRITIC_MAX=10 + +# Reverse Proxy Trust Configuration (false for direct access; true or CIDR string if behind trusted reverse proxy) +TRUST_PROXY=false + # LLM Providers Configuration (Optional) OPENAI_API_KEY="" OLLAMA_ENDPOINT="http://localhost:11434" + diff --git a/apps/api/package.json b/apps/api/package.json index b379c88..104ae2c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "dotenv": "^16.4.5", "fastify": "^4.26.2", "fastify-plugin": "^4.5.1", + "ioredis": "4.28.5", "jsonwebtoken": "^9.0.2", "pino": "^8.19.0", "zod": "^3.22.4" @@ -31,7 +32,6 @@ "@types/ioredis": "4.28.10", "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.11.24", - "ioredis": "4.28.5", "pino-pretty": "^10.3.1", "prisma": "^4.16.2", "tsx": "^4.7.1", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6cb5dd0..4d41cb9 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,7 +6,7 @@ import swaggerUi from '@fastify/swagger-ui'; import prismaPlugin from './plugins/prisma'; import observabilityPlugin from './plugins/observability'; -import rateLimitPlugin from './plugins/rate-limit'; +import rateLimitPlugin, { RateLimitPluginOptions } from './plugins/rate-limit'; import { errorHandler } from './errors/global-handler'; import { env } from './config/env'; @@ -24,7 +24,11 @@ import { criticRoutes } from './modules/critic/critic.routes'; import { integrationsRoutes } from './modules/integrations/integrations.routes'; import { focusReportRoutes } from './modules/focus-report/focus-report.routes'; -export function buildApp(): FastifyInstance { +export interface AppOptions { + rateLimit?: RateLimitPluginOptions; +} + +export function buildApp(options?: AppOptions): FastifyInstance { const loggerConfig = { development: { transport: { @@ -46,6 +50,7 @@ export function buildApp(): FastifyInstance { const app = Fastify({ logger: loggerConfig[currentEnv], + trustProxy: env.TRUST_PROXY, }); // Global Error Handler @@ -55,11 +60,16 @@ export function buildApp(): FastifyInstance { app.register(observabilityPlugin); // Rate Limiting - app.register(rateLimitPlugin); + app.register(rateLimitPlugin, options?.rateLimit); // Security & Utility Plugins app.register(cors, { - origin: env.NODE_ENV === 'production' ? (process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : false) : true, + origin: + env.NODE_ENV === 'production' + ? process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',') + : false + : true, }); app.register(helmet, { contentSecurityPolicy: false }); // Disable CSP for Swagger UI compatibility diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 7b2a61b..f6234ae 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -11,7 +11,7 @@ const envSchema = z.object({ DATABASE_URL: z.string().url().default('postgresql://postgres:postgres@localhost:5432/interrupt_iq?schema=public'), REDIS_URL: z.string().url().default('redis://localhost:6379'), JWT_SECRET: z.string().default('supersecret-interrupt-iq-key-change-in-prod'), - + // Integrations OAuth credentials SLACK_CLIENT_ID: z.string().optional(), SLACK_CLIENT_SECRET: z.string().optional(), @@ -19,6 +19,48 @@ const envSchema = z.object({ GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), APP_BASE_URL: z.string().url().default('http://localhost:3001'), + + // Rate Limiting Configuration + RATE_LIMIT_ENABLED: z + .preprocess((val) => { + if (typeof val === 'string') return val.toLowerCase() === 'true' || val === '1'; + if (typeof val === 'boolean') return val; + return true; + }, z.boolean()) + .default(true), + RATE_LIMIT_GLOBAL_MAX: z + .preprocess((val) => parseInt(val as string, 10), z.number().int().positive()) + .default(100), + RATE_LIMIT_GLOBAL_WINDOW_MS: z + .preprocess((val) => parseInt(val as string, 10), z.number().int().positive()) + .default(60000), + RATE_LIMIT_AUTH_MAX: z + .preprocess((val) => parseInt(val as string, 10), z.number().int().positive()) + .default(10), + RATE_LIMIT_EVENTS_MAX: z + .preprocess((val) => parseInt(val as string, 10), z.number().int().positive()) + .default(120), + RATE_LIMIT_DECISION_MAX: z + .preprocess((val) => parseInt(val as string, 10), z.number().int().positive()) + .default(60), + RATE_LIMIT_RETRIEVAL_MAX: z + .preprocess((val) => parseInt(val as string, 10), z.number().int().positive()) + .default(20), + RATE_LIMIT_CRITIC_MAX: z + .preprocess((val) => parseInt(val as string, 10), z.number().int().positive()) + .default(10), + + // Reverse proxy trust (false for direct access; true or CIDR string if behind trusted reverse proxy) + TRUST_PROXY: z + .preprocess( + (val) => { + if (val === 'true' || val === true) return true; + if (val === 'false' || val === false || val === undefined) return false; + return val; + }, + z.union([z.boolean(), z.string()]) + ) + .default(false), }); const parsed = envSchema.safeParse(process.env); diff --git a/apps/api/src/plugins/rate-limit.ts b/apps/api/src/plugins/rate-limit.ts index ced6e46..d0c52ad 100644 --- a/apps/api/src/plugins/rate-limit.ts +++ b/apps/api/src/plugins/rate-limit.ts @@ -1,49 +1,371 @@ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import fp from 'fastify-plugin'; +import { env } from '../config/env'; +import { cacheService } from '../services/cache.service'; import { TooManyRequestsError } from '../errors/app-error'; -async function rateLimitPlugin(fastify: FastifyInstance) { - const windowMs = 60 * 1000; // 1 minute window - const limit = 300; // max 300 requests per minute per IP for simulator readiness - const hits = new Map(); +export interface RateLimitPluginOptions { + enabled?: boolean; + globalMax?: number; + globalWindowMs?: number; + authMax?: number; + eventsMax?: number; + decisionMax?: number; + retrievalMax?: number; + criticMax?: number; + maxMemoryKeys?: number; +} + +export type PolicyGroup = + 'exempt' | 'auth' | 'critic' | 'retrieval' | 'decision' | 'events' | 'global'; + +export interface RoutePolicy { + group: PolicyGroup; + max: number; + windowMs: number; + isExempt: boolean; +} + +export interface PolicyLimits { + globalMax: number; + globalWindowMs: number; + authMax: number; + eventsMax: number; + decisionMax: number; + retrievalMax: number; + criticMax: number; +} + +/** + * Normalizes IPv4 and IPv6 string representations safely. + */ +export function normalizeIp(rawIp?: string): string { + if (!rawIp) return 'unknown'; + let ip = rawIp.trim(); + if (ip.startsWith('::ffff:')) { + ip = ip.substring(7); + } + if (ip === '::1') { + ip = '127.0.0.1'; + } + return ip; +} + +/** + * Resolves route policies without hardcoding limits inside route handlers. + * Matches HTTP method and URL path to specific sensitivity tiers. + */ +export function resolveRoutePolicy(method: string, url: string, limits: PolicyLimits): RoutePolicy { + const path = url.split('?')[0]; + + // Exempt routes: health checks, probes, and Swagger documentation + if ( + path === '/health' || + path.startsWith('/health') || + path === '/ready' || + path.startsWith('/ready') || + path === '/live' || + path.startsWith('/live') || + path.startsWith('/documentation') + ) { + return { + group: 'exempt', + max: 0, + windowMs: limits.globalWindowMs, + isExempt: true, + }; + } + + // Auth endpoints (sensitive to brute-force and credential stuffing) + if (path.startsWith('/api/v1/auth')) { + return { + group: 'auth', + max: limits.authMax, + windowMs: limits.globalWindowMs, + isExempt: false, + }; + } + + // LLM Critic evaluations (extremely expensive computation / external API calls) + if (path.startsWith('/api/v1/critic')) { + return { + group: 'critic', + max: limits.criticMax, + windowMs: limits.globalWindowMs, + isExempt: false, + }; + } + + // Semantic retrieval and embeddings (expensive vector & database operations) + if ( + path === '/api/v1/memory/retrieve' || + path.startsWith('/api/v1/memory/retrieve') || + path === '/api/v1/memory/embed' || + path.startsWith('/api/v1/memory/embed') || + path === '/api/v1/memory/reindex' || + path.startsWith('/api/v1/memory/reindex') + ) { + return { + group: 'retrieval', + max: limits.retrievalMax, + windowMs: limits.globalWindowMs, + isExempt: false, + }; + } + + // Decision engine evaluations (rules & heuristic pipelines) + if (path.startsWith('/api/v1/decision')) { + return { + group: 'decision', + max: limits.decisionMax, + windowMs: limits.globalWindowMs, + isExempt: false, + }; + } + + // Events ingestion and history (high-throughput ingest pipeline) + if (path.startsWith('/api/v1/events')) { + return { + group: 'events', + max: limits.eventsMax, + windowMs: limits.globalWindowMs, + isExempt: false, + }; + } + + // Fallback global policy for other /api routes + return { + group: 'global', + max: limits.globalMax, + windowMs: limits.globalWindowMs, + isExempt: false, + }; +} + +/** + * Generates client identification keys. + * Uses authenticated user ID when available from request.user, + * otherwise falls back to normalized client IP. + */ +export function generateClientKey(request: FastifyRequest, group: string): string { + const userId = + request.user && typeof request.user.id === 'string' && request.user.id.trim().length > 0 + ? request.user.id.trim() + : null; + + if (userId) { + return `rl:user:${userId}:${group}`; + } + + const ip = normalizeIp(request.ip); + return `rl:ip:${ip}:${group}`; +} + +/** + * Atomic fixed-window rate-limiting Lua script. + * Safely initializes window expiry on count 1 without resetting active windows. + */ +export const RATE_LIMIT_LUA_SCRIPT = ` +local key = KEYS[1] +local windowMs = tonumber(ARGV[1]) + +local count = redis.call("INCR", key) +if count == 1 then + redis.call("PEXPIRE", key, windowMs) +end + +local ttl = redis.call("PTTL", key) +if ttl == -1 then + redis.call("PEXPIRE", key, windowMs) + ttl = windowMs +end + +return { count, ttl } +`; + +interface MemoryCounter { + count: number; + resetTime: number; // Unix epoch ms +} + +/** + * Bounded in-memory fallback store with active sweep and capacity eviction. + * Used when Redis is unavailable or disconnected during development. + * + * NOTE: In-memory fallback is node-local and therefore not globally distributed + * across multiple API instances. + */ +export class BoundedMemoryStore { + private readonly maxKeys: number; + private readonly store: Map; + private cleanupTimer: NodeJS.Timeout | null = null; + + constructor(maxKeys: number = 10000, cleanupIntervalMs: number = 30000) { + this.maxKeys = maxKeys; + this.store = new Map(); + + this.cleanupTimer = setInterval(() => { + this.cleanup(); + }, cleanupIntervalMs); + + if (this.cleanupTimer && typeof this.cleanupTimer.unref === 'function') { + this.cleanupTimer.unref(); + } + } + + public increment(key: string, windowMs: number): { count: number; ttlMs: number } { + const now = Date.now(); + const entry = this.store.get(key); + + if (!entry || now >= entry.resetTime) { + if (this.store.size >= this.maxKeys) { + this.evictOne(); + } + const resetTime = now + windowMs; + this.store.set(key, { count: 1, resetTime }); + return { count: 1, ttlMs: windowMs }; + } + + entry.count += 1; + const ttlMs = Math.max(0, entry.resetTime - now); + return { count: entry.count, ttlMs }; + } + + public cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.store.entries()) { + if (now >= entry.resetTime) { + this.store.delete(key); + } + } + } - // Periodically sweep expired clients to prevent memory leaks - const interval = setInterval(() => { + private evictOne(): void { const now = Date.now(); - for (const [ip, info] of hits.entries()) { - if (now > info.resetTime) { - hits.delete(ip); + for (const [key, entry] of this.store.entries()) { + if (now >= entry.resetTime) { + this.store.delete(key); + return; } } - }, 60 * 1000); + const oldestKey = this.store.keys().next().value; + if (oldestKey) { + this.store.delete(oldestKey); + } + } - // Stop interval on Fastify shutdown to prevent hanging processes in tests + public size(): number { + return this.store.size; + } + + public reset(): void { + this.store.clear(); + } + + public close(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + this.store.clear(); + } +} + +// Global bounded in-memory fallback instance +export const memoryStore = new BoundedMemoryStore(); + +let lastRedisErrorLog = 0; +const REDIS_ERROR_LOG_THROTTLE_MS = 60000; + +async function rateLimitPlugin(fastify: FastifyInstance, opts: RateLimitPluginOptions = {}) { + const isEnabled = opts.enabled ?? env.RATE_LIMIT_ENABLED; + + const limits: PolicyLimits = { + globalMax: opts.globalMax ?? env.RATE_LIMIT_GLOBAL_MAX, + globalWindowMs: opts.globalWindowMs ?? env.RATE_LIMIT_GLOBAL_WINDOW_MS, + authMax: opts.authMax ?? env.RATE_LIMIT_AUTH_MAX, + eventsMax: opts.eventsMax ?? env.RATE_LIMIT_EVENTS_MAX, + decisionMax: opts.decisionMax ?? env.RATE_LIMIT_DECISION_MAX, + retrievalMax: opts.retrievalMax ?? env.RATE_LIMIT_RETRIEVAL_MAX, + criticMax: opts.criticMax ?? env.RATE_LIMIT_CRITIC_MAX, + }; + + // Cleanup on Fastify server shutdown to prevent hanging processes fastify.addHook('onClose', async () => { - clearInterval(interval); + memoryStore.close(); }); - fastify.addHook('preHandler', async (request) => { - if (process.env.NODE_ENV === 'test' || request.url.startsWith('/documentation') || request.url.startsWith('/health')) { + // Execute in preHandler hook so authenticate (in preValidation) has already attached request.user + fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => { + if (!isEnabled) { return; } - const ip = request.ip || 'unknown'; - const now = Date.now(); - const info = hits.get(ip); - - if (!info || now > info.resetTime) { - hits.set(ip, { count: 1, resetTime: now + windowMs }); + const policy = resolveRoutePolicy(request.method, request.url, limits); + if (policy.isExempt) { return; } - if (info.count >= limit) { - throw new TooManyRequestsError('Rate limit exceeded. Please try again later.'); + const key = generateClientKey(request, policy.group); + const windowMs = policy.windowMs; + const limit = policy.max; + + let count = 0; + let ttlMs = windowMs; + + const redis = cacheService.getRedisClient(); + + if (redis) { + try { + const result = (await redis.eval(RATE_LIMIT_LUA_SCRIPT, 1, key, windowMs.toString())) as [ + number, + number, + ]; + + count = Number(result[0]); + ttlMs = Math.max(0, Number(result[1])); + } catch (err: any) { + const now = Date.now(); + if (now - lastRedisErrorLog > REDIS_ERROR_LOG_THROTTLE_MS) { + lastRedisErrorLog = now; + fastify.log.warn( + { error: err.message, key }, + '⚠️ Redis rate-limit command failed. Gracefully falling back to bounded in-memory limiting.' + ); + } + // Fall back to in-memory counter if Redis fails + const memResult = memoryStore.increment(key, windowMs); + count = memResult.count; + ttlMs = memResult.ttlMs; + } + } else { + // In-memory fallback during development when Redis is unavailable or unconfigured + const memResult = memoryStore.increment(key, windowMs); + count = memResult.count; + ttlMs = memResult.ttlMs; } - info.count += 1; + const remaining = Math.max(0, limit - count); + const resetSeconds = Math.max(1, Math.ceil(ttlMs / 1000)); + + // Standard rate-limit headers + reply.header('RateLimit-Limit', limit); + reply.header('RateLimit-Remaining', remaining); + reply.header('RateLimit-Reset', resetSeconds); + + // Enforce throttling when limit is exceeded + if (count > limit) { + reply.header('Retry-After', resetSeconds); + const error = new TooManyRequestsError( + `Rate limit exceeded for policy '${policy.group}'. Please retry after ${resetSeconds} seconds.` + ); + error.name = 'TooManyRequestsError'; + throw error; + } }); } export default fp(rateLimitPlugin, { name: 'rate-limit-plugin', + fastify: '4.x', }); diff --git a/apps/api/test/rate-limit.test.ts b/apps/api/test/rate-limit.test.ts new file mode 100644 index 0000000..de6a680 --- /dev/null +++ b/apps/api/test/rate-limit.test.ts @@ -0,0 +1,739 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { FastifyInstance } from 'fastify'; +import jwt from 'jsonwebtoken'; +import { buildApp } from '../src/app'; +import { env } from '../src/config/env'; +import { cacheService } from '../src/services/cache.service'; +import { + BoundedMemoryStore, + resolveRoutePolicy, + normalizeIp, + generateClientKey, + memoryStore, + RATE_LIMIT_LUA_SCRIPT, +} from '../src/plugins/rate-limit'; + +describe('API Rate Limiting & Abuse Protection', () => { + let app: FastifyInstance; + + beforeEach(() => { + memoryStore.reset(); + vi.restoreAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + // ========================================== + // UNIT TESTS: Helpers, Keys & Bounded Memory + // ========================================== + + describe('IP Normalization & Key Generation', () => { + it('should normalize IPv4-mapped IPv6 and localhost representations', () => { + expect(normalizeIp('::ffff:192.168.1.1')).toBe('192.168.1.1'); + expect(normalizeIp('::1')).toBe('127.0.0.1'); + expect(normalizeIp(' 10.0.0.1 ')).toBe('10.0.0.1'); + expect(normalizeIp(undefined)).toBe('unknown'); + }); + + it('should generate anonymous IP key when unauthenticated', () => { + const req: any = { ip: '192.168.1.50' }; + const key = generateClientKey(req, 'auth'); + expect(key).toBe('rl:ip:192.168.1.50:auth'); + }); + + it('should generate authenticated user key when request.user is set', () => { + const req: any = { + ip: '192.168.1.50', + user: { id: 'usr_abc123', email: 'user@example.com', name: 'User' }, + }; + const key = generateClientKey(req, 'events'); + expect(key).toBe('rl:user:usr_abc123:events'); + }); + + it('should not allow unauthenticated request with empty user object to claim user key', () => { + const req: any = { ip: '10.0.0.5', user: { id: ' ' } }; + const key = generateClientKey(req, 'decision'); + expect(key).toBe('rl:ip:10.0.0.5:decision'); + }); + }); + + describe('Route Policy Resolver', () => { + const defaultLimits = { + globalMax: 100, + globalWindowMs: 60000, + authMax: 10, + eventsMax: 120, + decisionMax: 60, + retrievalMax: 20, + criticMax: 10, + }; + + it('should classify probe and documentation routes as exempt', () => { + expect(resolveRoutePolicy('GET', '/health', defaultLimits).isExempt).toBe(true); + expect(resolveRoutePolicy('GET', '/ready', defaultLimits).isExempt).toBe(true); + expect(resolveRoutePolicy('GET', '/live', defaultLimits).isExempt).toBe(true); + expect( + resolveRoutePolicy('GET', '/documentation/static/index.html', defaultLimits).isExempt + ).toBe(true); + }); + + it('should resolve auth endpoints to auth policy', () => { + const policy = resolveRoutePolicy('POST', '/api/v1/auth/login', defaultLimits); + expect(policy.group).toBe('auth'); + expect(policy.max).toBe(10); + expect(policy.isExempt).toBe(false); + }); + + it('should resolve events endpoints to events policy', () => { + const policy = resolveRoutePolicy('POST', '/api/v1/events', defaultLimits); + expect(policy.group).toBe('events'); + expect(policy.max).toBe(120); + }); + + it('should resolve decision endpoints to decision policy (singular /decision)', () => { + const policy = resolveRoutePolicy('POST', '/api/v1/decision/evaluate', defaultLimits); + expect(policy.group).toBe('decision'); + expect(policy.max).toBe(60); + }); + + it('should resolve retrieval endpoints to retrieval policy', () => { + const retrievePolicy = resolveRoutePolicy('POST', '/api/v1/memory/retrieve', defaultLimits); + expect(retrievePolicy.group).toBe('retrieval'); + expect(retrievePolicy.max).toBe(20); + + const embedPolicy = resolveRoutePolicy('POST', '/api/v1/memory/embed', defaultLimits); + expect(embedPolicy.group).toBe('retrieval'); + expect(embedPolicy.max).toBe(20); + }); + + it('should resolve critic endpoints to critic policy', () => { + const policy = resolveRoutePolicy('POST', '/api/v1/critic/evaluate', defaultLimits); + expect(policy.group).toBe('critic'); + expect(policy.max).toBe(10); + }); + + it('should resolve general API routes to global policy', () => { + const policy = resolveRoutePolicy('GET', '/api/v1/users/me', defaultLimits); + expect(policy.group).toBe('global'); + expect(policy.max).toBe(100); + }); + }); + + describe('BoundedMemoryStore', () => { + it('should enforce limits and track TTL correctly', () => { + const store = new BoundedMemoryStore(100, 60000); + const res1 = store.increment('test-key', 5000); + expect(res1.count).toBe(1); + expect(res1.ttlMs).toBeLessThanOrEqual(5000); + + const res2 = store.increment('test-key', 5000); + expect(res2.count).toBe(2); + + store.close(); + }); + + it('should evict keys when reaching capacity bound', () => { + const capacity = 3; + const store = new BoundedMemoryStore(capacity, 60000); + + store.increment('key1', 10000); + store.increment('key2', 10000); + store.increment('key3', 10000); + expect(store.size()).toBe(3); + + // Adding a 4th key triggers bounded eviction + store.increment('key4', 10000); + expect(store.size()).toBe(capacity); + + store.close(); + }); + + it('should sweep expired keys', () => { + const store = new BoundedMemoryStore(100, 60000); + store.increment('short-key', -10); // Expired immediately + expect(store.size()).toBe(1); + + store.cleanup(); + expect(store.size()).toBe(0); + + store.close(); + }); + }); + + // ========================================== + // INTEGRATION TESTS: HTTP & Policies + // ========================================== + + describe('HTTP Rate Limiting Integration', () => { + function setupPrismaMock(fastify: FastifyInstance) { + fastify.prisma.$executeRaw = async () => 1; + fastify.prisma.user.findUnique = vi.fn().mockImplementation(async ({ where }: any) => { + return { + id: where.id, + email: `${where.id}@example.com`, + name: `User ${where.id}`, + deletedAt: null, + }; + }); + fastify.prisma.event.create = vi.fn().mockImplementation(async ({ data }: any) => ({ + id: 'event-id-123', + ...data, + createdAt: new Date(), + timestamp: new Date(), + })); + } + + it('1. should not enforce limits when RATE_LIMIT_ENABLED is false', async () => { + app = buildApp({ + rateLimit: { + enabled: false, + globalMax: 2, + }, + }); + await app.ready(); + setupPrismaMock(app); + + for (let i = 0; i < 5; i++) { + const res = await app.inject({ method: 'GET', url: '/ready' }); + expect(res.statusCode).toBe(200); + expect(res.headers['ratelimit-limit']).toBeUndefined(); + } + }); + + it('2 & 3. should allow requests below and at limit, and set standard headers', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 2, + }, + }); + await app.ready(); + setupPrismaMock(app); + + // Request 1: below limit + const res1 = await app.inject({ + method: 'GET', + url: '/api/v1/invalid-probe-route', + }); + expect(res1.statusCode).toBe(404); // Handled by Fastify router, but preHandler ran + expect(res1.headers['ratelimit-limit']).toBe('2'); + expect(res1.headers['ratelimit-remaining']).toBe('1'); + expect(Number(res1.headers['ratelimit-reset'])).toBeGreaterThanOrEqual(1); + + // Request 2: exactly at limit + const res2 = await app.inject({ + method: 'GET', + url: '/api/v1/invalid-probe-route', + }); + expect(res2.statusCode).toBe(404); + expect(res2.headers['ratelimit-remaining']).toBe('0'); + }); + + it('4 & 18 & 22. should return HTTP 429, Retry-After header, and API error format when limit exceeded', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 2, + }, + }); + await app.ready(); + setupPrismaMock(app); + + await app.inject({ method: 'GET', url: '/api/v1/invalid-probe-route' }); + await app.inject({ method: 'GET', url: '/api/v1/invalid-probe-route' }); + + // Request 3: exceeds limit + const res3 = await app.inject({ + method: 'GET', + url: '/api/v1/invalid-probe-route', + }); + expect(res3.statusCode).toBe(429); + expect(res3.headers['retry-after']).toBeDefined(); + expect(Number(res3.headers['retry-after'])).toBeGreaterThanOrEqual(1); + + const body = JSON.parse(res3.body); + expect(body.success).toBe(false); + expect(body.error).toBe('TooManyRequestsError'); + expect(body.message).toContain('Rate limit exceeded'); + }); + + it('5. should reset quota after window expires', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 1, + globalWindowMs: 50, // 50ms test window + }, + }); + await app.ready(); + setupPrismaMock(app); + + const res1 = await app.inject({ method: 'GET', url: '/api/v1/test-expire' }); + expect(res1.statusCode).toBe(404); + + const res2 = await app.inject({ method: 'GET', url: '/api/v1/test-expire' }); + expect(res2.statusCode).toBe(429); + + // Wait for window to expire + await new Promise((r) => setTimeout(r, 60)); + + const res3 = await app.inject({ method: 'GET', url: '/api/v1/test-expire' }); + expect(res3.statusCode).toBe(404); // Quota restored + }); + + it('6. should isolate quotas for different client IPs', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 1, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const resIp1 = await app.inject({ + method: 'GET', + url: '/api/v1/test-ip', + remoteAddress: '10.0.0.1', + }); + expect(resIp1.statusCode).toBe(404); + + // Second request from IP 1 is blocked + const resIp1Blocked = await app.inject({ + method: 'GET', + url: '/api/v1/test-ip', + remoteAddress: '10.0.0.1', + }); + expect(resIp1Blocked.statusCode).toBe(429); + + // Request from IP 2 is permitted + const resIp2 = await app.inject({ + method: 'GET', + url: '/api/v1/test-ip', + remoteAddress: '10.0.0.2', + }); + expect(resIp2.statusCode).toBe(404); + }); + + it('7 & 8. should isolate quotas by authenticated user ID', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 1, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const tokenUserA = jwt.sign({ userId: 'user-A', email: 'a@example.com' }, env.JWT_SECRET); + const tokenUserB = jwt.sign({ userId: 'user-B', email: 'b@example.com' }, env.JWT_SECRET); + + // User A request 1 + const resA1 = await app.inject({ + method: 'GET', + url: '/api/v1/users/me', + headers: { authorization: `Bearer ${tokenUserA}` }, + }); + expect(resA1.statusCode).toBe(200); + + // User A request 2 is throttled + const resA2 = await app.inject({ + method: 'GET', + url: '/api/v1/users/me', + headers: { authorization: `Bearer ${tokenUserA}` }, + }); + expect(resA2.statusCode).toBe(429); + + // User B request 1 is permitted + const resB1 = await app.inject({ + method: 'GET', + url: '/api/v1/users/me', + headers: { authorization: `Bearer ${tokenUserB}` }, + }); + expect(resB1.statusCode).toBe(200); + }); + + it('9. should throttle same authenticated user across different IPs under same user quota', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 1, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const tokenUser = jwt.sign( + { userId: 'roaming-user', email: 'roam@example.com' }, + env.JWT_SECRET + ); + + // From IP 1 + const res1 = await app.inject({ + method: 'GET', + url: '/api/v1/users/me', + headers: { authorization: `Bearer ${tokenUser}` }, + remoteAddress: '172.16.0.1', + }); + expect(res1.statusCode).toBe(200); + + // From IP 2 -> throttled because key is user-scoped + const res2 = await app.inject({ + method: 'GET', + url: '/api/v1/users/me', + headers: { authorization: `Bearer ${tokenUser}` }, + remoteAddress: '172.16.0.2', + }); + expect(res2.statusCode).toBe(429); + }); + + it('10. should apply auth policy to /api/v1/auth endpoints', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + authMax: 2, + globalMax: 10, + }, + }); + await app.ready(); + setupPrismaMock(app); + + // /api/v1/auth/logout accepts post + const res1 = await app.inject({ method: 'POST', url: '/api/v1/auth/logout' }); + expect(res1.statusCode).toBe(200); + expect(res1.headers['ratelimit-limit']).toBe('2'); + + await app.inject({ method: 'POST', url: '/api/v1/auth/logout' }); + + // Exceeds auth limit + const res3 = await app.inject({ method: 'POST', url: '/api/v1/auth/logout' }); + expect(res3.statusCode).toBe(429); + expect(JSON.parse(res3.body).message).toContain("policy 'auth'"); + }); + + it('11. should apply events policy to /api/v1/events endpoints', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + eventsMax: 1, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const token = jwt.sign( + { userId: 'events-user', email: 'events@example.com' }, + env.JWT_SECRET + ); + + const res1 = await app.inject({ + method: 'POST', + url: '/api/v1/events', + headers: { authorization: `Bearer ${token}` }, + payload: { + source: 'github', + sender: 'octocat', + title: 'PR merged', + body: 'feat: add rate limiting', + category: 'development', + }, + }); + expect(res1.statusCode).toBe(201); + expect(res1.headers['ratelimit-limit']).toBe('1'); + + const res2 = await app.inject({ + method: 'POST', + url: '/api/v1/events', + headers: { authorization: `Bearer ${token}` }, + payload: { + source: 'github', + sender: 'octocat', + title: 'PR merged', + body: 'feat: add rate limiting', + category: 'development', + }, + }); + expect(res2.statusCode).toBe(429); + expect(JSON.parse(res2.body).message).toContain("policy 'events'"); + }); + + it('12. should apply decision policy to /api/v1/decision endpoints', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + decisionMax: 1, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const token = jwt.sign({ userId: 'decision-user', email: 'dec@example.com' }, env.JWT_SECRET); + + // GET /api/v1/decision/history + app.prisma.decision.findMany = vi.fn().mockResolvedValue([]) as any; + app.prisma.decision.count = vi.fn().mockResolvedValue(0) as any; + + const res1 = await app.inject({ + method: 'GET', + url: '/api/v1/decision/history', + headers: { authorization: `Bearer ${token}` }, + }); + expect(res1.headers['ratelimit-limit']).toBe('1'); + + const res2 = await app.inject({ + method: 'GET', + url: '/api/v1/decision/history', + headers: { authorization: `Bearer ${token}` }, + }); + expect(res2.statusCode).toBe(429); + expect(JSON.parse(res2.body).message).toContain("policy 'decision'"); + }); + + it('13. should apply retrieval policy to /api/v1/memory/retrieve', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + retrievalMax: 1, + globalMax: 10, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const token = jwt.sign( + { userId: 'retrieval-user', email: 'ret@example.com' }, + env.JWT_SECRET + ); + + // Mock retrieval service + app.prisma.memoryEpisode = { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + } as any; + + const res1 = await app.inject({ + method: 'POST', + url: '/api/v1/memory/retrieve', + headers: { authorization: `Bearer ${token}` }, + payload: { keyword: 'test' }, + }); + expect(res1.headers['ratelimit-limit']).toBe('1'); + + const res2 = await app.inject({ + method: 'POST', + url: '/api/v1/memory/retrieve', + headers: { authorization: `Bearer ${token}` }, + payload: { keyword: 'test' }, + }); + expect(res2.statusCode).toBe(429); + expect(JSON.parse(res2.body).message).toContain("policy 'retrieval'"); + }); + + it('14. should apply critic policy to /api/v1/critic/evaluate', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + criticMax: 1, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const token = jwt.sign( + { userId: 'critic-user', email: 'critic@example.com' }, + env.JWT_SECRET + ); + + app.prisma.memoryEpisode = { + findFirst: vi.fn().mockResolvedValue({ + id: '550e8400-e29b-41d4-a716-446655440000', + userId: 'critic-user', + decisionType: 'NOTIFY NOW', + explanation: 'test', + outcome: 'ACCEPTED', + createdAt: new Date(), + }), + } as any; + app.prisma.criticEvaluation = { + create: vi.fn().mockResolvedValue({ + id: 'eval-1', + userId: 'critic-user', + episodeId: '550e8400-e29b-41d4-a716-446655440000', + verdict: 'SOUND', + confidence: 0.9, + explanation: 'ok', + strengths: [], + weaknesses: [], + suggestedRuleChanges: [], + rawPrompt: '', + rawResponse: '', + createdAt: new Date().toISOString(), + }), + } as any; + + const res1 = await app.inject({ + method: 'POST', + url: '/api/v1/critic/evaluate', + headers: { authorization: `Bearer ${token}` }, + payload: { + episodeId: '550e8400-e29b-41d4-a716-446655440000', + provider: 'mock', + }, + }); + expect(res1.headers['ratelimit-limit']).toBe('1'); + + const res2 = await app.inject({ + method: 'POST', + url: '/api/v1/critic/evaluate', + headers: { authorization: `Bearer ${token}` }, + payload: { + episodeId: '550e8400-e29b-41d4-a716-446655440000', + provider: 'mock', + }, + }); + expect(res2.statusCode).toBe(429); + expect(JSON.parse(res2.body).message).toContain("policy 'critic'"); + }); + + it('16 & 17. should exempt /health, /ready, /live, and /documentation without rate limit headers', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 1, + }, + }); + await app.ready(); + setupPrismaMock(app); + + for (let i = 0; i < 5; i++) { + const hRes = await app.inject({ method: 'GET', url: '/health' }); + expect(hRes.statusCode).toBe(200); + expect(hRes.headers['ratelimit-limit']).toBeUndefined(); + + const rRes = await app.inject({ method: 'GET', url: '/ready' }); + expect(rRes.statusCode).toBe(200); + + const lRes = await app.inject({ method: 'GET', url: '/live' }); + expect(lRes.statusCode).toBe(200); + + const docRes = await app.inject({ method: 'GET', url: '/documentation/static/index.html' }); + expect(docRes.statusCode).toBe(200); + } + }); + + // ========================================== + // REDIS & RESILIENT FALLBACK TESTS + // ========================================== + + it('23. should execute Redis Lua script when Redis is healthy', async () => { + const mockEval = vi.fn().mockResolvedValue([1, 55000]); + const mockRedis = { eval: mockEval }; + vi.spyOn(cacheService, 'getRedisClient').mockReturnValue(mockRedis as any); + + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 5, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const res = await app.inject({ method: 'GET', url: '/api/v1/test-redis' }); + expect(res.statusCode).toBe(404); + expect(mockEval).toHaveBeenCalledWith(RATE_LIMIT_LUA_SCRIPT, 1, expect.any(String), '60000'); + expect(res.headers['ratelimit-remaining']).toBe('4'); + }); + + it('24 & 25. should gracefully fall back to in-memory limiting when Redis is unavailable', async () => { + vi.spyOn(cacheService, 'getRedisClient').mockReturnValue(null); + + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 2, + }, + }); + await app.ready(); + setupPrismaMock(app); + + const res1 = await app.inject({ method: 'GET', url: '/api/v1/mem-fallback' }); + expect(res1.statusCode).toBe(404); + expect(res1.headers['ratelimit-remaining']).toBe('1'); + + const res2 = await app.inject({ method: 'GET', url: '/api/v1/mem-fallback' }); + expect(res2.statusCode).toBe(404); + expect(res2.headers['ratelimit-remaining']).toBe('0'); + + const res3 = await app.inject({ method: 'GET', url: '/api/v1/mem-fallback' }); + expect(res3.statusCode).toBe(429); // In-memory fallback still strictly throttles + }); + + it('26. should fall back to memory without failing request if Redis throws during execution', async () => { + const mockEval = vi.fn().mockRejectedValue(new Error('Connection timed out')); + const mockRedis = { eval: mockEval }; + vi.spyOn(cacheService, 'getRedisClient').mockReturnValue(mockRedis as any); + + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 2, + }, + }); + await app.ready(); + setupPrismaMock(app); + + // Should not throw 500; gracefully catches Redis error and uses in-memory counter + const res1 = await app.inject({ method: 'GET', url: '/api/v1/redis-fail' }); + expect(res1.statusCode).toBe(404); + expect(res1.headers['ratelimit-remaining']).toBe('1'); + + const res2 = await app.inject({ method: 'GET', url: '/api/v1/redis-fail' }); + expect(res2.statusCode).toBe(404); + + const res3 = await app.inject({ method: 'GET', url: '/api/v1/redis-fail' }); + expect(res3.statusCode).toBe(429); + }); + + it('27. should resume using Redis when Redis client recovers', async () => { + let redisClient: any = null; + vi.spyOn(cacheService, 'getRedisClient').mockImplementation(() => redisClient); + + app = buildApp({ + rateLimit: { + enabled: true, + globalMax: 5, + }, + }); + await app.ready(); + setupPrismaMock(app); + + // Step 1: Redis unavailable -> memory store used + const res1 = await app.inject({ method: 'GET', url: '/api/v1/recovery' }); + expect(res1.statusCode).toBe(404); + + // Step 2: Redis recovers + const mockEval = vi.fn().mockResolvedValue([1, 60000]); + redisClient = { eval: mockEval }; + + const res2 = await app.inject({ method: 'GET', url: '/api/v1/recovery' }); + expect(res2.statusCode).toBe(404); + expect(mockEval).toHaveBeenCalled(); + }); + + it('29. should cleanly close memoryStore interval on Fastify onClose', async () => { + app = buildApp({ + rateLimit: { enabled: true }, + }); + await app.ready(); + + const closeSpy = vi.spyOn(memoryStore, 'close'); + await app.close(); + expect(closeSpy).toHaveBeenCalled(); + }); + }); +}); From c10d0ba4a556afdd59584a3ecd56061dbaf4a4ce Mon Sep 17 00:00:00 2001 From: hrishu802 Date: Sun, 20 Sep 2026 15:20:34 +0530 Subject: [PATCH 2/3] fix(ci): synchronize pnpm lockfile with pnpm 9 --- pnpm-lock.yaml | 166 +++++++------------------------------------------ 1 file changed, 23 insertions(+), 143 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbb7601..b839703 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: fastify-plugin: specifier: ^4.5.1 version: 4.5.1 + ioredis: + specifier: 4.28.5 + version: 4.28.5 jsonwebtoken: specifier: ^9.0.2 version: 9.0.3 @@ -91,9 +94,6 @@ importers: '@types/node': specifier: ^20.11.24 version: 20.19.43 - ioredis: - specifier: 4.28.5 - version: 4.28.5 pino-pretty: specifier: ^10.3.1 version: 10.3.1 @@ -108,7 +108,7 @@ importers: version: 5.9.3 vitest: specifier: 0.34.6 - version: 0.34.6(lightningcss@1.33.0) + version: 0.34.6(lightningcss@1.32.0) apps/web: dependencies: @@ -120,7 +120,7 @@ importers: version: link:../../packages/ui '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.3.3(vite@4.5.2(@types/node@20.19.43)(lightningcss@1.33.0)) + version: 4.3.3(vite@4.5.2(@types/node@20.19.43)(lightningcss@1.32.0)) axios: specifier: ^1.15.2 version: 1.20.0 @@ -151,7 +151,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.7.0(vite@4.5.2(@types/node@20.19.43)(lightningcss@1.33.0)) + version: 4.7.0(vite@4.5.2(@types/node@20.19.43)(lightningcss@1.32.0)) autoprefixer: specifier: ^10.5.0 version: 10.6.1(postcss@8.5.28) @@ -166,10 +166,10 @@ importers: version: 5.9.3 vite: specifier: 4.5.2 - version: 4.5.2(@types/node@20.19.43)(lightningcss@1.33.0) + version: 4.5.2(@types/node@20.19.43)(lightningcss@1.32.0) vitest: specifier: 0.34.6 - version: 0.34.6(lightningcss@1.33.0) + version: 0.34.6(lightningcss@1.32.0) packages/ai-core: dependencies: @@ -182,7 +182,7 @@ importers: version: 5.9.3 vitest: specifier: 0.34.6 - version: 0.34.6(lightningcss@1.33.0) + version: 0.34.6(lightningcss@1.32.0) packages/embedding-engine: dependencies: @@ -195,7 +195,7 @@ importers: version: 5.9.3 vitest: specifier: 0.34.6 - version: 0.34.6(lightningcss@1.33.0) + version: 0.34.6(lightningcss@1.32.0) packages/shared: devDependencies: @@ -204,7 +204,7 @@ importers: version: 5.9.3 vitest: specifier: 0.34.6 - version: 0.34.6(lightningcss@1.33.0) + version: 0.34.6(lightningcss@1.32.0) packages/ui: dependencies: @@ -226,7 +226,7 @@ importers: version: 5.9.3 vitest: specifier: 0.34.6 - version: 0.34.6(lightningcss@1.33.0) + version: 0.34.6(lightningcss@1.32.0) packages: @@ -1544,140 +1544,70 @@ packages: cpu: [arm64] os: [android] - lightningcss-android-arm64@1.33.0: - resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-arm64@1.33.0: - resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-darwin-x64@1.33.0: - resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-freebsd-x64@1.33.0: - resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm-gnueabihf@1.33.0: - resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-gnu@1.33.0: - resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.33.0: - resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-gnu@1.33.0: - resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.33.0: - resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-arm64-msvc@1.33.0: - resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss-win32-x64-msvc@1.33.0: - resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lightningcss@1.33.0: - resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} - engines: {node: '>= 12.0.0'} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2813,12 +2743,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@4.5.2(@types/node@20.19.43)(lightningcss@1.33.0))': + '@tailwindcss/vite@4.3.3(vite@4.5.2(@types/node@20.19.43)(lightningcss@1.32.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 4.5.2(@types/node@20.19.43)(lightningcss@1.33.0) + vite: 4.5.2(@types/node@20.19.43)(lightningcss@1.32.0) '@types/babel__core@7.20.5': dependencies: @@ -2900,7 +2830,7 @@ snapshots: '@ungap/structured-clone@1.4.0': {} - '@vitejs/plugin-react@4.7.0(vite@4.5.2(@types/node@20.19.43)(lightningcss@1.33.0))': + '@vitejs/plugin-react@4.7.0(vite@4.5.2(@types/node@20.19.43)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -2908,7 +2838,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 4.5.2(@types/node@20.19.43)(lightningcss@1.33.0) + vite: 4.5.2(@types/node@20.19.43)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color @@ -3731,69 +3661,36 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true - lightningcss-android-arm64@1.33.0: - optional: true - lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.33.0: - optional: true - lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-darwin-x64@1.33.0: - optional: true - lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.33.0: - optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.33.0: - optional: true - lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.33.0: - optional: true - lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.33.0: - optional: true - lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.33.0: - optional: true - lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-linux-x64-musl@1.33.0: - optional: true - lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.33.0: - optional: true - lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.33.0: - optional: true - lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -3810,23 +3707,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lightningcss@1.33.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.33.0 - lightningcss-darwin-arm64: 1.33.0 - lightningcss-darwin-x64: 1.33.0 - lightningcss-freebsd-x64: 1.33.0 - lightningcss-linux-arm-gnueabihf: 1.33.0 - lightningcss-linux-arm64-gnu: 1.33.0 - lightningcss-linux-arm64-musl: 1.33.0 - lightningcss-linux-x64-gnu: 1.33.0 - lightningcss-linux-x64-musl: 1.33.0 - lightningcss-win32-arm64-msvc: 1.33.0 - lightningcss-win32-x64-msvc: 1.33.0 - optional: true - lilconfig@3.1.3: {} lint-staged@15.5.2: @@ -4391,14 +4271,14 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@0.34.6(@types/node@20.19.43)(lightningcss@1.33.0): + vite-node@0.34.6(@types/node@20.19.43)(lightningcss@1.32.0): dependencies: cac: 6.7.14 debug: 4.4.3 mlly: 1.8.2 pathe: 1.1.2 picocolors: 1.1.1 - vite: 4.5.2(@types/node@20.19.43)(lightningcss@1.33.0) + vite: 4.5.2(@types/node@20.19.43)(lightningcss@1.32.0) transitivePeerDependencies: - '@types/node' - less @@ -4409,7 +4289,7 @@ snapshots: - supports-color - terser - vite@4.5.2(@types/node@20.19.43)(lightningcss@1.33.0): + vite@4.5.2(@types/node@20.19.43)(lightningcss@1.32.0): dependencies: esbuild: 0.18.20 postcss: 8.5.28 @@ -4417,9 +4297,9 @@ snapshots: optionalDependencies: '@types/node': 20.19.43 fsevents: 2.3.3 - lightningcss: 1.33.0 + lightningcss: 1.32.0 - vitest@0.34.6(lightningcss@1.33.0): + vitest@0.34.6(lightningcss@1.32.0): dependencies: '@types/chai': 4.3.20 '@types/chai-subset': 1.3.6(@types/chai@4.3.20) @@ -4442,8 +4322,8 @@ snapshots: strip-literal: 1.3.0 tinybench: 2.9.0 tinypool: 0.7.0 - vite: 4.5.2(@types/node@20.19.43)(lightningcss@1.33.0) - vite-node: 0.34.6(@types/node@20.19.43)(lightningcss@1.33.0) + vite: 4.5.2(@types/node@20.19.43)(lightningcss@1.32.0) + vite-node: 0.34.6(@types/node@20.19.43)(lightningcss@1.32.0) why-is-node-running: 2.3.0 transitivePeerDependencies: - less From a86478de0ae092d7bc90bd2512f87bb90a3fbae1 Mon Sep 17 00:00:00 2001 From: hrishu802 Date: Sun, 27 Sep 2026 17:18:21 +0530 Subject: [PATCH 3/3] fix(api): harden rate limit route matching and memory fallback --- apps/api/src/plugins/rate-limit.ts | 86 +++++++++++------ apps/api/test/rate-limit.test.ts | 147 +++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 27 deletions(-) diff --git a/apps/api/src/plugins/rate-limit.ts b/apps/api/src/plugins/rate-limit.ts index d0c52ad..ce2c246 100644 --- a/apps/api/src/plugins/rate-limit.ts +++ b/apps/api/src/plugins/rate-limit.ts @@ -14,10 +14,11 @@ export interface RateLimitPluginOptions { retrievalMax?: number; criticMax?: number; maxMemoryKeys?: number; + store?: BoundedMemoryStore; } export type PolicyGroup = - 'exempt' | 'auth' | 'critic' | 'retrieval' | 'decision' | 'events' | 'global'; + | 'exempt' | 'auth' | 'critic' | 'retrieval' | 'decision' | 'events' | 'global'; export interface RoutePolicy { group: PolicyGroup; @@ -51,6 +52,15 @@ export function normalizeIp(rawIp?: string): string { return ip; } +/** + * Checks whether a path matches a route prefix exactly or as a descendant path. + * Boundary-aware: matches '/prefix' and '/prefix/...', but not '/prefixSomething'. + */ +export function matchesRoutePrefix(path: string, prefix: string): boolean { + const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; + return path === normalizedPrefix || path.startsWith(`${normalizedPrefix}/`); +} + /** * Resolves route policies without hardcoding limits inside route handlers. * Matches HTTP method and URL path to specific sensitivity tiers. @@ -60,13 +70,10 @@ export function resolveRoutePolicy(method: string, url: string, limits: PolicyLi // Exempt routes: health checks, probes, and Swagger documentation if ( - path === '/health' || - path.startsWith('/health') || - path === '/ready' || - path.startsWith('/ready') || - path === '/live' || - path.startsWith('/live') || - path.startsWith('/documentation') + matchesRoutePrefix(path, '/health') || + matchesRoutePrefix(path, '/ready') || + matchesRoutePrefix(path, '/live') || + matchesRoutePrefix(path, '/documentation') ) { return { group: 'exempt', @@ -77,7 +84,7 @@ export function resolveRoutePolicy(method: string, url: string, limits: PolicyLi } // Auth endpoints (sensitive to brute-force and credential stuffing) - if (path.startsWith('/api/v1/auth')) { + if (matchesRoutePrefix(path, '/api/v1/auth')) { return { group: 'auth', max: limits.authMax, @@ -87,7 +94,7 @@ export function resolveRoutePolicy(method: string, url: string, limits: PolicyLi } // LLM Critic evaluations (extremely expensive computation / external API calls) - if (path.startsWith('/api/v1/critic')) { + if (matchesRoutePrefix(path, '/api/v1/critic')) { return { group: 'critic', max: limits.criticMax, @@ -98,12 +105,9 @@ export function resolveRoutePolicy(method: string, url: string, limits: PolicyLi // Semantic retrieval and embeddings (expensive vector & database operations) if ( - path === '/api/v1/memory/retrieve' || - path.startsWith('/api/v1/memory/retrieve') || - path === '/api/v1/memory/embed' || - path.startsWith('/api/v1/memory/embed') || - path === '/api/v1/memory/reindex' || - path.startsWith('/api/v1/memory/reindex') + matchesRoutePrefix(path, '/api/v1/memory/retrieve') || + matchesRoutePrefix(path, '/api/v1/memory/embed') || + matchesRoutePrefix(path, '/api/v1/memory/reindex') ) { return { group: 'retrieval', @@ -114,7 +118,7 @@ export function resolveRoutePolicy(method: string, url: string, limits: PolicyLi } // Decision engine evaluations (rules & heuristic pipelines) - if (path.startsWith('/api/v1/decision')) { + if (matchesRoutePrefix(path, '/api/v1/decision')) { return { group: 'decision', max: limits.decisionMax, @@ -124,7 +128,7 @@ export function resolveRoutePolicy(method: string, url: string, limits: PolicyLi } // Events ingestion and history (high-throughput ingest pipeline) - if (path.startsWith('/api/v1/events')) { + if (matchesRoutePrefix(path, '/api/v1/events')) { return { group: 'events', max: limits.eventsMax, @@ -196,20 +200,41 @@ interface MemoryCounter { * across multiple API instances. */ export class BoundedMemoryStore { - private readonly maxKeys: number; + private maxKeys: number; + private readonly defaultMaxKeys: number; + private readonly cleanupIntervalMs: number; private readonly store: Map; private cleanupTimer: NodeJS.Timeout | null = null; constructor(maxKeys: number = 10000, cleanupIntervalMs: number = 30000) { + this.defaultMaxKeys = maxKeys; this.maxKeys = maxKeys; + this.cleanupIntervalMs = cleanupIntervalMs; this.store = new Map(); - this.cleanupTimer = setInterval(() => { - this.cleanup(); - }, cleanupIntervalMs); + this.startCleanupTimer(); + } + + public startCleanupTimer(): void { + if (!this.cleanupTimer) { + this.cleanupTimer = setInterval(() => { + this.cleanup(); + }, this.cleanupIntervalMs); - if (this.cleanupTimer && typeof this.cleanupTimer.unref === 'function') { - this.cleanupTimer.unref(); + if (this.cleanupTimer && typeof this.cleanupTimer.unref === 'function') { + this.cleanupTimer.unref(); + } + } + } + + public getMaxKeys(): number { + return this.maxKeys; + } + + public setMaxKeys(maxKeys: number): void { + this.maxKeys = maxKeys; + while (this.store.size > this.maxKeys) { + this.evictOne(); } } @@ -260,6 +285,7 @@ export class BoundedMemoryStore { public reset(): void { this.store.clear(); + this.maxKeys = this.defaultMaxKeys; } public close(): void { @@ -280,6 +306,11 @@ const REDIS_ERROR_LOG_THROTTLE_MS = 60000; async function rateLimitPlugin(fastify: FastifyInstance, opts: RateLimitPluginOptions = {}) { const isEnabled = opts.enabled ?? env.RATE_LIMIT_ENABLED; + const fallbackStore = opts.store ?? memoryStore; + const maxMemoryKeys = opts.maxMemoryKeys ?? 10000; + fallbackStore.setMaxKeys(maxMemoryKeys); + fallbackStore.startCleanupTimer(); + const limits: PolicyLimits = { globalMax: opts.globalMax ?? env.RATE_LIMIT_GLOBAL_MAX, globalWindowMs: opts.globalWindowMs ?? env.RATE_LIMIT_GLOBAL_WINDOW_MS, @@ -292,7 +323,7 @@ async function rateLimitPlugin(fastify: FastifyInstance, opts: RateLimitPluginOp // Cleanup on Fastify server shutdown to prevent hanging processes fastify.addHook('onClose', async () => { - memoryStore.close(); + fallbackStore.close(); }); // Execute in preHandler hook so authenticate (in preValidation) has already attached request.user @@ -334,13 +365,13 @@ async function rateLimitPlugin(fastify: FastifyInstance, opts: RateLimitPluginOp ); } // Fall back to in-memory counter if Redis fails - const memResult = memoryStore.increment(key, windowMs); + const memResult = fallbackStore.increment(key, windowMs); count = memResult.count; ttlMs = memResult.ttlMs; } } else { // In-memory fallback during development when Redis is unavailable or unconfigured - const memResult = memoryStore.increment(key, windowMs); + const memResult = fallbackStore.increment(key, windowMs); count = memResult.count; ttlMs = memResult.ttlMs; } @@ -369,3 +400,4 @@ export default fp(rateLimitPlugin, { name: 'rate-limit-plugin', fastify: '4.x', }); + diff --git a/apps/api/test/rate-limit.test.ts b/apps/api/test/rate-limit.test.ts index de6a680..3f596a4 100644 --- a/apps/api/test/rate-limit.test.ts +++ b/apps/api/test/rate-limit.test.ts @@ -7,6 +7,7 @@ import { cacheService } from '../src/services/cache.service'; import { BoundedMemoryStore, resolveRoutePolicy, + matchesRoutePrefix, normalizeIp, generateClientKey, memoryStore, @@ -121,6 +122,61 @@ describe('API Rate Limiting & Abuse Protection', () => { expect(policy.group).toBe('global'); expect(policy.max).toBe(100); }); + + it('should enforce boundary-aware matching and reject false-prefix routes', () => { + // Helper function boundary validation + expect(matchesRoutePrefix('/api/v1/events', '/api/v1/events')).toBe(true); + expect(matchesRoutePrefix('/api/v1/events/123', '/api/v1/events')).toBe(true); + expect(matchesRoutePrefix('/api/v1/eventsSomething', '/api/v1/events')).toBe(false); + expect(matchesRoutePrefix('/api/v1/eventsExtra/xyz', '/api/v1/events')).toBe(false); + + // Exact and descendant matches for prefix route groups + expect(resolveRoutePolicy('POST', '/api/v1/events', defaultLimits).group).toBe('events'); + expect(resolveRoutePolicy('GET', '/api/v1/events/123', defaultLimits).group).toBe('events'); + expect(resolveRoutePolicy('GET', '/api/v1/events/123/status', defaultLimits).group).toBe('events'); + + // False-prefix events routes must NOT match events policy, falling through to global + expect(resolveRoutePolicy('POST', '/api/v1/eventsSomething', defaultLimits).group).toBe('global'); + expect(resolveRoutePolicy('GET', '/api/v1/eventsExtra/123', defaultLimits).group).toBe('global'); + + // Auth endpoint boundary matching + expect(resolveRoutePolicy('POST', '/api/v1/auth', defaultLimits).group).toBe('auth'); + expect(resolveRoutePolicy('POST', '/api/v1/auth/login', defaultLimits).group).toBe('auth'); + expect(resolveRoutePolicy('POST', '/api/v1/authenticate', defaultLimits).group).toBe('global'); + expect(resolveRoutePolicy('GET', '/api/v1/author/profile', defaultLimits).group).toBe('global'); + + // Decision endpoint boundary matching + expect(resolveRoutePolicy('POST', '/api/v1/decision', defaultLimits).group).toBe('decision'); + expect(resolveRoutePolicy('POST', '/api/v1/decision/evaluate', defaultLimits).group).toBe('decision'); + expect(resolveRoutePolicy('POST', '/api/v1/decisions', defaultLimits).group).toBe('global'); + expect(resolveRoutePolicy('POST', '/api/v1/decisionSomething', defaultLimits).group).toBe('global'); + + // Critic endpoint boundary matching + expect(resolveRoutePolicy('POST', '/api/v1/critic', defaultLimits).group).toBe('critic'); + expect(resolveRoutePolicy('POST', '/api/v1/critic/evaluate', defaultLimits).group).toBe('critic'); + expect(resolveRoutePolicy('POST', '/api/v1/critical', defaultLimits).group).toBe('global'); + expect(resolveRoutePolicy('POST', '/api/v1/criticExtra', defaultLimits).group).toBe('global'); + + // Retrieval endpoint boundary matching + expect(resolveRoutePolicy('POST', '/api/v1/memory/retrieve', defaultLimits).group).toBe('retrieval'); + expect(resolveRoutePolicy('POST', '/api/v1/memory/retrieve/custom', defaultLimits).group).toBe('retrieval'); + expect(resolveRoutePolicy('POST', '/api/v1/memory/retrieveSomething', defaultLimits).group).toBe('global'); + expect(resolveRoutePolicy('POST', '/api/v1/memory/embed', defaultLimits).group).toBe('retrieval'); + expect(resolveRoutePolicy('POST', '/api/v1/memory/embedding', defaultLimits).group).toBe('global'); + expect(resolveRoutePolicy('POST', '/api/v1/memory/reindex', defaultLimits).group).toBe('retrieval'); + expect(resolveRoutePolicy('POST', '/api/v1/memory/reindexing', defaultLimits).group).toBe('global'); + + // Exempt routes boundary matching + expect(resolveRoutePolicy('GET', '/health', defaultLimits).isExempt).toBe(true); + expect(resolveRoutePolicy('GET', '/health/deep', defaultLimits).isExempt).toBe(true); + expect(resolveRoutePolicy('GET', '/healthcheck', defaultLimits).isExempt).toBe(false); + expect(resolveRoutePolicy('GET', '/ready', defaultLimits).isExempt).toBe(true); + expect(resolveRoutePolicy('GET', '/readySomething', defaultLimits).isExempt).toBe(false); + expect(resolveRoutePolicy('GET', '/live', defaultLimits).isExempt).toBe(true); + expect(resolveRoutePolicy('GET', '/liveCheck', defaultLimits).isExempt).toBe(false); + expect(resolveRoutePolicy('GET', '/documentation', defaultLimits).isExempt).toBe(true); + expect(resolveRoutePolicy('GET', '/documentationExtra', defaultLimits).isExempt).toBe(false); + }); }); describe('BoundedMemoryStore', () => { @@ -162,6 +218,25 @@ describe('API Rate Limiting & Abuse Protection', () => { store.close(); }); + + it('should respect custom capacity configuration and dynamic updates', () => { + const store = new BoundedMemoryStore(5, 60000); + expect(store.getMaxKeys()).toBe(5); + + store.setMaxKeys(2); + expect(store.getMaxKeys()).toBe(2); + + store.increment('k1', 10000); + store.increment('k2', 10000); + store.increment('k3', 10000); + expect(store.size()).toBe(2); + + store.reset(); + expect(store.size()).toBe(0); + expect(store.getMaxKeys()).toBe(5); + + store.close(); + }); }); // ========================================== @@ -735,5 +810,77 @@ describe('API Rate Limiting & Abuse Protection', () => { await app.close(); expect(closeSpy).toHaveBeenCalled(); }); + + it('30. should apply boundary-aware route matching in HTTP requests and avoid false-prefix group assignment', async () => { + app = buildApp({ + rateLimit: { + enabled: true, + eventsMax: 1, + authMax: 2, + globalMax: 20, + }, + }); + await app.ready(); + setupPrismaMock(app); + + // /api/v1/eventsSomething must NOT match events policy (limit 1), should receive global policy (limit 20) + const res1 = await app.inject({ + method: 'POST', + url: '/api/v1/eventsSomething', + }); + expect(res1.headers['ratelimit-limit']).toBe('20'); + + // /api/v1/eventsExtra/123 must NOT match events policy + const res2 = await app.inject({ + method: 'POST', + url: '/api/v1/eventsExtra/123', + }); + expect(res2.headers['ratelimit-limit']).toBe('20'); + + // /api/v1/authenticate must NOT match auth policy (limit 2), should receive global policy (limit 20) + const res3 = await app.inject({ + method: 'POST', + url: '/api/v1/authenticate', + }); + expect(res3.headers['ratelimit-limit']).toBe('20'); + }); + + it('31. should honor custom maxMemoryKeys option in the memory fallback store', async () => { + // Default maxMemoryKeys is 10000 when not specified + const defaultApp = buildApp({ + rateLimit: { enabled: true }, + }); + await defaultApp.ready(); + expect(memoryStore.getMaxKeys()).toBe(10000); + await defaultApp.close(); + + // Custom maxMemoryKeys is honored + const customCapacity = 2; + app = buildApp({ + rateLimit: { + enabled: true, + maxMemoryKeys: customCapacity, + globalMax: 10, + }, + }); + await app.ready(); + expect(memoryStore.getMaxKeys()).toBe(customCapacity); + + // Force fallback in-memory store by mocking Redis client as unavailable + vi.spyOn(cacheService, 'getRedisClient').mockReturnValue(null); + + // Request from IP 1 + await app.inject({ method: 'GET', url: '/api/v1/mem-test', remoteAddress: '10.0.0.1' }); + expect(memoryStore.size()).toBe(1); + + // Request from IP 2 + await app.inject({ method: 'GET', url: '/api/v1/mem-test', remoteAddress: '10.0.0.2' }); + expect(memoryStore.size()).toBe(2); + + // Request from IP 3: should trigger capacity eviction because maxMemoryKeys is 2 + await app.inject({ method: 'GET', url: '/api/v1/mem-test', remoteAddress: '10.0.0.3' }); + expect(memoryStore.size()).toBe(customCapacity); + }); }); }); +