diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index d143bd3..f630630 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 0e53277..deaa402 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 a8b965b..7512d7a 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 57e5163..f88b422 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 7f137eb..8e46be0 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 0000000..659fab1 --- /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 0000000..6b1a3bd --- /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 0000000..da4110e --- /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 09c7b33..1bf6ad8 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); }); });