From cb6114b1d95ed2d8daf6893ebdac724e595f1f27 Mon Sep 17 00:00:00 2001 From: nottherealalanturing Date: Sat, 27 Jun 2026 13:19:52 +0100 Subject: [PATCH 1/6] feat: analytics date range validation, avatar filename regex guard, e2e tests --- src/admin/admin.controller.ts | 2 + src/email/email.module.ts | 2 +- src/transactions/dto/transaction.dto.ts | 9 ++- src/transactions/transactions.service.ts | 19 ++++++ src/users/avatar-upload.controller.ts | 9 +-- src/users/pipes/filename-validation.pipe.ts | 31 ++++++++++ test/e2e/analytics-date-range.spec.ts | 66 +++++++++++++++++++++ test/e2e/avatar-filename-validation.spec.ts | 43 ++++++++++++++ test/users/avatar-upload.spec.ts | 8 +-- 9 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 src/users/pipes/filename-validation.pipe.ts create mode 100644 test/e2e/analytics-date-range.spec.ts create mode 100644 test/e2e/avatar-filename-validation.spec.ts diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index d143bd34..f6306307 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -11,6 +11,7 @@ import { Query, Res, UseGuards, + UseInterceptors, HttpException, HttpStatus, } from '@nestjs/common'; @@ -37,6 +38,7 @@ import { UpdateTransactionStatusDto, } from './dto/admin.dto'; import { RestoreBackupDto, UpdateBackupScheduleDto } from '../backup/dto/backup.dto'; +import { AdminAuditInterceptor } from './admin-audit.interceptor'; @Controller('admin') @UseGuards(JwtAuthGuard, RolesGuard) diff --git a/src/email/email.module.ts b/src/email/email.module.ts index 0e53277d..deaa4024 100644 --- a/src/email/email.module.ts +++ b/src/email/email.module.ts @@ -6,7 +6,7 @@ import { EmailWebhookController } from './email-webhook.controller'; import { PrismaModule } from '../database/prisma.module'; import { TrackingModule } from '../tracking/tracking.module'; import { MailerModule } from '@nestjs-modules/mailer'; -import { EjsAdapter } from '@nestjs-modules/mailer/dist/adapters/ejs.adapter'; +import { EjsAdapter } from '@nestjs-modules/mailer/adapters/ejs.adapter'; import { ConfigService } from '@nestjs/config'; import { join } from 'path'; import { BullModule } from '@nestjs/bullmq'; diff --git a/src/transactions/dto/transaction.dto.ts b/src/transactions/dto/transaction.dto.ts index a8b965b5..7512d7a8 100644 --- a/src/transactions/dto/transaction.dto.ts +++ b/src/transactions/dto/transaction.dto.ts @@ -1,6 +1,6 @@ // @ts-nocheck -import { IsString, IsNumber, IsOptional, IsEnum, IsUUID, IsDate, IsIn, Min } from 'class-validator'; +import { IsString, IsNumber, IsOptional, IsEnum, IsUUID, IsDate, IsIn, Min, Max } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -213,6 +213,13 @@ export class TransactionAnalyticsQueryDto { @IsOptional() @IsEnum(TransactionTypeDto) type?: TransactionTypeDto; + + @ApiPropertyOptional({ description: 'Maximum number of days for the date range (1-365)' }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(365) + maxDays?: number = 365; } export class TransactionVolumeTrendDto { diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index 57e51637..f88b4226 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -315,6 +315,7 @@ export class TransactionsService { */ async getAnalytics(query: TransactionAnalyticsQueryDto = {}): Promise { const where: Record = {}; + const maxDays = query.maxDays ?? 365; if (query.type) { where.type = query.type; @@ -324,6 +325,24 @@ export class TransactionsService { where.createdAt = {}; if (query.startDate) where.createdAt.gte = query.startDate; if (query.endDate) where.createdAt.lte = query.endDate; + + if (query.startDate && query.endDate) { + const diffMs = new Date(query.endDate).getTime() - new Date(query.startDate).getTime(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays > maxDays) { + const cappedEnd = new Date(query.startDate); + cappedEnd.setDate(cappedEnd.getDate() + maxDays); + where.createdAt.lte = cappedEnd; + } + } else if (query.startDate && !query.endDate) { + const cappedEnd = new Date(query.startDate); + cappedEnd.setDate(cappedEnd.getDate() + maxDays); + where.createdAt.lte = cappedEnd; + } else if (!query.startDate && query.endDate) { + const cappedStart = new Date(query.endDate); + cappedStart.setDate(cappedStart.getDate() - maxDays); + where.createdAt.gte = cappedStart; + } } const transactions = await this.prisma.transaction.findMany({ diff --git a/src/users/avatar-upload.controller.ts b/src/users/avatar-upload.controller.ts index 7f137eb6..8e46be0e 100644 --- a/src/users/avatar-upload.controller.ts +++ b/src/users/avatar-upload.controller.ts @@ -16,7 +16,8 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { AvatarUploadService } from './avatar-upload.service'; import { UsersService } from './users.service'; -import { AvatarUploadResponseDto, AvatarDeleteDto } from './dto/avatar-upload.dto'; +import { AvatarUploadResponseDto } from './dto/avatar-upload.dto'; +import { FilenameValidationPipe } from './pipes/filename-validation.pipe'; // Multer type definition interface MulterFile { @@ -67,7 +68,7 @@ export class AvatarUploadController { @Delete('delete') async deleteAvatar( - @Body() deleteDto: AvatarDeleteDto, + @Body('filename', FilenameValidationPipe) filename: string, @Request() req: { user: { id: string } }, ): Promise<{ message: string }> { if (!req.user || !req.user.id) { @@ -76,7 +77,7 @@ export class AvatarUploadController { try { // Delete avatar file - await this.avatarUploadService.deleteAvatar(req.user.id, deleteDto.filename); + await this.avatarUploadService.deleteAvatar(req.user.id, filename); // Remove avatar URL from user's record await this.usersService.updateAvatar(req.user.id, null); @@ -89,7 +90,7 @@ export class AvatarUploadController { @Get(':filename') async getAvatar( - @Param('filename') filename: string, + @Param('filename', FilenameValidationPipe) filename: string, @Request() req: { user: { id: string } }, ): Promise<{ avatarUrl: string }> { if (!req.user || !req.user.id) { diff --git a/src/users/pipes/filename-validation.pipe.ts b/src/users/pipes/filename-validation.pipe.ts new file mode 100644 index 00000000..659fab1f --- /dev/null +++ b/src/users/pipes/filename-validation.pipe.ts @@ -0,0 +1,31 @@ +import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common'; + +const FILENAME_REGEX = /^[a-zA-Z0-9._-]+$/; +const MAX_FILENAME_LENGTH = 255; + +@Injectable() +export class FilenameValidationPipe implements PipeTransform { + transform(value: string): string { + if (!value || value.trim().length === 0) { + throw new BadRequestException('Filename must not be empty'); + } + + if (value.length > MAX_FILENAME_LENGTH) { + throw new BadRequestException( + `Filename must not exceed ${MAX_FILENAME_LENGTH} characters`, + ); + } + + if (value.includes('..') || value.includes('/') || value.includes('\\')) { + throw new BadRequestException('Filename must not contain path traversal sequences'); + } + + if (!FILENAME_REGEX.test(value)) { + throw new BadRequestException( + 'Filename must only contain alphanumeric characters, dots, hyphens, and underscores', + ); + } + + return value; + } +} diff --git a/test/e2e/analytics-date-range.spec.ts b/test/e2e/analytics-date-range.spec.ts new file mode 100644 index 00000000..6b1a3bd8 --- /dev/null +++ b/test/e2e/analytics-date-range.spec.ts @@ -0,0 +1,66 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TransactionsService } from '../../src/transactions/transactions.service'; +import { PrismaService } from '../../src/database/prisma.service'; +import { BlockchainService } from '../../src/blockchain/blockchain.service'; +import { NotificationsService } from '../../src/notifications/notifications.service'; +import { CommissionsService } from '../../src/commissions/commissions.service'; +import { TransactionFeesService } from '../../src/transactions/transaction-fees.service'; +import { TimelineService } from '../../src/transactions/timeline.service'; +import { TransactionAuditService } from '../../src/transactions/transaction-audit.service'; +import { TransactionAnalyticsGranularity } from '../../src/transactions/dto/transaction.dto'; +import { Logger } from '@nestjs/common'; + +describe('Analytics date range boundary (e2e)', () => { + let service: TransactionsService; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionsService, + { provide: PrismaService, useValue: { transaction: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0) }, $connect: jest.fn(), $disconnect: jest.fn(), $transaction: jest.fn((a: any) => Promise.all(a)) } }, + { provide: BlockchainService, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: CommissionsService, useValue: {} }, + { provide: TransactionFeesService, useValue: {} }, + { provide: TimelineService, useValue: {} }, + { provide: TransactionAuditService, useValue: {} }, + ], + }).compile(); + + service = moduleRef.get(TransactionsService); + }); + + it('should cap date range at maxDays when startDate and endDate exceed limit', async () => { + const startDate = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000); + const endDate = new Date(); + const result = await service.getAnalytics({ startDate, endDate, maxDays: 365 }); + expect(result).toBeDefined(); + expect(result.totalTransactions).toBe(0); + }); + + it('should cap date range at 365 days when only startDate is provided', async () => { + const startDate = new Date(Date.now() - 500 * 24 * 60 * 60 * 1000); + const result = await service.getAnalytics({ startDate, maxDays: 365 }); + expect(result).toBeDefined(); + }); + + it('should cap date range at 365 days when only endDate is provided', async () => { + const endDate = new Date(Date.now() + 500 * 24 * 60 * 60 * 1000); + const result = await service.getAnalytics({ endDate, maxDays: 365 }); + expect(result).toBeDefined(); + }); + + it('should not cap date range within 365 day limit', async () => { + const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const endDate = new Date(); + const result = await service.getAnalytics({ startDate, endDate, maxDays: 365 }); + expect(result).toBeDefined(); + }); + + it('should use maxDays from DTO as the cap', async () => { + const startDate = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000); + const result = await service.getAnalytics({ startDate, maxDays: 100 }); + expect(result).toBeDefined(); + }); +}); diff --git a/test/e2e/avatar-filename-validation.spec.ts b/test/e2e/avatar-filename-validation.spec.ts new file mode 100644 index 00000000..da4110ec --- /dev/null +++ b/test/e2e/avatar-filename-validation.spec.ts @@ -0,0 +1,43 @@ +import { BadRequestException } from '@nestjs/common'; +import { FilenameValidationPipe } from '../../src/users/pipes/filename-validation.pipe'; + +describe('Avatar filename validation (e2e)', () => { + const pipe = new FilenameValidationPipe(); + + it('should accept valid filenames with alphanumeric chars', () => { + expect(pipe.transform('avatar123.jpg')).toBe('avatar123.jpg'); + }); + + it('should accept valid filenames with dots, hyphens, underscores', () => { + expect(pipe.transform('my-avatar_123.v2.png')).toBe('my-avatar_123.v2.png'); + }); + + it('should reject filename with path traversal (../)', () => { + expect(() => pipe.transform('../../../etc/passwd')).toThrow(BadRequestException); + }); + + it('should reject filename with backslash', () => { + expect(() => pipe.transform('..\\windows\\system32')).toThrow(BadRequestException); + }); + + it('should reject empty filename', () => { + expect(() => pipe.transform('')).toThrow(BadRequestException); + }); + + it('should reject filename with special characters', () => { + expect(() => pipe.transform('avatar@file!.png')).toThrow(BadRequestException); + }); + + it('should reject filename with spaces', () => { + expect(() => pipe.transform('avatar file.png')).toThrow(BadRequestException); + }); + + it('should reject filename exceeding 255 characters', () => { + const longName = 'a'.repeat(256) + '.jpg'; + expect(() => pipe.transform(longName)).toThrow(BadRequestException); + }); + + it('should reject whitespace-only filename', () => { + expect(() => pipe.transform(' ')).toThrow(BadRequestException); + }); +}); diff --git a/test/users/avatar-upload.spec.ts b/test/users/avatar-upload.spec.ts index 09c7b33a..1bf6ad8c 100644 --- a/test/users/avatar-upload.spec.ts +++ b/test/users/avatar-upload.spec.ts @@ -95,16 +95,16 @@ describe('AvatarUploadController', () => { describe('deleteAvatar', () => { it('should delete avatar successfully', async () => { - const deleteDto = { filename: 'test.jpg' }; + const filename = 'test.jpg'; jest.spyOn(avatarUploadService, 'deleteAvatar').mockResolvedValue(); jest.spyOn(usersService, 'updateAvatar').mockResolvedValue(mockUser as any); - const result = await controller.deleteAvatar(deleteDto, { user: mockUser }); + const result = await controller.deleteAvatar(filename, { user: mockUser }); expect(avatarUploadService.deleteAvatar).toHaveBeenCalledWith( mockUser.id, - deleteDto.filename, + filename, ); expect(usersService.updateAvatar).toHaveBeenCalledWith(mockUser.id, null); expect(result).toEqual({ message: 'Avatar deleted successfully' }); @@ -112,7 +112,7 @@ describe('AvatarUploadController', () => { it('should throw BadRequestException when user is not authenticated', async () => { await expect( - controller.deleteAvatar({ filename: 'test.jpg' }, { user: null } as any), + controller.deleteAvatar('test.jpg', { user: null } as any), ).rejects.toThrow(BadRequestException); }); }); From 3d7260c08661fdfae8be365ebec6055f2d474b8e Mon Sep 17 00:00:00 2001 From: zakkiyyat Date: Sat, 27 Jun 2026 13:22:13 +0100 Subject: [PATCH 2/6] feat: inject ConfigService into password.utils, move getPasswordHistoryLimit into AuthService, add tests --- src/auth/auth.service.ts | 16 ++++++++++------ src/auth/password.utils.ts | 20 +++++++++++--------- src/auth/security.utils.ts | 5 ----- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 7df0cb85..5575d355 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -32,7 +32,6 @@ import { comparePassword, createSha256, generateBackupCodes, - getPasswordHistoryLimit, hashPassword, parseDuration, randomBase32Secret, @@ -111,7 +110,7 @@ export class AuthService { throw new BadRequestException('A user with that email already exists'); } - const passwordErrors = validatePassword(data.password); + const passwordErrors = validatePassword(data.password, this.configService); if (passwordErrors.length > 0) { throw new BadRequestException( `Password does not meet complexity requirements: ${passwordErrors.join('; ')}`, @@ -663,7 +662,7 @@ export class AuthService { } async changePassword(user: AuthUserPayload, data: ChangePasswordDto) { - const passwordHistoryLimit = getPasswordHistoryLimit(); + const passwordHistoryLimit = this.getPasswordHistoryLimit(); const existingUser = await this.prisma.user.findUnique({ where: { id: user.sub }, include: { @@ -685,7 +684,7 @@ export class AuthService { throw new UnauthorizedException('Current password is incorrect'); } - const passwordErrors = validatePassword(data.newPassword); + const passwordErrors = validatePassword(data.newPassword, this.configService); if (passwordErrors.length > 0) { throw new BadRequestException( `Password does not meet complexity requirements: ${passwordErrors.join('; ')}`, @@ -1294,9 +1293,9 @@ export class AuthService { throw new BadRequestException('Account is blocked'); } - const passwordHistoryLimit = getPasswordHistoryLimit(); + const passwordHistoryLimit = this.getPasswordHistoryLimit(); - const passwordErrors = validatePassword(data.newPassword); + const passwordErrors = validatePassword(data.newPassword, this.configService); if (passwordErrors.length > 0) { throw new BadRequestException( `Password does not meet complexity requirements: ${passwordErrors.join('; ')}`, @@ -1396,6 +1395,11 @@ export class AuthService { }); } + private getPasswordHistoryLimit(): number { + const parsed = Number(this.configService.get('PASSWORD_HISTORY_LIMIT') ?? 5); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 5; + } + private async verifyCaptcha(token: string): Promise { const secret = this.configService.get('RECAPTCHA_SECRET'); if (!secret) { diff --git a/src/auth/password.utils.ts b/src/auth/password.utils.ts index 2fa144b6..183a56a1 100644 --- a/src/auth/password.utils.ts +++ b/src/auth/password.utils.ts @@ -1,5 +1,7 @@ // @ts-nocheck +import { ConfigService } from '@nestjs/config'; + export type PasswordPolicy = { minLength: number; requireUppercase: boolean; @@ -9,22 +11,22 @@ export type PasswordPolicy = { specialChars?: string; }; -export function getPasswordPolicy(): PasswordPolicy { - const minLength = Number(process.env.PASSWORD_MIN_LENGTH ?? 8); +export function getPasswordPolicy(configService: ConfigService): PasswordPolicy { + const minLength = Number(configService.get('PASSWORD_MIN_LENGTH') ?? 8); return { minLength: Number.isFinite(minLength) && minLength > 0 ? minLength : 8, - requireUppercase: (process.env.PASSWORD_REQUIRE_UPPERCASE ?? 'true') === 'true', - requireLowercase: (process.env.PASSWORD_REQUIRE_LOWERCASE ?? 'true') === 'true', - requireDigit: (process.env.PASSWORD_REQUIRE_DIGIT ?? 'true') === 'true', - requireSpecial: (process.env.PASSWORD_REQUIRE_SPECIAL ?? 'true') === 'true', + requireUppercase: (configService.get('PASSWORD_REQUIRE_UPPERCASE') ?? 'true') === 'true', + requireLowercase: (configService.get('PASSWORD_REQUIRE_LOWERCASE') ?? 'true') === 'true', + requireDigit: (configService.get('PASSWORD_REQUIRE_DIGIT') ?? 'true') === 'true', + requireSpecial: (configService.get('PASSWORD_REQUIRE_SPECIAL') ?? 'true') === 'true', specialChars: - process.env.PASSWORD_SPECIAL_CHARS ?? '!@#$%^&*()_+-=[]{}|;:\",./<>?'.slice(0, 32), + configService.get('PASSWORD_SPECIAL_CHARS') ?? '!@#$%^&*()_+-=[]{}|;:\",./<>?'.slice(0, 32), }; } -export function validatePassword(password: string): string[] { +export function validatePassword(password: string, configService: ConfigService): string[] { const errors: string[] = []; - const policy = getPasswordPolicy(); + const policy = getPasswordPolicy(configService); if (!password || password.length < policy.minLength) { errors.push(`Password must be at least ${policy.minLength} characters long`); diff --git a/src/auth/security.utils.ts b/src/auth/security.utils.ts index a1436bf3..51c96c2b 100644 --- a/src/auth/security.utils.ts +++ b/src/auth/security.utils.ts @@ -78,11 +78,6 @@ export function generateBackupCodes(count = 8): string[] { return Array.from({ length: count }, () => randomBytes(4).toString('hex').toUpperCase()); } -export function getPasswordHistoryLimit(): number { - const parsed = Number(process.env.PASSWORD_HISTORY_LIMIT ?? 5); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 5; -} - export function verifyBackupCode(candidate: string, backupCodeHashes: string[]) { const digest = createSha256(candidate.trim().toUpperCase()); const digestBuffer = Buffer.from(digest); From 97831014e5c40f2465dd88116afa7d53bed64f58 Mon Sep 17 00:00:00 2001 From: devdeen213 Date: Sat, 27 Jun 2026 13:24:55 +0100 Subject: [PATCH 3/6] feat: export file 24h expiry with admin revoke, audit logging, reactivate error handling --- src/admin/admin.controller.ts | 17 ++++++++++++++++ src/users/users.controller.ts | 38 ++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index f6306307..dfbe9ed3 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -3,7 +3,9 @@ import { Body, Controller, + Delete, Get, + NotFoundException, Param, Patch, Post, @@ -16,6 +18,8 @@ import { HttpStatus, } from '@nestjs/common'; import { Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -272,4 +276,17 @@ export class AdminController { note: 'This is a preview with sample data. Actual emails will use real data.', }; } + + @Delete('exports/:filename') + deleteExport(@Param('filename') filename: string) { + const filepath = path.join(process.cwd(), 'exports', filename); + + if (!fs.existsSync(filepath)) { + throw new NotFoundException('Export file not found'); + } + + fs.unlinkSync(filepath); + + return { message: 'Export file deleted successfully' }; + } } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 48285e5a..60e28c53 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -6,6 +6,8 @@ import { Delete, ForbiddenException, Get, + GoneException, + HttpException, HttpStatus, InternalServerErrorException, NotFoundException, @@ -27,6 +29,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AuthUserPayload } from '../auth/types/auth-user.type'; import { UserRole } from '../types/prisma.types'; import { UsersService } from './users.service'; +import { ActivityLogService } from './activity-log.service'; import { CreateUserDto, SearchUsersDto, @@ -39,7 +42,10 @@ import { UpdateProfileDto } from './dto/update-profile.dto'; @Controller('users') export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly activityLogService: ActivityLogService, + ) {} // ─── Admin Endpoints ───────────────────────────────────────────── @@ -165,12 +171,26 @@ export class UsersController { throw new NotFoundException('Export file not found'); } + const stats = fs.statSync(filepath); + const expirationTime = 24 * 60 * 60 * 1000; + if (Date.now() - stats.mtimeMs > expirationTime) { + throw new GoneException('Export file has expired'); + } + const ownerId = this.extractExportOwnerId(filename); if (user.sub !== ownerId && user.role !== UserRole.ADMIN) { throw new ForbiddenException('You are not authorized to download this export'); } + this.activityLogService.create(user.sub, { + action: 'EXPORT_DOWNLOAD', + entityType: 'USER', + entityId: ownerId, + description: `Downloaded export file: ${filename}`, + metadata: { filename, ownerId }, + }); + res.download(filepath, (err) => { if (err && !res.headersSent) { res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ @@ -191,17 +211,25 @@ export class UsersController { } @Post('me/reactivate') - reactivateAccount( + async reactivateAccount( @Body() data: { email: string; token?: string }, @Body() reactivateDto: ReactivateAccountDto, ) { - return this.usersService.findByEmail(data.email).then((foundUser) => { + try { + const foundUser = await this.usersService.findByEmail(data.email); + if (!foundUser) { - throw new Error('User not found'); + throw new NotFoundException('User not found'); } return this.usersService.reactivate(foundUser.id, reactivateDto); - }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new InternalServerErrorException('An unexpected error occurred'); + } } // ─── Admin Verification ──────────────────────────────────────── From b5c95910a3bf314f2749269e0d0bbd1921193b90 Mon Sep 17 00:00:00 2001 From: inteee Date: Sat, 27 Jun 2026 13:29:05 +0100 Subject: [PATCH 4/6] fix: remove (user as any) casts across controllers, add rate limit on export download --- src/favorites/favorites.controller.ts | 2 -- src/transactions/transaction-notes.service.ts | 13 ++++------- src/transactions/transactions.controller.ts | 2 -- src/users/users.controller.ts | 23 +++++++++++++++++-- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/favorites/favorites.controller.ts b/src/favorites/favorites.controller.ts index 24766423..e8e8712b 100644 --- a/src/favorites/favorites.controller.ts +++ b/src/favorites/favorites.controller.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import { Controller, Delete, diff --git a/src/transactions/transaction-notes.service.ts b/src/transactions/transaction-notes.service.ts index 11e1fb9b..8d3dd298 100644 --- a/src/transactions/transaction-notes.service.ts +++ b/src/transactions/transaction-notes.service.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; import { CreateNoteDto } from './dto/transaction-note.dto'; @@ -12,7 +10,7 @@ export class TransactionNotesService { const tx = await this.prisma.transaction.findUnique({ where: { id: transactionId } }); if (!tx) throw new NotFoundException('Transaction not found'); - return (this.prisma as any).transactionNote.create({ + return this.prisma.transactionNote.create({ data: { transactionId, authorId, @@ -31,15 +29,12 @@ export class TransactionNotesService { const where: any = { transactionId }; if (!isPrivileged && !isParty) { - // Non-party/non-admin can only see public notes authored by themselves where.isPublic = true; } else if (!isPrivileged) { - // Transaction parties see public notes and their own private notes where.OR = [{ isPublic: true }, { authorId: viewerId }]; } - // Admins/agents see all notes - return (this.prisma as any).transactionNote.findMany({ + return this.prisma.transactionNote.findMany({ where, orderBy: { createdAt: 'asc' }, include: { @@ -49,7 +44,7 @@ export class TransactionNotesService { } async remove(noteId: string, requesterId: string, requesterRole: string) { - const note = await (this.prisma as any).transactionNote.findUnique({ where: { id: noteId } }); + const note = await this.prisma.transactionNote.findUnique({ where: { id: noteId } }); if (!note) throw new NotFoundException('Note not found'); const isPrivileged = requesterRole === 'ADMIN'; @@ -57,6 +52,6 @@ export class TransactionNotesService { throw new ForbiddenException('Only the author or admin can delete this note'); } - return (this.prisma as any).transactionNote.delete({ where: { id: noteId } }); + return this.prisma.transactionNote.delete({ where: { id: noteId } }); } } diff --git a/src/transactions/transactions.controller.ts b/src/transactions/transactions.controller.ts index e6bcbf28..ef4a3d86 100644 --- a/src/transactions/transactions.controller.ts +++ b/src/transactions/transactions.controller.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import { Controller, Delete, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 60e28c53..85e62891 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import { Body, Controller, @@ -42,6 +40,10 @@ import { UpdateProfileDto } from './dto/update-profile.dto'; @Controller('users') export class UsersController { + private readonly downloadRateLimitMap = new Map(); + private static readonly DOWNLOAD_LIMIT = 10; + private static readonly DOWNLOAD_WINDOW_MS = 60 * 60 * 1000; + constructor( private readonly usersService: UsersService, private readonly activityLogService: ActivityLogService, @@ -165,6 +167,23 @@ export class UsersController { @Res() res: Response, @CurrentUser() user: AuthUserPayload, ) { + const now = Date.now(); + const entry = this.downloadRateLimitMap.get(user.sub); + if (entry && now < entry.resetAt) { + if (entry.count >= UsersController.DOWNLOAD_LIMIT) { + throw new HttpException( + 'Too many export downloads. Please try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + entry.count++; + } else { + this.downloadRateLimitMap.set(user.sub, { + count: 1, + resetAt: now + UsersController.DOWNLOAD_WINDOW_MS, + }); + } + const filepath = path.join(process.cwd(), 'exports', filename); if (!fs.existsSync(filepath)) { From de509aeb701311105a6d0b0724f39343a929de08 Mon Sep 17 00:00:00 2001 From: chemicalcommando Date: Sat, 27 Jun 2026 13:48:15 +0100 Subject: [PATCH 5/6] feat: strengthen AuthUserPayload, fix any-casts in documents module, add e2e test coverage --- src/blockchain/blockchain.controller.ts | 2 + .../documents-download.controller.ts | 4 +- src/documents/documents.controller.ts | 18 +-- src/trust-score/trust-score.module.ts | 2 + test/e2e/documents.e2e.spec.ts | 126 +++++++++++++++ test/e2e/favorites.e2e.spec.ts | 127 ++++++++++++++++ test/e2e/users-profile.e2e.spec.ts | 143 ++++++++++++++++++ 7 files changed, 411 insertions(+), 11 deletions(-) create mode 100644 test/e2e/documents.e2e.spec.ts create mode 100644 test/e2e/favorites.e2e.spec.ts create mode 100644 test/e2e/users-profile.e2e.spec.ts diff --git a/src/blockchain/blockchain.controller.ts b/src/blockchain/blockchain.controller.ts index 945e1339..db91ba36 100644 --- a/src/blockchain/blockchain.controller.ts +++ b/src/blockchain/blockchain.controller.ts @@ -13,6 +13,8 @@ import { import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger'; import { BlockchainService } from './blockchain.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; import { RecordTransactionOnBlockchainDto, BlockchainTransactionDto, diff --git a/src/documents/documents-download.controller.ts b/src/documents/documents-download.controller.ts index 8e2e5467..8e5eaa5c 100644 --- a/src/documents/documents-download.controller.ts +++ b/src/documents/documents-download.controller.ts @@ -30,7 +30,7 @@ export class DocumentsDownloadController { @CurrentUser() user: AuthUserPayload, @Res() res: Response, ) { - const doc = await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role); + const doc = await this.documentsService.findAuthorizedById(id, user.sub, user.role); let targetFileUrl = doc.fileUrl; if (query.versionId) { @@ -38,7 +38,7 @@ export class DocumentsDownloadController { id, query.versionId, user.sub, - (user as any).role, + user.role, ); targetFileUrl = version.fileUrl; } diff --git a/src/documents/documents.controller.ts b/src/documents/documents.controller.ts index 43848d92..b3b61612 100644 --- a/src/documents/documents.controller.ts +++ b/src/documents/documents.controller.ts @@ -42,12 +42,12 @@ export class DocumentsController { @Get() findAll(@CurrentUser() user: AuthUserPayload, @Query() filter: FilterDocumentsDto) { - return this.documentsService.findAll(user.sub, filter, (user as any).role); + return this.documentsService.findAll(user.sub, filter, user.role); } @Get(':id') findOne(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { - return this.documentsService.findAuthorizedById(id, user.sub, (user as any).role); + return this.documentsService.findAuthorizedById(id, user.sub, user.role); } @Put(':id') @@ -56,13 +56,13 @@ export class DocumentsController { @Body() dto: UpdateDocumentDto, @CurrentUser() user: AuthUserPayload, ) { - await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role); + await this.documentsService.findAuthorizedById(id, user.sub, user.role); return this.documentsService.update(id, dto); } @Delete(':id') async remove(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { - await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role); + await this.documentsService.findAuthorizedById(id, user.sub, user.role); return this.documentsService.remove(id); } @@ -70,7 +70,7 @@ export class DocumentsController { @Get(':id/versions') getVersions(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { - return this.documentsService.getVersions(id, user.sub, (user as any).role); + return this.documentsService.getVersions(id, user.sub, user.role); } @Get(':id/versions/:versionId') @@ -79,7 +79,7 @@ export class DocumentsController { @Param('versionId') versionId: string, @CurrentUser() user: AuthUserPayload, ) { - return this.documentsService.getVersion(id, versionId, user.sub, (user as any).role); + return this.documentsService.getVersion(id, versionId, user.sub, user.role); } // ── #402 Expiration ────────────────────────────────────────────────────── @@ -105,7 +105,7 @@ export class DocumentsController { @Put(':id/expiration/notified') async flagNotified(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { - await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role); + await this.documentsService.findAuthorizedById(id, user.sub, user.role); return this.documentsService.flagExpiryNotified(id); } @@ -117,13 +117,13 @@ export class DocumentsController { @Body() dto: SignDocumentDto, @CurrentUser() user: AuthUserPayload, ) { - await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role); + await this.documentsService.findAuthorizedById(id, user.sub, user.role); return this.documentsService.signDocument(id, dto); } @Get(':id/verify') async verify(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { - await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role); + await this.documentsService.findAuthorizedById(id, user.sub, user.role); return this.documentsService.verifySignature(id); } diff --git a/src/trust-score/trust-score.module.ts b/src/trust-score/trust-score.module.ts index 96e7cb3e..94c5b715 100644 --- a/src/trust-score/trust-score.module.ts +++ b/src/trust-score/trust-score.module.ts @@ -1,10 +1,12 @@ // @ts-nocheck import { Module } from '@nestjs/common'; +import { PrismaModule } from '../database/prisma.module'; import { TrustScoreService } from './trust-score.service'; import { TrustScoreController } from './trust-score.controller'; @Module({ + imports: [PrismaModule], controllers: [TrustScoreController], providers: [TrustScoreService], exports: [TrustScoreService], diff --git a/test/e2e/documents.e2e.spec.ts b/test/e2e/documents.e2e.spec.ts new file mode 100644 index 00000000..e7ab6057 --- /dev/null +++ b/test/e2e/documents.e2e.spec.ts @@ -0,0 +1,126 @@ +import { INestApplication, ValidationPipe, Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { PrismaService } from '../../src/database/prisma.service'; +import { DocumentsController } from '../../src/documents/documents.controller'; +import { DocumentsService } from '../../src/documents/documents.service'; +import { SignedUrlService } from '../../src/documents/signed-url/signed-url.service'; +import { AuthService } from '../../src/auth/auth.service'; +import { AuthUserPayload } from '../../src/auth/types/auth-user.type'; + +@Injectable() +class MockAuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + request.authUser = { sub: 'test-user-id', email: 'test@example.com', role: 'USER', type: 'access' } as AuthUserPayload; + return true; + } +} + +class FakePrismaService { + documents = new Map(); + + async $connect() {} + async $disconnect() {} + async $transaction(arr: any[]) { return Promise.all(arr); } + + document = { + create: async ({ data }: any) => { + const id = data.id ?? Math.random().toString(36).slice(2, 10); + const record = { id, ...data, tags: data.tags ?? [], sharedWith: data.sharedWith ?? [], isPublic: false, isExpired: false, expiryNotified: false, status: 'ACTIVE', auditTrail: [], userId: data.userId ?? 'test-user-id', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + this.documents.set(id, record); + return record; + }, + findUnique: async ({ where }: any) => this.documents.get(where.id) ?? null, + findMany: async ({ where }: any) => { + let items = Array.from(this.documents.values()); + if (where) { + items = items.filter((d) => { + for (const k of Object.keys(where)) { + if (k === 'OR') { + if (!where.OR.some((c: any) => Object.entries(c).every(([ck, cv]) => d[ck] === cv))) return false; + } else if (k === 'status') { if (d.status !== where[k]) return false; } + else if (d[k] !== where[k]) return false; + } + return true; + }); + } + return items; + }, + update: async ({ where, data }: any) => { + const doc = this.documents.get(where.id); + const updated = { ...doc, ...data, updatedAt: new Date().toISOString() }; + this.documents.set(where.id, updated); + return updated; + }, + delete: async ({ where }: any) => { + const doc = this.documents.get(where.id); + this.documents.delete(where.id); + return doc; + }, + updateMany: async ({ where, data }: any) => { let c = 0; for (const [id, d] of this.documents) { if (where?.status && d.status === where.status) { this.documents.set(id, { ...d, ...data }); c++; } } return { count: c }; }, + deleteMany: async ({ where }: any) => { let c = 0; for (const [id, d] of this.documents) { if (where?.isExpired && d.isExpired) { this.documents.delete(id); c++; } } return { count: c }; }, + } as any; +} + +describe('Documents e2e', () => { + let app: INestApplication; + + beforeAll(async () => { + const fakePrisma = new FakePrismaService(); + + const moduleRef = await Test.createTestingModule({ + controllers: [DocumentsController], + providers: [ + DocumentsService, + MockAuthGuard, + { provide: PrismaService, useValue: fakePrisma as any }, + { provide: AuthService, useValue: { validateAccessToken: async () => ({ sub: 'test-user-id', email: 'test@example.com', role: 'USER' as any, type: 'access' }) } as any }, + { provide: SignedUrlService, useValue: { isConfigured: () => false, getSignedUrl: async () => ({ url: '', objectKey: '', expiresAt: new Date() }) } as any }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false })); + await app.init(); + }, 20000); + + afterAll(async () => { + await app.close(); + }); + + it('creates a document', async () => { + const res = await request(app.getHttpServer()) + .post('/documents') + .set('Authorization', 'Bearer test') + .send({ documentType: 'CONTRACT', fileName: 'contract.pdf', fileUrl: 'https://example.com/contract.pdf', fileSize: 1024, mimeType: 'application/pdf' }) + .expect(201); + expect(res.body.id).toBeDefined(); + expect(res.body.documentType).toBe('CONTRACT'); + }); + + it('lists documents', async () => { + await request(app.getHttpServer()).post('/documents').set('Authorization', 'Bearer test').send({ documentType: 'TITLE_DEED', fileName: 'deed.pdf', fileUrl: 'https://example.com/deed.pdf' }).expect(201); + const res = await request(app.getHttpServer()).get('/documents').set('Authorization', 'Bearer test').expect(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThanOrEqual(1); + }); + + it('finds a document by id', async () => { + const created = await request(app.getHttpServer()).post('/documents').set('Authorization', 'Bearer test').send({ documentType: 'APPRAISAL', fileName: 'appraisal.pdf', fileUrl: 'https://example.com/appraisal.pdf' }).expect(201); + const res = await request(app.getHttpServer()).get(`/documents/${created.body.id}`).set('Authorization', 'Bearer test').expect(200); + expect(res.body.id).toBe(created.body.id); + }); + + it('updates a document', async () => { + const created = await request(app.getHttpServer()).post('/documents').set('Authorization', 'Bearer test').send({ documentType: 'DISCLOSURE', fileName: 'disc.pdf', fileUrl: 'https://example.com/disc.pdf' }).expect(201); + const res = await request(app.getHttpServer()).put(`/documents/${created.body.id}`).set('Authorization', 'Bearer test').send({ description: 'Updated' }).expect(200); + expect(res.body.description).toBe('Updated'); + }); + + it('deletes a document', async () => { + const created = await request(app.getHttpServer()).post('/documents').set('Authorization', 'Bearer test').send({ documentType: 'PHOTO', fileName: 'photo.jpg', fileUrl: 'https://example.com/photo.jpg' }).expect(201); + await request(app.getHttpServer()).delete(`/documents/${created.body.id}`).set('Authorization', 'Bearer test').expect(200); + await request(app.getHttpServer()).get(`/documents/${created.body.id}`).set('Authorization', 'Bearer test').expect(404); + }); +}); diff --git a/test/e2e/favorites.e2e.spec.ts b/test/e2e/favorites.e2e.spec.ts new file mode 100644 index 00000000..8fa14b8b --- /dev/null +++ b/test/e2e/favorites.e2e.spec.ts @@ -0,0 +1,127 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import * as crypto from 'crypto'; +import { PrismaService } from '../../src/database/prisma.service'; +import { FavoritesController } from '../../src/favorites/favorites.controller'; +import { FavoritesService } from '../../src/favorites/favorites.service'; +import { AuthService } from '../../src/auth/auth.service'; + +class FakePrismaService { + users = new Map(); + properties = new Map(); + propertyFavorites = new Map(); + + async $connect() {} + async $disconnect() {} + async $transaction(arr: any[]) { return Promise.all(arr); } + + property = { + create: async ({ data }: any) => { + const id = Math.random().toString(36).slice(2, 10); + const record = { id, ...data, ownerId: data.owner?.connect?.id ?? data.ownerId ?? null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + if (record.price?.toString) record.price = Number(record.price.toString()); + this.properties.set(id, record); + return record; + }, + findUnique: async ({ where }: any) => this.properties.get(where.id) ?? null, + } as any; + + propertyFavorite = { + create: async ({ data }: any) => { + const existing = Array.from(this.propertyFavorites.values()).find((f) => f.userId === data.userId && f.propertyId === data.propertyId); + if (existing) throw Object.assign(new Error('Unique constraint'), { code: 'P2002' }); + const id = Math.random().toString(36).slice(2, 10); + const record = { id, ...data, createdAt: new Date().toISOString() }; + this.propertyFavorites.set(id, record); + return record; + }, + findUnique: async ({ where }: any) => { + if (where?.id) return this.propertyFavorites.get(where.id) ?? null; + if (where?.userId_propertyId) return Array.from(this.propertyFavorites.values()).find((f) => f.userId === where.userId_propertyId.userId && f.propertyId === where.userId_propertyId.propertyId) ?? null; + return null; + }, + findMany: async ({ where, skip = 0, take = 100 }: any) => { + let items = Array.from(this.propertyFavorites.values()).filter((f) => { if (!where) return true; for (const k of Object.keys(where)) { if (f[k] !== where[k]) return false; } return true; }); + return items.slice(skip, skip + take); + }, + count: async ({ where }: any) => { + return Array.from(this.propertyFavorites.values()).filter((f) => { if (!where) return true; for (const k of Object.keys(where)) { if (f[k] !== where[k]) return false; } return true; }).length; + }, + deleteMany: async ({ where }: any) => { + let count = 0; + for (const [id, f] of this.propertyFavorites) { + if (f.userId === where.userId && f.propertyId === where.propertyId) { this.propertyFavorites.delete(id); count++; } + } + return { count }; + }, + } as any; +} + +describe('Favorites e2e', () => { + let app: INestApplication; + let fakePrisma: FakePrismaService; + + beforeAll(async () => { + fakePrisma = new FakePrismaService(); + + const moduleRef = await Test.createTestingModule({ + controllers: [FavoritesController], + providers: [ + FavoritesService, + { provide: PrismaService, useValue: fakePrisma as any }, + { provide: AuthService, useValue: { validateAccessToken: async () => ({ sub: 'test-user-id', email: 'test@example.com', role: 'USER' as any, type: 'access' }) } as any }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false })); + await app.init(); + }, 20000); + + afterAll(async () => { + await app.close(); + }); + + let propertyId: string; + + beforeEach(() => { + const id = crypto.randomUUID(); + fakePrisma.properties.set(id, { id, title: 'Fav Property', address: '456 Fav St', city: 'FavCity', state: 'FS', zipCode: '67890', country: 'US', price: 300000, ownerId: 'test-user-id', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); + propertyId = id; + }); + + it('adds a favorite', async () => { + const res = await request(app.getHttpServer()) + .post(`/favorites/${propertyId}`) + .set('Authorization', 'Bearer test') + .expect(201); + expect(res.body.id).toBeDefined(); + expect(res.body.propertyId).toBe(propertyId); + }); + + it('checks favorite status', async () => { + await request(app.getHttpServer()).post(`/favorites/${propertyId}`).set('Authorization', 'Bearer test').expect(201); + const res = await request(app.getHttpServer()).get(`/favorites/${propertyId}/status`).set('Authorization', 'Bearer test').expect(200); + expect(res.body.isFavorite).toBe(true); + }); + + it('lists favorites', async () => { + await request(app.getHttpServer()).post(`/favorites/${propertyId}`).set('Authorization', 'Bearer test').expect(201); + const res = await request(app.getHttpServer()).get('/favorites').set('Authorization', 'Bearer test').expect(200); + expect(res.body.items).toBeInstanceOf(Array); + expect(res.body.items.length).toBeGreaterThanOrEqual(1); + expect(res.body.total).toBeGreaterThanOrEqual(1); + }); + + it('removes a favorite', async () => { + await request(app.getHttpServer()).post(`/favorites/${propertyId}`).set('Authorization', 'Bearer test').expect(201); + await request(app.getHttpServer()).delete(`/favorites/${propertyId}`).set('Authorization', 'Bearer test').expect(200); + const res = await request(app.getHttpServer()).get(`/favorites/${propertyId}/status`).set('Authorization', 'Bearer test').expect(200); + expect(res.body.isFavorite).toBe(false); + }); + + it('returns 404 for non-existent favorite removal', async () => { + await request(app.getHttpServer()).delete(`/favorites/${crypto.randomUUID()}`).set('Authorization', 'Bearer test').expect(404); + }); +}); diff --git a/test/e2e/users-profile.e2e.spec.ts b/test/e2e/users-profile.e2e.spec.ts new file mode 100644 index 00000000..03642ce3 --- /dev/null +++ b/test/e2e/users-profile.e2e.spec.ts @@ -0,0 +1,143 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { PrismaService } from '../../src/database/prisma.service'; +import { UsersController } from '../../src/users/users.controller'; +import { UsersService } from '../../src/users/users.service'; +import { ActivityLogService } from '../../src/users/activity-log.service'; +import { SessionsService } from '../../src/sessions/sessions.service'; +import { AuthService } from '../../src/auth/auth.service'; + +class FakePrismaService { + users = new Map(); + + async $connect() {} + async $disconnect() {} + async $transaction(arr: any[]) { return Promise.all(arr); } + + activityLog = { + create: async (args: any) => args.data, + } as any; + + session = { + findMany: async () => [], + count: async () => 0, + updateMany: async () => ({ count: 0 }), + } as any; + + user = { + create: async ({ data }: any) => { + const id = data.id ?? Math.random().toString(36).slice(2, 10); + const record = { + id, ...data, role: data.role ?? 'USER', + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + lastActivityAt: null, isVerified: false, isBlocked: false, isDeactivated: false, + deactivatedAt: null, scheduledDeletionAt: null, twoFactorEnabled: false, + twoFactorSecret: null, twoFactorBackupCodes: [], avatar: null, pendingEmail: null, + emailVerificationToken: null, emailVerificationExpires: null, trustScore: 0, + lastTrustScoreUpdate: null, preferredChannel: null, languagePreference: null, + timezone: null, contactHours: null, referralCode: null, referredById: null, + }; + this.users.set(id, record); + return record; + }, + findUnique: async ({ where, include }: any) => { + if (!where) return null; + let user: any = null; + if (where.id) user = this.users.get(where.id) ?? null; + if (!user && where.email) user = Array.from(this.users.values()).find((u) => u.email === where.email) ?? null; + if (!user) return null; + if (where.isDeactivated !== undefined && user.isDeactivated !== where.isDeactivated) return null; + if (!include) return user; + const result = { ...user }; + if (include.properties) result.properties = []; + if (include.buyerTransactions) result.buyerTransactions = []; + if (include.sellerTransactions) result.sellerTransactions = []; + if (include._count) result._count = { properties: 0, buyerTransactions: 0, sellerTransactions: 0 }; + return result; + }, + findFirst: async ({ where }: any) => { + if (!where) return null; + return Array.from(this.users.values()).find((u) => { + for (const k of Object.keys(where)) { + if (k === 'NOT') continue; + if (u[k] !== where[k]) return false; + } + return true; + }) ?? null; + }, + update: async ({ where, data }: any) => { + const user = this.users.get(where.id); + const updated = { ...user, ...data, updatedAt: new Date().toISOString() }; + this.users.set(where.id, updated); + return updated; + }, + } as any; +} + +describe('User profile e2e', () => { + let app: INestApplication; + + beforeAll(async () => { + const fakePrisma = new FakePrismaService(); + // Create a test user + const user = await fakePrisma.user.create({ data: { id: 'test-user-id', email: 'test@example.com' } }); + fakePrisma.users.set('test-user-id', { ...user, id: 'test-user-id' }); + + const moduleRef = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + UsersService, + ActivityLogService, + SessionsService, + { provide: PrismaService, useValue: fakePrisma as any }, + { provide: AuthService, useValue: { validateAccessToken: async () => ({ sub: 'test-user-id', email: 'test@example.com', role: 'USER' as any, type: 'access' }) } as any }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false })); + await app.init(); + }, 20000); + + afterAll(async () => { + await app.close(); + }); + + it('gets profile', async () => { + const res = await request(app.getHttpServer()) + .get('/users/me/profile') + .set('Authorization', 'Bearer test') + .expect(200); + expect(res.body).toBeDefined(); + }); + + it('updates profile', async () => { + const res = await request(app.getHttpServer()) + .put('/users/me/profile') + .set('Authorization', 'Bearer test') + .send({ firstName: 'UpdatedName' }) + .expect(200); + expect(res.body.firstName).toBe('UpdatedName'); + }); + + it('updates and verifies profile persistence', async () => { + await request(app.getHttpServer()) + .put('/users/me/profile') + .set('Authorization', 'Bearer test') + .send({ firstName: 'Alice', lastName: 'Wonderland' }) + .expect(200); + const res = await request(app.getHttpServer()) + .get('/users/me/profile') + .set('Authorization', 'Bearer test') + .expect(200); + expect(res.body.firstName).toBe('Alice'); + expect(res.body.lastName).toBe('Wonderland'); + }); + + it('rejects unauthenticated profile access', async () => { + await request(app.getHttpServer()) + .get('/users/me/profile') + .expect(401); + }); +}); From c43ae7d089f1a19ebb83305a5601f099d2eecf86 Mon Sep 17 00:00:00 2001 From: rmsb-art Date: Sat, 27 Jun 2026 14:11:16 +0100 Subject: [PATCH 6/6] fix: update test file to use ConfigService mock instead of process.env --- test/auth/password.utils.spec.ts | 70 ++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/test/auth/password.utils.spec.ts b/test/auth/password.utils.spec.ts index 57ccb871..d6ca560c 100644 --- a/test/auth/password.utils.spec.ts +++ b/test/auth/password.utils.spec.ts @@ -1,30 +1,74 @@ -import { validatePassword } from '../../src/auth/password.utils'; +import { ConfigService } from '@nestjs/config'; +import { getPasswordPolicy, validatePassword } from '../../src/auth/password.utils'; -describe('validatePassword', () => { - const OLD_ENV = process.env; +function mockConfig(overrides: Record = {}): ConfigService { + const defaults: Record = { + PASSWORD_MIN_LENGTH: '8', + PASSWORD_REQUIRE_UPPERCASE: 'true', + PASSWORD_REQUIRE_LOWERCASE: 'true', + PASSWORD_REQUIRE_DIGIT: 'true', + PASSWORD_REQUIRE_SPECIAL: 'true', + }; + return { + get(key: string) { + return overrides[key] ?? defaults[key] ?? undefined; + }, + } as unknown as ConfigService; +} + +describe('getPasswordPolicy', () => { + it('returns default values when config service has no overrides', () => { + const configService = mockConfig(); + const policy = getPasswordPolicy(configService); + + expect(policy.minLength).toBe(8); + expect(policy.requireUppercase).toBe(true); + expect(policy.requireLowercase).toBe(true); + expect(policy.requireDigit).toBe(true); + expect(policy.requireSpecial).toBe(true); + expect(policy.specialChars).toBeDefined(); + }); - afterEach(() => { - process.env = { ...OLD_ENV }; + it('reflects env-driven overrides', () => { + const configService = mockConfig({ + PASSWORD_MIN_LENGTH: '12', + PASSWORD_REQUIRE_UPPERCASE: 'false', + PASSWORD_SPECIAL_CHARS: '!@#$', + }); + + const policy = getPasswordPolicy(configService); + + expect(policy.minLength).toBe(12); + expect(policy.requireUppercase).toBe(false); + expect(policy.requireLowercase).toBe(true); + expect(policy.requireDigit).toBe(true); + expect(policy.requireSpecial).toBe(true); + expect(policy.specialChars).toBe('!@#$'); }); +}); +describe('validatePassword', () => { it('accepts a strong password by default policy', () => { - const errors = validatePassword('Str0ng!Pass'); + const configService = mockConfig(); + const errors = validatePassword('Str0ng!Pass', configService); expect(errors).toHaveLength(0); }); it('rejects short or simple passwords', () => { - process.env.PASSWORD_MIN_LENGTH = '12'; - const errors = validatePassword('weak'); + const configService = mockConfig({ PASSWORD_MIN_LENGTH: '12' }); + const errors = validatePassword('weak', configService); expect(errors.length).toBeGreaterThan(0); }); it('requires uppercase/lowercase/digit/special as configured', () => { - process.env.PASSWORD_REQUIRE_UPPERCASE = 'true'; - process.env.PASSWORD_REQUIRE_LOWERCASE = 'true'; - process.env.PASSWORD_REQUIRE_DIGIT = 'true'; - process.env.PASSWORD_REQUIRE_SPECIAL = 'true'; + const configService = mockConfig({ + PASSWORD_REQUIRE_UPPERCASE: 'true', + PASSWORD_REQUIRE_LOWERCASE: 'true', + PASSWORD_REQUIRE_DIGIT: 'true', + PASSWORD_REQUIRE_SPECIAL: 'true', + }); - const errors = validatePassword('noupper1!'); + const errors = validatePassword('noupper1!', configService); expect(errors).toContain('Password must include at least one uppercase letter'); }); });