Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/auth/auth.service.captcha.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions src/cache/cache-metrics.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
78 changes: 78 additions & 0 deletions test/cache/cache-metrics.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>();
blacklistedToken = new Map<string, any>();

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);
});
});
Loading