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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
41
apps/api/src/modules/auth/presentation/dto/submit-kyc.dto.ts
Normal file
41
apps/api/src/modules/auth/presentation/dto/submit-kyc.dto.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user