From cb6114b1d95ed2d8daf6893ebdac724e595f1f27 Mon Sep 17 00:00:00 2001 From: nottherealalanturing Date: Sat, 27 Jun 2026 13:19:52 +0100 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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 b0abb9ad63813a540820a89786e5b8d1f472d44d Mon Sep 17 00:00:00 2001 From: rmsb-art Date: Sat, 27 Jun 2026 14:10:47 +0100 Subject: [PATCH 5/5] 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'); }); });