From 4143c4dcb94f698d77440a918139301d56dfb733 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 18 Apr 2026 14:34:30 +0700 Subject: [PATCH] feat(auth): commit KYC presigned-upload DTOs + presentation tests (TEC-2750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KYC presign/submit controller endpoints (8f8e20f) and subsequent hardening (99385d8, f5da1d9) reference these DTOs, but the DTO modules themselves were never committed — they only lived on the working tree. Security Engineer flagged the blocker on TEC-2750. - Commit SubmitKycDto and GenerateKycUploadUrlsDto so auth.controller builds from a clean checkout. - Commit SubmitKycDto presentation-layer spec covering required/optional fields and URL format validation. - Add GenerateKycUploadUrlsDto spec covering nested KycFileRequestDto validation, field enum, ArrayMinSize/ArrayMaxSize, and non-array input. Co-Authored-By: Claude Opus 4.7 --- .../generate-kyc-upload-urls.dto.spec.ts | 140 ++++++++++++++++++ .../__tests__/submit-kyc.dto.spec.ts | 107 +++++++++++++ .../dto/generate-kyc-upload-urls.dto.ts | 51 +++++++ .../auth/presentation/dto/submit-kyc.dto.ts | 41 +++++ 4 files changed, 339 insertions(+) create mode 100644 apps/api/src/modules/auth/presentation/__tests__/generate-kyc-upload-urls.dto.spec.ts create mode 100644 apps/api/src/modules/auth/presentation/__tests__/submit-kyc.dto.spec.ts create mode 100644 apps/api/src/modules/auth/presentation/dto/generate-kyc-upload-urls.dto.ts create mode 100644 apps/api/src/modules/auth/presentation/dto/submit-kyc.dto.ts diff --git a/apps/api/src/modules/auth/presentation/__tests__/generate-kyc-upload-urls.dto.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/generate-kyc-upload-urls.dto.spec.ts new file mode 100644 index 0000000..86e3b1b --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/generate-kyc-upload-urls.dto.spec.ts @@ -0,0 +1,140 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { + GenerateKycUploadUrlsDto, + KycFileRequestDto, +} from '../dto/generate-kyc-upload-urls.dto'; + +const validFile = (): KycFileRequestDto => { + const file = new KycFileRequestDto(); + file.field = 'frontImage'; + file.mimeType = 'image/jpeg'; + file.fileName = 'cccd-front.jpg'; + return file; +}; + +describe('KycFileRequestDto', () => { + it('accepts valid payload', async () => { + const errors = await validate(validFile()); + expect(errors).toHaveLength(0); + }); + + it.each(['frontImage', 'backImage', 'selfieImage'] as const)( + 'accepts allowed field value: %s', + async (value) => { + const file = validFile(); + file.field = value; + const errors = await validate(file); + expect(errors).toHaveLength(0); + }, + ); + + it('rejects unknown field enum value', async () => { + const file = validFile(); + (file as unknown as { field: string }).field = 'backgroundImage'; + const errors = await validate(file); + expect(errors.some((e) => e.property === 'field')).toBe(true); + }); + + it('rejects empty mimeType', async () => { + const file = validFile(); + file.mimeType = ''; + const errors = await validate(file); + expect(errors.some((e) => e.property === 'mimeType')).toBe(true); + }); + + it('rejects non-string mimeType', async () => { + const file = validFile(); + (file as unknown as { mimeType: unknown }).mimeType = 123; + const errors = await validate(file); + expect(errors.some((e) => e.property === 'mimeType')).toBe(true); + }); + + it('rejects empty fileName', async () => { + const file = validFile(); + file.fileName = ''; + const errors = await validate(file); + expect(errors.some((e) => e.property === 'fileName')).toBe(true); + }); + + it('rejects non-string fileName', async () => { + const file = validFile(); + (file as unknown as { fileName: unknown }).fileName = null; + const errors = await validate(file); + expect(errors.some((e) => e.property === 'fileName')).toBe(true); + }); +}); + +describe('GenerateKycUploadUrlsDto', () => { + const validPayload = (fileCount: 1 | 2 | 3 = 1): unknown => { + const fields = ['frontImage', 'backImage', 'selfieImage'] as const; + return { + files: Array.from({ length: fileCount }, (_, i) => ({ + field: fields[i], + mimeType: 'image/jpeg', + fileName: `kyc-${fields[i]}.jpg`, + })), + }; + }; + + it('accepts single-file payload', async () => { + const dto = plainToInstance(GenerateKycUploadUrlsDto, validPayload(1)); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts three-file payload', async () => { + const dto = plainToInstance(GenerateKycUploadUrlsDto, validPayload(3)); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects missing files array', async () => { + const dto = plainToInstance(GenerateKycUploadUrlsDto, {}); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'files')).toBe(true); + }); + + it('rejects empty files array (below ArrayMinSize)', async () => { + const dto = plainToInstance(GenerateKycUploadUrlsDto, { files: [] }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'files')).toBe(true); + }); + + it('rejects more than three files (above ArrayMaxSize)', async () => { + const payload = validPayload(3) as { files: unknown[] }; + payload.files.push({ + field: 'frontImage', + mimeType: 'image/jpeg', + fileName: 'extra.jpg', + }); + const dto = plainToInstance(GenerateKycUploadUrlsDto, payload); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'files')).toBe(true); + }); + + it('rejects non-array files value', async () => { + const dto = plainToInstance(GenerateKycUploadUrlsDto, { + files: 'not-an-array', + }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'files')).toBe(true); + }); + + it('validates nested KycFileRequestDto entries', async () => { + const dto = plainToInstance(GenerateKycUploadUrlsDto, { + files: [ + { + field: 'invalidField', + mimeType: '', + fileName: '', + }, + ], + }); + const errors = await validate(dto); + const filesError = errors.find((e) => e.property === 'files'); + expect(filesError).toBeDefined(); + expect(filesError?.children?.length ?? 0).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/submit-kyc.dto.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/submit-kyc.dto.spec.ts new file mode 100644 index 0000000..3bd85c8 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/submit-kyc.dto.spec.ts @@ -0,0 +1,107 @@ +import { validate } from 'class-validator'; +import { SubmitKycDto } from '../../presentation/dto/submit-kyc.dto'; + +describe('SubmitKycDto', () => { + const validDto = (): SubmitKycDto => { + const dto = new SubmitKycDto(); + dto.documentType = 'CCCD'; + dto.documentNumber = '001234567890'; + dto.frontImageUrl = 'https://cdn.goodgo.vn/kyc/front-123.jpg'; + return dto; + }; + + it('accepts valid required fields only', async () => { + const dto = validDto(); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts all fields together', async () => { + const dto = validDto(); + dto.backImageUrl = 'https://cdn.goodgo.vn/kyc/back-123.jpg'; + dto.selfieUrl = 'https://cdn.goodgo.vn/kyc/selfie-123.jpg'; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects empty documentType', async () => { + const dto = validDto(); + dto.documentType = ''; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'documentType')).toBe(true); + }); + + it('rejects missing documentType', async () => { + const dto = validDto(); + (dto as any).documentType = undefined; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'documentType')).toBe(true); + }); + + it('rejects empty documentNumber', async () => { + const dto = validDto(); + dto.documentNumber = ''; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'documentNumber')).toBe(true); + }); + + it('rejects missing documentNumber', async () => { + const dto = validDto(); + (dto as any).documentNumber = undefined; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'documentNumber')).toBe(true); + }); + + it('rejects empty frontImageUrl', async () => { + const dto = validDto(); + dto.frontImageUrl = ''; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'frontImageUrl')).toBe(true); + }); + + it('rejects invalid frontImageUrl format', async () => { + const dto = validDto(); + dto.frontImageUrl = 'not-a-url'; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'frontImageUrl')).toBe(true); + }); + + it('rejects missing frontImageUrl', async () => { + const dto = validDto(); + (dto as any).frontImageUrl = undefined; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'frontImageUrl')).toBe(true); + }); + + it('rejects invalid backImageUrl format', async () => { + const dto = validDto(); + dto.backImageUrl = 'not-a-url'; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'backImageUrl')).toBe(true); + }); + + it('rejects invalid selfieUrl format', async () => { + const dto = validDto(); + dto.selfieUrl = 'not-a-url'; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'selfieUrl')).toBe(true); + }); + + it('rejects non-string documentType', async () => { + const dto = validDto(); + (dto as any).documentType = 123; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'documentType')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/dto/generate-kyc-upload-urls.dto.ts b/apps/api/src/modules/auth/presentation/dto/generate-kyc-upload-urls.dto.ts new file mode 100644 index 0000000..5c90598 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/generate-kyc-upload-urls.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsIn, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class KycFileRequestDto { + @ApiProperty({ + enum: ['frontImage', 'backImage', 'selfieImage'], + description: 'KYC image field identifier', + }) + @IsIn(['frontImage', 'backImage', 'selfieImage']) + field!: 'frontImage' | 'backImage' | 'selfieImage'; + + @ApiProperty({ + description: 'MIME type of the file (image/jpeg, image/png, image/webp)', + example: 'image/jpeg', + }) + @IsString() + @MinLength(1) + mimeType!: string; + + @ApiProperty({ + description: 'Original file name', + example: 'cccd-front.jpg', + }) + @IsString() + @MinLength(1) + fileName!: string; +} + +export class GenerateKycUploadUrlsDto { + @ApiProperty({ + type: [KycFileRequestDto], + description: 'List of KYC files to generate upload URLs for (1-3)', + minItems: 1, + maxItems: 3, + }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(3) + @ValidateNested({ each: true }) + @Type(() => KycFileRequestDto) + files!: KycFileRequestDto[]; +} diff --git a/apps/api/src/modules/auth/presentation/dto/submit-kyc.dto.ts b/apps/api/src/modules/auth/presentation/dto/submit-kyc.dto.ts new file mode 100644 index 0000000..c891d67 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/submit-kyc.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsUrl } from 'class-validator'; + +export class SubmitKycDto { + @ApiProperty({ example: 'CCCD', description: 'Document type (CCCD, CMND, passport)' }) + @IsString() + @IsNotEmpty({ message: 'Loại giấy tờ không được để trống' }) + documentType!: string; + + @ApiProperty({ example: '001234567890', description: 'Document number' }) + @IsString() + @IsNotEmpty({ message: 'Số giấy tờ không được để trống' }) + documentNumber!: string; + + @ApiProperty({ + example: 'https://cdn.goodgo.vn/kyc/front-123.jpg', + description: 'Front image presigned URL', + }) + @IsString() + @IsUrl({}, { message: 'URL ảnh mặt trước không hợp lệ' }) + @IsNotEmpty({ message: 'Vui lòng tải ảnh mặt trước giấy tờ' }) + frontImageUrl!: string; + + @ApiPropertyOptional({ + example: 'https://cdn.goodgo.vn/kyc/back-123.jpg', + description: 'Back image presigned URL', + }) + @IsOptional() + @IsString() + @IsUrl({}, { message: 'URL ảnh mặt sau không hợp lệ' }) + backImageUrl?: string; + + @ApiPropertyOptional({ + example: 'https://cdn.goodgo.vn/kyc/selfie-123.jpg', + description: 'Selfie image presigned URL', + }) + @IsOptional() + @IsString() + @IsUrl({}, { message: 'URL ảnh selfie không hợp lệ' }) + selfieUrl?: string; +}