feat(auth): commit KYC presigned-upload DTOs + presentation tests (TEC-2750)

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 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 14:34:30 +07:00
parent a6d1ef307c
commit 4143c4dcb9
4 changed files with 339 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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[];
}

View File

@@ -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;
}