From efa29b6a03a60b73f6a39a6b4f30a2a76c61af3a Mon Sep 17 00:00:00 2001 From: NteinPrecious Date: Mon, 29 Jun 2026 14:13:42 +0100 Subject: [PATCH 1/2] feat: implement #728, #729, #730, #731 - cache metrics tests, captcha lockout test --- src/auth/auth.service.captcha.spec.ts | 45 +++++++++++++++ src/cache/cache-metrics.interceptor.ts | 18 ++++++ test/cache/cache-metrics.e2e-spec.ts | 78 ++++++++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 test/cache/cache-metrics.e2e-spec.ts 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); + }); +}); From 5e5c7844cf38ae1686d26d85071ce496d886fa81 Mon Sep 17 00:00:00 2001 From: demilade18-git Date: Mon, 29 Jun 2026 14:13:42 +0100 Subject: [PATCH 2/2] feat: implement #732, #733, #734, #735 - prisma error mapping, reactivation token --- prisma/schema.prisma | 2 + src/auth/security.utils.ts | 6 +++ src/users/dto/deactivation.dto.ts | 3 ++ src/users/users.controller.ts | 6 +++ src/users/users.service.ts | 70 ++++++++++++++++++++++++++++--- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65faa340..d6d8ba05 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -183,6 +183,8 @@ model User { pendingEmail String? @map("pending_email") emailVerificationToken String? @map("email_verification_token") emailVerificationExpires DateTime? @map("email_verification_expires") + reactivationToken String? @map("reactivation_token") + reactivationTokenExpires DateTime? @map("reactivation_token_expires") trustScore Int @default(0) @map("trust_score") lastTrustScoreUpdate DateTime? @map("last_trust_score_update") createdAt DateTime @default(now()) @map("created_at") diff --git a/src/auth/security.utils.ts b/src/auth/security.utils.ts index 51c96c2b..119bb0ce 100644 --- a/src/auth/security.utils.ts +++ b/src/auth/security.utils.ts @@ -145,6 +145,12 @@ export function verifyTotpCode({ return false; } +export function generateReactivationToken(): { token: string; hash: string } { + const token = randomToken(32); + const hash = createSha256(token); + return { token, hash }; +} + export function parseDuration(input: string, fallbackSeconds: number): number { const value = input?.trim(); if (!value) { diff --git a/src/users/dto/deactivation.dto.ts b/src/users/dto/deactivation.dto.ts index 19abcaa5..e11866f8 100644 --- a/src/users/dto/deactivation.dto.ts +++ b/src/users/dto/deactivation.dto.ts @@ -20,4 +20,7 @@ export class ReactivateAccountDto { @IsOptional() @IsString() reason?: string; + + @IsString() + token: string; } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 0625a3ff..d8647610 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -231,6 +231,12 @@ export class UsersController { return this.usersService.deactivate(user.sub, deactivateDto); } + @UseGuards(JwtAuthGuard) + @Post('me/request-reactivation') + requestReactivation(@CurrentUser() user: AuthUserPayload) { + return this.usersService.requestReactivation(user.sub); + } + @UseGuards(JwtAuthGuard) @Post('me/reactivate') reactivateAccount( diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 66ae2995..01de3cd7 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,10 +7,11 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; +import * as crypto from 'crypto'; import { PrismaService } from '../database/prisma.service'; import { CreateUserDto, SearchUsersDto, UpdatePreferencesDto, UpdateUserDto } from './dto/user.dto'; import { DeactivateAccountDto, ReactivateAccountDto } from './dto/deactivation.dto'; -import { hashPassword, sanitizeUser } from '../auth/security.utils'; +import { hashPassword, sanitizeUser, createSha256, generateReactivationToken } from '../auth/security.utils'; import * as fs from 'fs'; import * as path from 'path'; import { UpdateProfileDto } from './dto/update-profile.dto'; @@ -484,19 +485,76 @@ export class UsersService implements OnModuleInit { return updatedUser; } - async reactivate(userId: string, data: ReactivateAccountDto = {}) { - void data; + async requestReactivation(userId: string): Promise<{ message: string }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.isDeactivated) { + throw new BadRequestException('Account is not deactivated'); + } + + const { token, hash } = generateReactivationToken(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await this.prisma.user.update({ + where: { id: userId }, + data: { + reactivationToken: hash, + reactivationTokenExpires: expiresAt, + }, + }); + this.logger.log(`Reactivation token generated for user ${userId} (${user.email})`); + + return { + message: 'Reactivation token generated. Use the token to reactivate your account.', + }; + } + + async reactivate(userId: string, data: ReactivateAccountDto) { const user = await this.prisma.user.findUnique({ where: { id: userId }, }); if (!user) { - throw new Error('User not found'); + throw new NotFoundException('User not found'); } if (!user.isDeactivated) { - throw new Error('Account is not deactivated'); + throw new BadRequestException('Account is not deactivated'); + } + + if (!user.reactivationToken || !user.reactivationTokenExpires) { + throw new BadRequestException('No reactivation token has been requested. Please request a token first.'); + } + + if (new Date() > user.reactivationTokenExpires) { + await this.prisma.user.update({ + where: { id: userId }, + data: { + reactivationToken: null, + reactivationTokenExpires: null, + }, + }); + throw new BadRequestException('Reactivation token has expired. Please request a new one.'); + } + + // Constant-time comparison of token hash + const providedHash = createSha256(data.token); + const storedHash = user.reactivationToken; + const storedHashBuffer = Buffer.from(storedHash); + const providedHashBuffer = Buffer.from(providedHash); + + if ( + storedHashBuffer.length !== providedHashBuffer.length || + !crypto.timingSafeEqual(storedHashBuffer, providedHashBuffer) + ) { + throw new BadRequestException('Invalid reactivation token'); } const updatedUser = await this.prisma.user.update({ @@ -505,6 +563,8 @@ export class UsersService implements OnModuleInit { isDeactivated: false, deactivatedAt: null, scheduledDeletionAt: null, + reactivationToken: null, + reactivationTokenExpires: null, }, select: { id: true,