diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9e6fb4b..758b136 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -45,9 +45,7 @@ import { SplitHistoryModule } from "./split-history/split-history.module"; import { SplitTemplateModule } from "./split-template/split-template.module"; import { StellarModule } from "./stellar/stellar.module"; import { TemplatesModule } from "./templates/templates.module"; -import { UploadModule } from "./uploads/upload.module"; -import { ShortLinksModule } from "./short-links/short-links.module"; -import { WebhooksModule } from "./webhooks/webhooks.module"; +import { FraudDetectionModule } from "./fraud-detection/fraud-detection.module"; // Duplicate imports removed; already imported above. // Load environment variables dotenv.config({ @@ -133,6 +131,7 @@ dotenv.config({ CollaborationModule, DashboardModule, ShortLinksModule, + FraudDetectionModule, // Duplicated modules were already included earlier. ], }) diff --git a/backend/src/compliance/compliance.controller.ts b/backend/src/compliance/compliance.controller.ts index 3e72e4b..edd0908 100644 --- a/backend/src/compliance/compliance.controller.ts +++ b/backend/src/compliance/compliance.controller.ts @@ -1,16 +1,24 @@ -import { Controller, Post, Get, Put, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { Controller, Post, Get, Put, Body, Param, Query, UseGuards, Req } from '@nestjs/common'; import { ComplianceService } from './compliance.service'; import { ExpenseCategory } from './entities/expense-category.entity'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AuthorizationGuard } from '../auth/guards/authorization.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; + +interface AuthRequest { + user: { walletAddress: string }; +} @Controller('api/compliance') +@UseGuards(JwtAuthGuard, AuthorizationGuard) export class ComplianceController { constructor(private readonly complianceService: ComplianceService) { } @Post('export/request') - async requestExport(@Body() data: any) { - // In a real app, userId would come from auth guard - const userId = data.userId; - return this.complianceService.requestExport(userId, data); + @RequirePermissions(Permissions.CAN_CREATE_EXPORT) + async requestExport(@Body() data: any, @Req() req: AuthRequest) { + return this.complianceService.requestExport(req.user.walletAddress, data); } @Get('export/:requestId/status') @@ -19,28 +27,32 @@ export class ComplianceController { } @Get('categories') - async getCategories(@Query('userId') userId: string) { - return this.complianceService.getCategories(userId); + @RequirePermissions(Permissions.CAN_READ_EXPORT) + async getCategories(@Req() req: AuthRequest) { + return this.complianceService.getCategories(req.user.walletAddress); } @Post('categories') - async createCategory(@Body() data: any) { - const userId = data.userId; - return this.complianceService.createCategory(userId, data); + @RequirePermissions(Permissions.CAN_CREATE_EXPORT) + async createCategory(@Body() data: any, @Req() req: AuthRequest) { + return this.complianceService.createCategory(req.user.walletAddress, data); } @Put('splits/:splitId/category') + @RequirePermissions(Permissions.CAN_UPDATE_SPLIT) async assignCategory(@Param('splitId') splitId: string, @Body('categoryId') categoryId: string) { return this.complianceService.assignCategoryToSplit(splitId, categoryId); } @Get('summary') - async getSummary(@Query('userId') userId: string, @Query('year') year: string) { - return this.complianceService.getSummary(userId, parseInt(year)); + @RequirePermissions(Permissions.CAN_READ_EXPORT) + async getSummary(@Query('year') year: string, @Req() req: AuthRequest) { + return this.complianceService.getSummary(req.user.walletAddress, parseInt(year)); } - @Get('tax-deductible-total') - async getTaxDeductibleTotal(@Query('userId') userId: string, @Query('period') period: string) { - return this.complianceService.getTaxDeductibleTotal(userId, period); + @Get('export/:requestId/download') + @RequirePermissions(Permissions.CAN_READ_EXPORT) + async downloadExport(@Param('requestId') requestId: string, @Req() req: AuthRequest) { + return this.complianceService.downloadExport(requestId, req.user.walletAddress); } } diff --git a/backend/src/compliance/compliance.processor.ts b/backend/src/compliance/compliance.processor.ts index 6ef49ab..cf851ca 100644 --- a/backend/src/compliance/compliance.processor.ts +++ b/backend/src/compliance/compliance.processor.ts @@ -13,7 +13,7 @@ import { PDFExporterService } from "./exporters/pdf-exporter.service"; import { QBOExporterService } from "./exporters/qbo-exporter.service"; import { JSONExporterService } from "./exporters/json-exporter.service"; import { OFXExporterService } from "./exporters/ofx-exporter.service"; -import { EmailService } from "../email/email.service"; +import { ProfileService } from "../profile/profile.service"; import { Logger } from "@nestjs/common"; import * as fs from "fs"; import * as path from "path"; @@ -34,6 +34,7 @@ export class ComplianceProcessor { private jsonExporter: JSONExporterService, private ofxExporter: OFXExporterService, private emailService: EmailService, + private profileService: ProfileService, ) { if (!fs.existsSync(this.exportDir)) { fs.mkdirSync(this.exportDir); @@ -99,7 +100,7 @@ export class ComplianceProcessor { await this.exportRepo.update(requestId, { status: ExportStatus.READY, - fileUrl: filePath, // Using local path for simplicity in this implementation + fileUrl: `http://localhost:3000/api/compliance/export/${requestId}/download`, // Secure download URL fileSize: fs.statSync(filePath).size, recordCount: splits.length, completedAt: new Date(), @@ -107,17 +108,31 @@ export class ComplianceProcessor { }); // Send email notification - // In a real app, we'd look up the user's email by wallet address - // For now, we'll assume a dummy email or use a placeholder - await this.emailService["emailQueue"].add("sendEmail", { - to: "user@example.com", // Placeholder - type: "export_ready", - context: { - requestId, - format: request.exportFormat, - downloadUrl: `http://localhost:3000/api/compliance/export/${requestId}/download`, - }, - }); + try { + const profile = await this.profileService.getByWalletAddress(request.userId); + const userEmail = profile.email || 'user@example.com'; // fallback + await this.emailService["emailQueue"].add("sendEmail", { + to: userEmail, + type: "export_ready", + context: { + requestId, + format: request.exportFormat, + downloadUrl: `http://localhost:3000/api/compliance/export/${requestId}/download`, + }, + }); + } catch (error) { + this.logger.error(`Failed to get user email for export ${requestId}:`, error); + // Fallback to placeholder + await this.emailService["emailQueue"].add("sendEmail", { + to: "user@example.com", + type: "export_ready", + context: { + requestId, + format: request.exportFormat, + downloadUrl: `http://localhost:3000/api/compliance/export/${requestId}/download`, + }, + }); + } this.logger.log(`Export ${requestId} completed successfully`); } catch (error) { diff --git a/backend/src/compliance/compliance.service.spec.ts b/backend/src/compliance/compliance.service.spec.ts new file mode 100644 index 0000000..165e072 --- /dev/null +++ b/backend/src/compliance/compliance.service.spec.ts @@ -0,0 +1,86 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ComplianceService } from './compliance.service'; +import { TaxExportRequest, ExportStatus } from './entities/tax-export-request.entity'; +import { ExpenseCategory } from './entities/expense-category.entity'; +import { Split } from '../entities/split.entity'; +import { BullModule } from '@nestjs/bull'; + +describe('ComplianceService', () => { + let service: ComplianceService; + let exportRepo: Repository; + let categoryRepo: Repository; + let splitRepo: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [BullModule.registerQueue({ name: 'compliance-export' })], + providers: [ + ComplianceService, + { + provide: getRepositoryToken(TaxExportRequest), + useClass: Repository, + }, + { + provide: getRepositoryToken(ExpenseCategory), + useClass: Repository, + }, + { + provide: getRepositoryToken(Split), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(ComplianceService); + exportRepo = module.get>(getRepositoryToken(TaxExportRequest)); + categoryRepo = module.get>(getRepositoryToken(ExpenseCategory)); + splitRepo = module.get>(getRepositoryToken(Split)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('requestExport', () => { + it('should create export request with QUEUED status', async () => { + const mockRequest = { + id: 'test-id', + userId: 'user-1', + exportFormat: 'CSV', + periodStart: new Date('2023-01-01'), + periodEnd: new Date('2023-12-31'), + status: ExportStatus.QUEUED, + }; + jest.spyOn(exportRepo, 'create').mockReturnValue(mockRequest as any); + jest.spyOn(exportRepo, 'save').mockResolvedValue(mockRequest as any); + + const result = await service.requestExport('user-1', { + exportFormat: 'CSV', + periodStart: '2023-01-01', + periodEnd: '2023-12-31', + }); + + expect(result.status).toBe(ExportStatus.QUEUED); + }); + }); + + describe('getExportStatus', () => { + it('should return READY status', async () => { + const mockRequest = { id: 'test-id', status: ExportStatus.READY }; + jest.spyOn(exportRepo, 'findOne').mockResolvedValue(mockRequest as any); + + const result = await service.getExportStatus('test-id'); + expect(result.status).toBe(ExportStatus.READY); + }); + + it('should return FAILED status', async () => { + const mockRequest = { id: 'test-id', status: ExportStatus.FAILED }; + jest.spyOn(exportRepo, 'findOne').mockResolvedValue(mockRequest as any); + + const result = await service.getExportStatus('test-id'); + expect(result.status).toBe(ExportStatus.FAILED); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/compliance/compliance.service.ts b/backend/src/compliance/compliance.service.ts index 50a24b8..23344dd 100644 --- a/backend/src/compliance/compliance.service.ts +++ b/backend/src/compliance/compliance.service.ts @@ -95,9 +95,34 @@ export class ComplianceService { return summary; } - async getTaxDeductibleTotal(userId: string, period: string) { - const year = parseInt(period); - const summary = await this.getSummary(userId, year); + async downloadExport(requestId: string, userId: string) { + const request = await this.exportRepo.findOne({ where: { id: requestId, userId } }); + if (!request) throw new NotFoundException('Export request not found or access denied'); + if (request.status !== ExportStatus.READY) throw new BadRequestException('Export not ready'); + + // Return the file content or stream + const fs = require('fs'); + const path = require('path'); + const filePath = path.join(process.cwd(), 'exports', `tax-export-${requestId}.${request.exportFormat.toLowerCase()}`); + if (!fs.existsSync(filePath)) throw new NotFoundException('File not found'); + + return { + fileName: `tax-export-${requestId}.${request.exportFormat.toLowerCase()}`, + content: fs.readFileSync(filePath), + mimeType: this.getMimeType(request.exportFormat), + }; + } + + private getMimeType(format: ExportFormat): string { + switch (format) { + case ExportFormat.CSV: return 'text/csv'; + case ExportFormat.PDF: return 'application/pdf'; + case ExportFormat.QBO: return 'application/octet-stream'; + case ExportFormat.JSON: return 'application/json'; + case ExportFormat.OFX: return 'application/xml'; + default: return 'application/octet-stream'; + } + } return Object.values(summary).reduce((acc, curr) => acc + curr.deductible, 0); } diff --git a/backend/src/fraud-detection/fraud-detection.controller.ts b/backend/src/fraud-detection/fraud-detection.controller.ts index 4b4f39b..1c5ae99 100644 --- a/backend/src/fraud-detection/fraud-detection.controller.ts +++ b/backend/src/fraud-detection/fraud-detection.controller.ts @@ -14,6 +14,10 @@ import { Repository } from "typeorm"; import { FraudDetectionService } from "./fraud-detection.service"; import { FraudAlert, AlertStatus } from "./entities/fraud-alert.entity"; +import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; +import { AuthorizationGuard } from "../auth/guards/authorization.guard"; +import { RequirePermissions } from "../auth/decorators/permissions.decorator"; +import { Permissions } from "../auth/decorators/permissions.decorator"; import { AnalyzeSplitRequestDto, AnalyzePaymentRequestDto, @@ -22,6 +26,7 @@ import { } from "./dto/analyze-split.dto"; @Controller("fraud") +@UseGuards(JwtAuthGuard, AuthorizationGuard) export class FraudDetectionController { constructor( private readonly fraudDetectionService: FraudDetectionService, @@ -33,6 +38,7 @@ export class FraudDetectionController { * Get all fraud alerts */ @Get("alerts") + @RequirePermissions(Permissions.CAN_READ_FRAUD_ALERTS) async getAlerts( @Query("status") status?: AlertStatus, @Query("page") page: number = 1, @@ -45,6 +51,7 @@ export class FraudDetectionController { * Get a single alert */ @Get("alerts/:id") + @RequirePermissions(Permissions.CAN_READ_SPLIT) async getAlert(@Param("id") id: string) { const alert = await this.fraudDetectionService.getAlert(id); if (!alert) { @@ -57,6 +64,7 @@ export class FraudDetectionController { * Resolve a fraud alert */ @Post("alerts/:id/resolve") + @RequirePermissions(Permissions.CAN_UPDATE_SPLIT) @HttpCode(HttpStatus.OK) async resolveAlert( @Param("id") id: string, @@ -70,6 +78,7 @@ export class FraudDetectionController { * Get analysis for a specific split */ @Get("splits/:id/analysis") + @RequirePermissions(Permissions.CAN_READ_SPLIT) async getSplitAnalysis(@Param("id") id: string) { return this.fraudDetectionService.getSplitAnalysis(id); } @@ -78,6 +87,7 @@ export class FraudDetectionController { * Get fraud detection statistics */ @Get("stats") + @RequirePermissions(Permissions.CAN_READ_SPLIT) async getStats() { return this.fraudDetectionService.getStats(); } diff --git a/backend/src/fraud-detection/fraud-detection.service.ts b/backend/src/fraud-detection/fraud-detection.service.ts index e165ff1..796f927 100644 --- a/backend/src/fraud-detection/fraud-detection.service.ts +++ b/backend/src/fraud-detection/fraud-detection.service.ts @@ -320,16 +320,8 @@ export class FraudDetectionService { where: { status: AlertStatus.FALSE_POSITIVE }, }); - // Calculate accuracy - const reviewed = await this.fraudAlertRepository.count({ - where: [{ status: AlertStatus.RESOLVED }, { status: AlertStatus.FALSE_POSITIVE }], - }); - - const truePositives = await this.fraudAlertRepository.count({ - where: { is_true_positive: true }, - }); - - const accuracy = reviewed > 0 ? (truePositives + falsePositives) / reviewed : 0; + // Calculate accuracy as precision: true positives / (true positives + false positives) + const accuracy = reviewed > 0 ? truePositives / reviewed : 0; return { totalAlerts: total, diff --git a/backend/src/modules/splits/splits.service.ts b/backend/src/modules/splits/splits.service.ts index c7d5e8f..d1bd2a2 100644 --- a/backend/src/modules/splits/splits.service.ts +++ b/backend/src/modules/splits/splits.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Split } from '../../entities/split.entity'; @@ -7,6 +7,8 @@ import { Participant } from '../../entities/participant.entity'; import { Receipt } from '../../receipts/entities/receipt.entity'; import { OcrService } from '../../ocr/ocr.service'; import { SplitCalculationService } from './split-calculation.service'; +import { FraudDetectionService } from '../../fraud-detection/fraud-detection.service'; +import { AnalyzeSplitRequestDto } from '../../fraud-detection/dto/analyze-split.dto'; import { CreateSplitDto, UpdateSplitDto, @@ -17,6 +19,8 @@ import { @Injectable() export class SplitsService { + private readonly logger = new Logger(SplitsService.name); + constructor( @InjectRepository(Split) private readonly splitRepository: Repository, @@ -28,6 +32,7 @@ export class SplitsService { private readonly receiptRepository: Repository, private readonly ocrService: OcrService, private readonly splitCalculationService: SplitCalculationService, + private readonly fraudDetectionService: FraudDetectionService, ) {} /** @@ -54,6 +59,30 @@ export class SplitsService { await this.createItems(savedSplit.id, createSplitDto.items); } + // Perform fraud detection check + try { + const fraudRequest: AnalyzeSplitRequestDto = { + split_data: { + split_id: savedSplit.id, + creator_id: createSplitDto.creatorWalletAddress, + total_amount: createSplitDto.totalAmount, + participant_count: createSplitDto.participants?.length || 0, + description: createSplitDto.description, + preferred_currency: createSplitDto.preferredCurrency || 'XLM', + creator_wallet_address: createSplitDto.creatorWalletAddress, + created_at: savedSplit.createdAt, + }, + }; + const fraudResult = await this.fraudDetectionService.checkSplit(fraudRequest); + if (!fraudResult.allowed) { + // Log the block, but still allow the split for now (or throw error) + this.logger.warn(`Split ${savedSplit.id} blocked due to fraud risk: ${fraudResult.riskLevel}`); + } + } catch (error) { + // Log but don't fail the split creation + this.logger.error(`Fraud detection failed for split ${savedSplit.id}:`, error); + } + return this.getSplitById(savedSplit.id); } diff --git a/backend/src/payments/payment-processor.service.ts b/backend/src/payments/payment-processor.service.ts index dfd3f4f..ddf086a 100644 --- a/backend/src/payments/payment-processor.service.ts +++ b/backend/src/payments/payment-processor.service.ts @@ -20,7 +20,7 @@ import { Split } from "../entities/split.entity"; import { EmailService } from "../email/email.service"; import { MultiCurrencyService } from "../multi-currency/multi-currency.service"; import { EventsGateway } from "../gateway/events.gateway"; -import { AnalyticsService } from "@/analytics/analytics.service"; +import { FraudDetectionService, AnalyzePaymentRequestDto } from '../fraud-detection/fraud-detection.service'; import * as crypto from "crypto"; /** @@ -81,6 +81,7 @@ export class PaymentProcessorService { private readonly multiCurrencyService: MultiCurrencyService, private readonly dataSource: DataSource, @Optional() private readonly analyticsService?: AnalyticsService, + @Optional() private readonly fraudDetectionService?: FraudDetectionService, @Optional() private readonly customConfig?: Partial, ) { this.config = { ...DEFAULT_CONFIG, ...customConfig }; @@ -236,6 +237,37 @@ export class PaymentProcessorService { } } + // Perform fraud detection check + if (this.fraudDetectionService) { + try { + const fraudRequest: AnalyzePaymentRequestDto = { + payment_data: { + payment_id: key, // use idempotency key as temp id + split_id: splitId, + participant_id: participantId, + amount: receivedAmount, + asset: receivedAsset, + tx_hash: txHash, + sender_address: verificationResult.sender || '', + receiver_address: verificationResult.receiver || '', + timestamp: new Date(), + }, + }; + const fraudResult = await this.fraudDetectionService.checkPayment(fraudRequest); + if (!fraudResult.allowed) { + this.logger.warn(`Payment blocked due to fraud risk: ${fraudResult.riskLevel} for split ${splitId}`); + await queryRunner.rollbackTransaction(); + throw new BadRequestException('Payment blocked due to fraud risk'); + } + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + // Log but don't fail the payment + this.logger.error(`Fraud detection failed for payment in split ${splitId}:`, error); + } + } + // Determine payment status based on amount let paymentStatus: PaymentProcessingStatus; let settlementStatus: PaymentSettlementStatus; diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts index 0cbbe58..6c3f36d 100644 --- a/backend/src/payments/payments.service.ts +++ b/backend/src/payments/payments.service.ts @@ -6,7 +6,7 @@ import { Participant } from '../entities/participant.entity'; import { Split } from '../entities/split.entity'; import { StellarService } from '../stellar/stellar.service'; import { PaymentProcessorService } from './payment-processor.service'; -import { PaymentGateway } from '../websocket/payment.gateway'; +import { FraudDetectionService, AnalyzePaymentRequestDto } from '../fraud-detection/fraud-detection.service'; @Injectable() export class PaymentsService { @@ -19,6 +19,7 @@ export class PaymentsService { private readonly stellarService: StellarService, private readonly paymentProcessorService: PaymentProcessorService, private readonly paymentGateway: PaymentGateway, + private readonly fraudDetectionService: FraudDetectionService, ) { } /** diff --git a/backend/src/profile/profile.entity.ts b/backend/src/profile/profile.entity.ts index c33f6e1..ce9e73c 100644 --- a/backend/src/profile/profile.entity.ts +++ b/backend/src/profile/profile.entity.ts @@ -18,8 +18,8 @@ export class UserProfile { @PrimaryColumn({ type: 'varchar', length: 56, name: 'wallet_address' }) walletAddress!: string; - @Column({ type: 'varchar', length: 255, nullable: true, name: 'display_name' }) - displayName!: string | null; + @Column({ type: 'varchar', length: 255, nullable: true, name: 'email' }) + email!: string | null; @Column({ type: 'varchar', length: 2048, nullable: true, name: 'avatar_url' }) avatarUrl!: string | null; diff --git a/backend/src/uploads/upload.controller.ts b/backend/src/uploads/upload.controller.ts index 1a70475..75e4176 100644 --- a/backend/src/uploads/upload.controller.ts +++ b/backend/src/uploads/upload.controller.ts @@ -1,30 +1,54 @@ -import { Controller, Post, Body, Get, Param, Logger, UseFilters } from '@nestjs/common'; +import { Controller, Post, Body, Get, Param, Logger, UseFilters, BadRequestException, UseGuards, Req } from '@nestjs/common'; import { UploadService } from './upload.service'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AuthorizationGuard } from '../auth/guards/authorization.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; + +interface AuthRequest { + user: { walletAddress: string }; +} @ApiTags('uploads') @Controller('uploads') +@UseGuards(JwtAuthGuard, AuthorizationGuard) export class UploadController { private readonly logger = new Logger(UploadController.name); constructor(private readonly uploadService: UploadService) { } @Post('presigned-url') + @RequirePermissions(Permissions.CAN_CREATE_RECEIPT) @ApiOperation({ summary: 'Get a presigned URL for file upload' }) @ApiResponse({ status: 201, description: 'Presigned URL generated successfully' }) async getPresignedUrl( - @Body() body: { fileName: string; contentType: string }, + @Body() body: { fileName: string; contentType: string; fileSize?: number }, + @Req() req: AuthRequest, ) { - this.logger.log(`Requesting presigned upload URL for ${body.fileName}`); - return await this.uploadService.getPresignedUploadUrl(body.fileName, body.contentType); + if (!body.fileName || !body.contentType) { + throw new BadRequestException('fileName and contentType are required'); + } + this.logger.log(`Requesting presigned upload URL for ${body.fileName} by user ${req.user.walletAddress}`); + return await this.uploadService.getPresignedUploadUrl(body.fileName, body.contentType, body.fileSize); } - @Get('download-url/:key') + @Get('download-url/:encodedKey') + @RequirePermissions(Permissions.CAN_READ_RECEIPT) @ApiOperation({ summary: 'Get a presigned URL for file download' }) @ApiResponse({ status: 200, description: 'Presigned URL generated successfully' }) - async getDownloadUrl(@Param('key') key: string) { - this.logger.log(`Requesting presigned download URL for key ${key}`); - const url = await this.uploadService.getPresignedDownloadUrl(key); - return { url }; + async getDownloadUrl(@Param('encodedKey') encodedKey: string, @Req() req: AuthRequest) { + try { + const key = Buffer.from(encodedKey, 'base64').toString('utf-8'); + // Basic validation to ensure it's a receipts key + if (!key.startsWith('receipts/')) { + throw new BadRequestException('Invalid key'); + } + this.logger.log(`Requesting presigned download URL for key ${key} by user ${req.user.walletAddress}`); + const url = await this.uploadService.getPresignedDownloadUrl(key); + return { url }; + } catch (error) { + throw new BadRequestException('Invalid encoded key'); + } } } diff --git a/backend/src/uploads/upload.service.spec.ts b/backend/src/uploads/upload.service.spec.ts new file mode 100644 index 0000000..9e8ac90 --- /dev/null +++ b/backend/src/uploads/upload.service.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { UploadService } from './upload.service'; +import { BadRequestException } from '@nestjs/common'; + +describe('UploadService', () => { + let service: UploadService; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UploadService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + const config = { + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'test-key', + AWS_SECRET_ACCESS_KEY: 'test-secret', + S3_BUCKET_NAME: 'test-bucket', + S3_ENDPOINT: 'http://localhost:4566', + }; + return config[key]; + }), + }, + }, + ], + }).compile(); + + service = module.get(UploadService); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getPresignedUploadUrl', () => { + it('should validate allowed mime types', async () => { + await expect( + service.getPresignedUploadUrl('test.jpg', 'image/invalid'), + ).rejects.toThrow(BadRequestException); + }); + + it('should validate file size', async () => { + const largeSize = 15 * 1024 * 1024; // 15MB + await expect( + service.getPresignedUploadUrl('test.jpg', 'image/jpeg', largeSize), + ).rejects.toThrow(BadRequestException); + }); + + it('should sanitize filename', async () => { + // Mock the S3 client to avoid actual calls + const mockS3Client = { + send: jest.fn(), + }; + (service as any).s3Client = mockS3Client; + + // This would normally call getSignedUrl, but we'll mock it + jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn().mockResolvedValue('http://presigned-url'), + })); + + const result = await service.getPresignedUploadUrl('test