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