diff --git a/src/auth/auth.service.captcha.spec.ts b/src/auth/auth.service.captcha.spec.ts index 7943fa08..5833e3e8 100644 --- a/src/auth/auth.service.captcha.spec.ts +++ b/src/auth/auth.service.captcha.spec.ts @@ -171,6 +171,51 @@ describe('AuthService – CAPTCHA failure lockout', () => { }); }); + describe('5 CAPTCHA failures trigger account lockout', () => { + it('locks account after 5 invalid CAPTCHA submissions', async () => { + mockCaptchaFail(); + rateLimitService.isAccountLocked + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + rateLimitService.getFailedAttemptsCount.mockResolvedValue(3); + + let callCount = 0; + rateLimitService.recordFailedAttempt.mockImplementation(async () => { + callCount++; + // After 5th failed attempt, the account is considered locked + if (callCount >= 5) { + rateLimitService.isAccountLocked.mockResolvedValue(true); + rateLimitService.getLockoutInfo.mockResolvedValue({ + isLocked: true, + remainingLockoutMinutes: 30, + }); + return true; + } + return false; + }); + + const loginArgs = { + email: 'user@example.com', + password: 'pass', + captchaToken: 'bad', + }; + + // First 5 attempts fail with "Invalid CAPTCHA" + for (let i = 0; i < 5; i++) { + await expect(service.login(loginArgs)).rejects.toThrow('Invalid CAPTCHA'); + } + + // 6th attempt: account is locked + await expect(service.login(loginArgs)).rejects.toThrow(/locked/i); + + expect(rateLimitService.recordFailedAttempt).toHaveBeenCalledTimes(5); + }); + }); + describe('password failure (unchanged behavior)', () => { it('still records a failed attempt on wrong password', async () => { rateLimitService.isAccountLocked.mockResolvedValue(false); diff --git a/src/cache/cache-metrics.interceptor.ts b/src/cache/cache-metrics.interceptor.ts index b946d0ce..e120857f 100644 --- a/src/cache/cache-metrics.interceptor.ts +++ b/src/cache/cache-metrics.interceptor.ts @@ -3,6 +3,24 @@ /** * Cache Metrics Interceptor * Automatically tracks cache performance metrics + * + * DI Contract: + * This interceptor must be registered via app.useGlobalInterceptors() in main.ts + * using the already-resolved singleton from the DI container: + * + * const cacheMetricsInterceptor = app.get(CacheMetricsInterceptor); + * app.useGlobalInterceptors(cacheMetricsInterceptor); + * + * WHY: The CacheMonitoringService it depends on is provided at the CacheModule + * level. If @UseInterceptors(CacheMetricsInterceptor) were used on a controller + * instead, NestJS would instantiate a NEW interceptor instance from a sub-module + * injector, which would receive a DIFFERENT CacheMonitoringService singleton. + * As a result, metric counters would not reflect server-wide values, the stats + * endpoint would be unreliable, and alerting (low hit-rate / high response-time) + * would break silently. + * + * Any refactoring that changes how this interceptor is registered MUST verify + * that CacheMonitoringService remains a true application-wide singleton. */ import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; diff --git a/test/cache/cache-metrics.e2e-spec.ts b/test/cache/cache-metrics.e2e-spec.ts new file mode 100644 index 00000000..d1682320 --- /dev/null +++ b/test/cache/cache-metrics.e2e-spec.ts @@ -0,0 +1,78 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AppModule } from '../../src/app.module'; +import { PrismaService } from '../../src/database/prisma.service'; +import { CacheMonitoringService } from '../../src/cache/cache-monitoring.service'; + +class FakePrismaService { + users = new Map(); + blacklistedToken = new Map(); + + async $connect() {} + async $disconnect() {} + + user = { + findUnique: async ({ where }: any) => { + if (where?.id) return this.users.get(where.id) ?? null; + if (where?.email) return Array.from(this.users.values()).find((u) => u.email === where.email) ?? null; + return null; + }, + update: async ({ where, data }: any) => { + const user = this.users.get(where.id); + const updated = { ...user, ...data }; + this.users.set(where.id, updated); + return updated; + }, + } as any; +} + +describe('CacheMetricsInterceptor e2e — singleton verification', () => { + let app: INestApplication; + let monitoringService: CacheMonitoringService; + let fakePrisma: FakePrismaService; + + beforeAll(async () => { + fakePrisma = new FakePrismaService(); + + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(PrismaService) + .useValue(fakePrisma as any) + .compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + monitoringService = app.get(CacheMonitoringService); + monitoringService.resetMetrics(); + }, 20000); + + afterAll(async () => { + await app.close(); + }); + + it('records metrics after an HTTP request', async () => { + const metricsBefore = monitoringService.getMetrics(); + expect(metricsBefore.totalRequests).toBe(0); + + await request(app.getHttpServer()) + .get('/api/properties') + .expect(200); + + const metricsAfter = monitoringService.getMetrics(); + expect(metricsAfter.totalRequests).toBeGreaterThanOrEqual(1); + expect(metricsAfter.avgResponseTime).toBeGreaterThan(0); + }); + + it('accumulates metrics across multiple requests', async () => { + monitoringService.resetMetrics(); + + await request(app.getHttpServer()).get('/api/properties'); + await request(app.getHttpServer()).get('/api/properties'); + await request(app.getHttpServer()).get('/api/properties'); + + const metrics = monitoringService.getMetrics(); + expect(metrics.totalRequests).toBeGreaterThanOrEqual(3); + expect(metrics.avgResponseTime).toBeGreaterThan(0); + }); +});