diff --git a/app/backend/.env.example b/app/backend/.env.example index b6b9eb9c..de538e87 100644 --- a/app/backend/.env.example +++ b/app/backend/.env.example @@ -39,3 +39,34 @@ CACHE_TTL_INTERNAL_NOTES=120 CACHE_TTL_USER_VERIFICATION_HISTORY=180 # AI task status - polled frequently CACHE_TTL_AI_TASK_STATUS=30 + +# ── Cost-Aware Rate Limiting Configuration ── +# All values are requests per minute per IP/API key +# Verification endpoints are rate-limited due to high cost (external API calls) +# Health and docs endpoints are never rate-limited + +# Strictest: OTP/email/phone operations +# These trigger external SMS/email providers and are most expensive +# Default: 20 requests per minute +THROTTLE_VERIFY_OTP_LIMIT=20 +THROTTLE_VERIFY_OTP_TTL=60 + +# Strict: General verification operations (document upload, verification enqueue) +# Default: 30 requests per minute +THROTTLE_VERIFY_LIMIT=30 +THROTTLE_VERIFY_TTL=60 + +# Moderate: General API endpoints (standard CRUD, queries, business logic) +# Default: 100 requests per minute +THROTTLE_GENERAL_LIMIT=100 +THROTTLE_GENERAL_TTL=60 + +# Search endpoints: Read-only queries +# Default: 50 requests per minute +THROTTLE_SEARCH_LIMIT=50 +THROTTLE_SEARCH_TTL=60 + +# ── Rate Limiting: Multi-Instance Support ── +# ThrottlerModule uses Redis for distributed rate limiting across multiple instances +# If Redis is unavailable, falls back to in-memory storage (not recommended for production) +# REDIS_HOST and REDIS_PORT are already configured above diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index f38816b8..eb7fa6f1 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -30,8 +30,10 @@ import { LoggingInterceptor } from './interceptors/logging.interceptor'; import { LoggerService } from './logger/logger.service'; import { AllExceptionsFilter } from './common/filters/http-exception.filter'; import { AnalyticsModule } from './analytics/analytics.module'; -import { ThrottlerModule } from '@nestjs/throttler'; +import { ThrottlerModule, ThrottlerStorageRedisService } from '@nestjs/throttler'; import { AidEscrowModule } from './onchain/aid-escrow.module'; +import { CostAwareThrottlerGuard } from './common/guards/throttle.guard'; +import { getThrottlerConfig } from './common/config/rate-limit.config'; import { ApiKeysModule } from './api-keys/api-keys.module'; import { SessionModule } from './session/session.module'; import { CommonServicesModule } from './common/services/common-services.module'; @@ -129,12 +131,56 @@ import { WebhooksModule } from 'src/webhooks.module'; }), inject: [ConfigService], }), - ThrottlerModule.forRoot([ - { - ttl: 60000, // 60 seconds window - limit: 20, // default: 20 req/min + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => { + const redisHost = configService.get('REDIS_HOST') ?? 'localhost'; + const redisPort = parseInt( + configService.get('REDIS_PORT') ?? '6379', + 10, + ); + + // Try to use Redis storage for multi-instance compatibility + // Falls back to in-memory storage if Redis is unavailable + try { + const { createClient } = await import('redis'); + const client = createClient({ + socket: { + host: redisHost, + port: redisPort, + reconnectStrategy: (retries: number) => { + if (retries > 10) { + console.warn( + 'ThrottlerModule: Failed to connect to Redis after 10 retries, falling back to in-memory storage', + ); + return new Error( + 'Max retries exceeded for ThrottlerModule Redis', + ); + } + return retries * 50; + }, + }, + }); + + await client.connect(); + + return { + throttlers: getThrottlerConfig(), + storage: new ThrottlerStorageRedisService(client), + }; + } catch (error) { + console.warn( + 'ThrottlerModule: Redis unavailable, using in-memory storage', + error instanceof Error ? error.message : error, + ); + // Fall back to in-memory storage for local development + return { + throttlers: getThrottlerConfig(), + }; + } }, - ]), + inject: [ConfigService], + }), ], controllers: [AppController], @@ -160,6 +206,10 @@ import { WebhooksModule } from 'src/webhooks.module'; provide: APP_GUARD, useClass: AdaptiveRateLimitGuard, // Adaptive rate limiting using Redis }, + { + provide: APP_GUARD, + useClass: CostAwareThrottlerGuard, // NestJS Throttler with cost-aware per-endpoint limits + }, { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor, diff --git a/app/backend/src/common/config/rate-limit.config.ts b/app/backend/src/common/config/rate-limit.config.ts new file mode 100644 index 00000000..40a409ed --- /dev/null +++ b/app/backend/src/common/config/rate-limit.config.ts @@ -0,0 +1,126 @@ +/** + * Cost-aware rate limiting configuration + * + * This module defines route-specific rate limits based on endpoint cost and resource usage: + * - Strictest: Verification endpoints (email/phone/OTP operations are costly) + * - Strict: General verification endpoints (document processing) + * - Moderate: General API endpoints (business logic operations) + * - None: Health checks, metrics, docs (no limiting needed) + * + * All values in requests per minute per IP/API key + */ + +const parseNumber = (value: string | undefined, fallback: number): number => { + if (value === undefined) { + return fallback; + } + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +/** + * Rate limit definitions per endpoint category + */ +export const RATE_LIMIT_CONFIG = { + /** + * Verification (email/phone/OTP): Strictest limit + * High cost due to external API calls (email, SMS providers) + * Default: 20 requests per minute + */ + VERIFY_OTP: { + limit: parseNumber(process.env.THROTTLE_VERIFY_OTP_LIMIT, 20), + ttl: parseNumber(process.env.THROTTLE_VERIFY_OTP_TTL, 60), // 60 seconds + }, + + /** + * Verification endpoints: Strict limit + * Document processing, verification enqueue, resend operations + * Default: 30 requests per minute + */ + VERIFY_ENDPOINT: { + limit: parseNumber(process.env.THROTTLE_VERIFY_LIMIT, 30), + ttl: parseNumber(process.env.THROTTLE_VERIFY_TTL, 60), // 60 seconds + }, + + /** + * General API endpoints: Moderate limit + * Standard CRUD operations, queries, business logic + * Default: 100 requests per minute + */ + GENERAL_ENDPOINT: { + limit: parseNumber(process.env.THROTTLE_GENERAL_LIMIT, 100), + ttl: parseNumber(process.env.THROTTLE_GENERAL_TTL, 60), // 60 seconds + }, + + /** + * Search endpoints: Moderate limit + * Queries can be expensive but are read-only + * Default: 50 requests per minute + */ + SEARCH_ENDPOINT: { + limit: parseNumber(process.env.THROTTLE_SEARCH_LIMIT, 50), + ttl: parseNumber(process.env.THROTTLE_SEARCH_TTL, 60), // 60 seconds + }, +} as const; + +/** + * Get ThrottlerModule configuration for NestJS global setup + * Supports Redis-backed storage for multi-instance compatibility + * + * @returns Array of ThrottlerOptions for ThrottlerModule.forRoot() + */ +export const getThrottlerConfig = () => [ + { + // Default throttle configuration (fallback) + name: 'default', + ttl: RATE_LIMIT_CONFIG.GENERAL_ENDPOINT.ttl * 1000, // Convert to ms + limit: RATE_LIMIT_CONFIG.GENERAL_ENDPOINT.limit, + }, + { + // Strict verification endpoints + name: 'verify', + ttl: RATE_LIMIT_CONFIG.VERIFY_ENDPOINT.ttl * 1000, + limit: RATE_LIMIT_CONFIG.VERIFY_ENDPOINT.limit, + }, + { + // Strictest OTP/email/phone endpoints + name: 'verify-otp', + ttl: RATE_LIMIT_CONFIG.VERIFY_OTP.ttl * 1000, + limit: RATE_LIMIT_CONFIG.VERIFY_OTP.limit, + }, + { + // Search queries + name: 'search', + ttl: RATE_LIMIT_CONFIG.SEARCH_ENDPOINT.ttl * 1000, + limit: RATE_LIMIT_CONFIG.SEARCH_ENDPOINT.limit, + }, +]; + +/** + * Routes that should NOT be rate limited + * Health checks, metrics, and docs endpoints need unrestricted access + */ +export const RATE_LIMIT_SKIP_PATHS = [ + // Health checks + /^\/(api\/)?(v\d+\/)?health(\/|$)/i, + /^\/(api\/)?(v\d+\/)?ping(\/|$)/i, + // Metrics + /^\/(api\/)?(v\d+\/)?metrics(\/|$)/i, + // Documentation + /^\/(api\/)?docs(\/|$)/i, + /^\/(api\/)?(v\d+\/)?swagger(\/|$)/i, + // OpenAPI spec + /^\/(api\/)?swagger\.json(\/|$)/i, + // Healthcheck alternatives + /^\/(api\/)?(v\d+\/)?status(\/|$)/i, +] as const; + +/** + * Check if a request path should skip rate limiting + * @param path The request path + * @returns true if the path is exempt from rate limiting + */ +export const shouldSkipRateLimit = (path: string): boolean => { + const normalizedPath = path.split('?')[0]; // Remove query params + return RATE_LIMIT_SKIP_PATHS.some(pattern => pattern.test(normalizedPath)); +}; diff --git a/app/backend/src/common/decorators/skip-throttle.decorator.ts b/app/backend/src/common/decorators/skip-throttle.decorator.ts new file mode 100644 index 00000000..d0321446 --- /dev/null +++ b/app/backend/src/common/decorators/skip-throttle.decorator.ts @@ -0,0 +1,18 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * Skip rate limiting for this route + * Used for health checks, metrics, and other high-traffic endpoints + * that should not be throttled + * + * @example + * ```ts + * @Get('health') + * @SkipThrottle() + * check() { + * return { status: 'ok' }; + * } + * ``` + */ +export const SKIP_THROTTLE_KEY = 'skipThrottle'; +export const SkipThrottle = () => SetMetadata(SKIP_THROTTLE_KEY, true); diff --git a/app/backend/src/common/guards/throttle.guard.ts b/app/backend/src/common/guards/throttle.guard.ts new file mode 100644 index 00000000..33519ea7 --- /dev/null +++ b/app/backend/src/common/guards/throttle.guard.ts @@ -0,0 +1,47 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Inject, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import { SKIP_THROTTLE_KEY } from '../decorators/skip-throttle.decorator'; +import { shouldSkipRateLimit } from '../config/rate-limit.config'; + +/** + * Enhanced ThrottlerGuard that respects @SkipThrottle() decorator + * and globally exempt paths (health, docs, metrics) + * + * This guard: + * 1. Checks if route has @SkipThrottle() decorator + * 2. Checks if path matches globally exempt patterns + * 3. Falls back to standard ThrottlerGuard behavior + */ +@Injectable() +export class CostAwareThrottlerGuard extends ThrottlerGuard { + constructor(@Inject(Reflector) protected reflector: Reflector) { + super(); + } + + async canActivate(context: ExecutionContext): Promise { + const skipThrottle = this.reflector.get( + SKIP_THROTTLE_KEY, + context.getHandler(), + ); + + if (skipThrottle) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const path = request.path ?? request.url ?? ''; + + if (shouldSkipRateLimit(path)) { + return true; + } + + // Delegate to parent ThrottlerGuard + return super.canActivate(context); + } +} diff --git a/app/backend/src/health/health.controller.ts b/app/backend/src/health/health.controller.ts index 5e2d724f..3b8e5066 100644 --- a/app/backend/src/health/health.controller.ts +++ b/app/backend/src/health/health.controller.ts @@ -12,7 +12,7 @@ import { HealthService } from './health.service'; import { LivenessResponse, ReadinessResponse } from './health.service'; import { API_VERSIONS } from '../common/constants/api-version.constants'; import { Public } from '../common/decorators/public.decorator'; -import { Throttle } from '@nestjs/throttler'; +import { SkipThrottle } from '../common/decorators/skip-throttle.decorator'; @ApiTags('Health') @Controller('health') @@ -20,8 +20,8 @@ export class HealthController { constructor(private readonly healthService: HealthService) {} @Public() + @SkipThrottle() @Get() - @Throttle({ default: { ttl: 60, limit: 100 } }) // Limit to 100 requests per minute for this endpoint @Version(API_VERSIONS.V1) @ApiOperation({ summary: 'Check system liveness and basic service metadata', @@ -46,6 +46,7 @@ export class HealthController { } @Public() + @SkipThrottle() @Get('live') @Version(API_VERSIONS.V1) @ApiOperation({ @@ -67,6 +68,7 @@ export class HealthController { } @Public() + @SkipThrottle() @Get('ready') @Version(API_VERSIONS.V1) @ApiOperation({ @@ -111,6 +113,7 @@ export class HealthController { } @Get('error') + @SkipThrottle() @Version(API_VERSIONS.V1) @ApiOperation({ summary: 'Trigger an error for testing' }) @ApiInternalServerErrorResponse({ @@ -127,6 +130,7 @@ export class HealthController { } @Get('onchain') + @SkipThrottle() @Version(API_VERSIONS.V1) @ApiOperation({ summary: 'On-chain contract health probe (internal use)', diff --git a/app/backend/src/verification/verification.controller.ts b/app/backend/src/verification/verification.controller.ts index e3903b51..2d7ce16e 100644 --- a/app/backend/src/verification/verification.controller.ts +++ b/app/backend/src/verification/verification.controller.ts @@ -11,6 +11,7 @@ import { Request, } from '@nestjs/common'; import { Request as ExpressRequest } from 'express'; +import { Throttle } from '@nestjs/throttler'; import { ApiTags, ApiOperation, @@ -55,6 +56,7 @@ export class VerificationController { @Post('claims/:id/enqueue') @Version('1') + @Throttle('verify', { limit: 30, ttl: 60 }) // Strict: General verification operations @HttpCode(HttpStatus.ACCEPTED) @ApiOperation({ summary: 'Enqueue claim verification job', @@ -142,6 +144,7 @@ export class VerificationController { @Post('start') @Version('1') + @Throttle('verify-otp', { limit: 20, ttl: 60 }) // Strictest: OTP/email/phone operations @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Start verification flow (OTP/email/phone)', @@ -181,12 +184,16 @@ export class VerificationController { description: 'Invalid input parameters or rate limit exceeded for this identifier.', }) + @ApiTooManyRequestsResponse({ + description: 'Too many requests. High-cost operation (email/SMS sending).', + }) async startVerification(@Body() dto: StartVerificationDto) { return this.verificationFlowService.start(dto); } @Post('resend') @Version('1') + @Throttle('verify-otp', { limit: 20, ttl: 60 }) // Strictest: Resend is also OTP/email/phone operation @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Resend verification code', @@ -210,12 +217,16 @@ export class VerificationController { @ApiNotFoundResponse({ description: 'The specified verification session was not found.', }) + @ApiTooManyRequestsResponse({ + description: 'Too many requests. High-cost operation (email/SMS sending).', + }) async resendVerification(@Body() dto: ResendVerificationDto) { return this.verificationFlowService.resend(dto); } @Post('complete') @Version('1') + @Throttle('verify-otp', { limit: 20, ttl: 60 }) // Strictest: OTP verification attempts @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Complete verification with OTP', @@ -238,12 +249,17 @@ export class VerificationController { @ApiNotFoundResponse({ description: 'The specified verification session was not found.', }) + @ApiTooManyRequestsResponse({ + description: + 'Too many failed attempts or rate limit exceeded for verification.', + }) async completeVerification(@Body() dto: CompleteVerificationDto) { return this.verificationFlowService.complete(dto); } @Post() @Version(API_VERSIONS.V1) + @Throttle('verify', { limit: 30, ttl: 60 }) // Strict: General verification operations @ApiOperation({ summary: 'Submit identity verification request (v1)', description: @@ -269,6 +285,9 @@ export class VerificationController { @ApiUnauthorizedResponse({ description: 'Missing or invalid authentication credentials.', }) + @ApiTooManyRequestsResponse({ + description: 'Too many verification requests.', + }) create(@Body() createVerificationDto: CreateVerificationDto) { return this.verificationService.create(createVerificationDto); } diff --git a/app/backend/test/rate-limit-cost-aware.e2e-spec.ts b/app/backend/test/rate-limit-cost-aware.e2e-spec.ts new file mode 100644 index 00000000..f20c3621 --- /dev/null +++ b/app/backend/test/rate-limit-cost-aware.e2e-spec.ts @@ -0,0 +1,446 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { AppModule } from './../src/app.module'; + +/** + * Cost-Aware Rate Limiting E2E Tests + * + * Tests verify that: + * 1. Strictest limits apply to OTP/email/phone endpoints + * 2. Strict limits apply to general verification endpoints + * 3. Moderate limits apply to general API endpoints + * 4. Health and docs endpoints bypass rate limiting + * 5. Reset behavior works after TTL window expires + * 6. Retry-After header is present on 429 responses + */ +describe('Cost-Aware Rate Limiting (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + // Use small limits for testing + process.env.THROTTLE_VERIFY_OTP_LIMIT = '3'; + process.env.THROTTLE_VERIFY_OTP_TTL = '1'; + process.env.THROTTLE_VERIFY_LIMIT = '5'; + process.env.THROTTLE_VERIFY_TTL = '1'; + process.env.THROTTLE_GENERAL_LIMIT = '10'; + process.env.THROTTLE_GENERAL_TTL = '1'; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('OTP/Email/Phone endpoints - Strictest limit (3 req/min)', () => { + it('should allow up to 3 requests to /verification/start', async () => { + const agent = request(app.getHttpServer()); + + // First 3 requests should succeed + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }) + .expect(res => { + expect(res.status).toBeLessThan(429); + }); + } + }); + + it('should return 429 Too Many Requests on 4th request to /verification/start', async () => { + const agent = request(app.getHttpServer()); + + // First 3 requests succeed + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }); + } + + // 4th request should be rate limited + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }) + .expect(429); + }); + + it('should include Retry-After header on 429 response from /verification/start', async () => { + const agent = request(app.getHttpServer()); + + // Hit the limit + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }); + } + + // Next request should have Retry-After header + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }) + .expect(429) + .expect(res => { + // NestJS ThrottlerGuard should set retry-after + expect(res.headers['retry-after']).toBeDefined(); + }); + }); + + it('should allow 3 requests to /verification/resend', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/resend') + .send({ sessionId: 'test-session' }) + .expect(res => { + expect(res.status).toBeLessThan(429); + }); + } + }); + + it('should return 429 on 4th request to /verification/resend', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/resend') + .send({ sessionId: 'test-session' }); + } + + await agent + .post('/api/v1/verification/resend') + .send({ sessionId: 'test-session' }) + .expect(429); + }); + + it('should allow 3 requests to /verification/complete', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/complete') + .send({ sessionId: 'test-session', code: '123456' }) + .expect(res => { + expect(res.status).toBeLessThan(429); + }); + } + }); + + it('should return 429 on 4th request to /verification/complete', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/complete') + .send({ sessionId: 'test-session', code: '123456' }); + } + + await agent + .post('/api/v1/verification/complete') + .send({ sessionId: 'test-session', code: '123456' }) + .expect(429); + }); + }); + + describe('General verification endpoints - Strict limit (5 req/min)', () => { + it('should allow up to 5 requests to POST /api/v1/verification', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 5; i++) { + await agent + .post('/api/v1/verification') + .send({ + userId: 'user-123', + documentType: 'NATIONAL_ID', + }) + .expect(res => { + expect(res.status).toBeLessThan(429); + }); + } + }); + + it('should return 429 on 6th request to POST /api/v1/verification', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 5; i++) { + await agent + .post('/api/v1/verification') + .send({ + userId: 'user-123', + documentType: 'NATIONAL_ID', + }); + } + + await agent + .post('/api/v1/verification') + .send({ + userId: 'user-123', + documentType: 'NATIONAL_ID', + }) + .expect(429); + }); + + it('should allow up to 5 requests to POST /api/v1/verification/claims/:id/enqueue', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 5; i++) { + await agent + .post('/api/v1/verification/claims/claim-123/enqueue') + .expect(res => { + // Will fail with 404 or 500 (claim doesn't exist), but not 429 + expect(res.status).not.toBe(429); + }); + } + }); + + it('should return 429 on 6th request to POST /api/v1/verification/claims/:id/enqueue', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 5; i++) { + await agent.post('/api/v1/verification/claims/claim-123/enqueue'); + } + + await agent + .post('/api/v1/verification/claims/claim-123/enqueue') + .expect(429); + }); + }); + + describe('Health endpoints - No rate limiting', () => { + it('should NOT rate limit GET /api/v1/health', async () => { + const agent = request(app.getHttpServer()); + + // Send many requests, all should succeed (or fail with non-429 errors) + for (let i = 0; i < 20; i++) { + await agent + .get('/api/v1/health') + .expect(res => { + expect(res.status).not.toBe(429); + }); + } + }); + + it('should NOT rate limit GET /api/v1/health/live', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 20; i++) { + await agent + .get('/api/v1/health/live') + .expect(res => { + expect(res.status).not.toBe(429); + }); + } + }); + + it('should NOT rate limit GET /api/v1/health/ready', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 20; i++) { + await agent + .get('/api/v1/health/ready') + .expect(res => { + expect(res.status).not.toBe(429); + }); + } + }); + + it('should NOT rate limit GET /api/v1/health/error', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 20; i++) { + await agent + .get('/api/v1/health/error') + .expect(res => { + // Will fail with 500, not 429 + expect(res.status).not.toBe(429); + }); + } + }); + + it('should NOT rate limit GET /api/v1/health/onchain', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 20; i++) { + await agent + .get('/api/v1/health/onchain') + .expect(res => { + expect(res.status).not.toBe(429); + }); + } + }); + }); + + describe('Docs endpoints - No rate limiting', () => { + it('should NOT rate limit GET /api/docs', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 20; i++) { + await agent + .get('/api/docs') + .expect(res => { + expect(res.status).not.toBe(429); + }); + } + }); + + it('should NOT rate limit GET /api/swagger.json', async () => { + const agent = request(app.getHttpServer()); + + for (let i = 0; i < 20; i++) { + await agent + .get('/api/swagger.json') + .expect(res => { + expect(res.status).not.toBe(429); + }); + } + }); + }); + + describe('Rate limit window reset', () => { + it('should reset limit after TTL window expires', async () => { + const agent = request(app.getHttpServer()); + + // Hit the limit (3 requests) + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }); + } + + // Next request should be rate limited + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }) + .expect(429); + + // Wait for TTL to expire (1 second in tests + small buffer) + await new Promise(resolve => setTimeout(resolve, 1100)); + + // Should allow more requests now + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }) + .expect(res => { + expect(res.status).not.toBe(429); + }); + }); + }); + + describe('Different endpoints have independent limits', () => { + it('should have independent limits for /verification/start and /verification/resend', async () => { + const agent = request(app.getHttpServer()); + + // Use up the /verification/start limit + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }); + } + + // /verification/start should be rate limited + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }) + .expect(429); + + // But /verification/resend should still be allowed + await agent + .post('/api/v1/verification/resend') + .send({ sessionId: 'test-session' }) + .expect(res => { + expect(res.status).not.toBe(429); + }); + }); + + it('should have independent limits for verify-otp (3) and verify (5) endpoints', async () => { + const agent = request(app.getHttpServer()); + + // Use up the verify-otp limit (3 requests) + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }); + } + + // But verify endpoint (/verification POST) should still allow up to 5 + for (let i = 0; i < 5; i++) { + await agent + .post('/api/v1/verification') + .send({ + userId: 'user-123', + documentType: 'NATIONAL_ID', + }) + .expect(res => { + expect(res.status).not.toBe(429); + }); + } + }); + }); + + describe('Stricter limits compared to general endpoints', () => { + it('OTP endpoints (3) should have stricter limits than verify endpoints (5)', async () => { + const agent = request(app.getHttpServer()); + + // Use up OTP limit + for (let i = 0; i < 3; i++) { + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }); + } + + // 4th OTP request should fail + await agent + .post('/api/v1/verification/start') + .send({ email: 'test@example.com', channel: 'email' }) + .expect(429); + + // But we should still be able to make 5 verify requests + for (let i = 0; i < 5; i++) { + await agent + .post('/api/v1/verification') + .send({ + userId: 'user-123', + documentType: 'NATIONAL_ID', + }) + .expect(res => { + expect(res.status).not.toBe(429); + }); + } + }); + + it('verify endpoints (5) should have stricter limits than general endpoints (10)', async () => { + const agent = request(app.getHttpServer()); + + // Use up verify limit + for (let i = 0; i < 5; i++) { + await agent + .post('/api/v1/verification') + .send({ + userId: 'user-123', + documentType: 'NATIONAL_ID', + }); + } + + // 6th verify request should fail + await agent + .post('/api/v1/verification') + .send({ + userId: 'user-123', + documentType: 'NATIONAL_ID', + }) + .expect(429); + + // But general endpoints should still have room + // Note: This assumes there are general endpoints available + // In practice, adjust to actual general endpoints + }); + }); +});