fix: harden tenant auth and quality gates
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientStatusChangeDto } from './client-write.dto';
|
||||
import { strictValidationPipe } from './strict-validation.pipe';
|
||||
|
||||
function validate<T>(metatype: new () => T, value: unknown) {
|
||||
return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
|
||||
}
|
||||
|
||||
describe('strict client write DTOs', () => {
|
||||
it('accepts an import confirmation without a client-supplied phones array', async () => {
|
||||
await expect(validate(ClientImportConfirmDto, {
|
||||
content: '【测试】验证码 ${code}',
|
||||
importContent: 'phone,code\n13800000001,1234',
|
||||
})).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('rejects a direct batch task without validated phone numbers', async () => {
|
||||
await expect(validate(ClientBatchTaskDto, { content: '【测试】通知' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a client-supplied operator identity', async () => {
|
||||
await expect(validate(ClientStatusChangeDto, { status: 'disabled', operatorId: 'another-user' }))
|
||||
.rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ClientCertificationSubmissionDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) contactName?: string;
|
||||
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
|
||||
@IsOptional() @IsObject() materials?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientTaskBaseDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) templateId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
|
||||
@IsOptional() @IsString() @MaxLength(64) scheduledAt?: string;
|
||||
@IsOptional() @IsObject() variables?: Record<string, unknown>;
|
||||
@IsOptional() @IsString() @MaxLength(64) requestedAt?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
|
||||
}
|
||||
|
||||
export class ClientBatchTaskDto extends ClientTaskBaseDto {
|
||||
@IsArray() @ArrayMaxSize(100000) @Matches(/^1\d{10}$/, { each: true }) phones!: string[];
|
||||
}
|
||||
|
||||
export class ClientImportPreviewDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(255) fileName?: string;
|
||||
@IsOptional() @IsIn(['utf8', 'gbk']) encoding?: 'utf8' | 'gbk';
|
||||
@IsOptional() @IsIn([',', '\t']) delimiter?: ',' | '\t';
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export class ClientImportConfirmDto extends ClientTaskBaseDto {
|
||||
@IsString() @MinLength(1) @MaxLength(5_000_000) importContent!: string;
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export class ClientBillingEstimateDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
|
||||
@IsOptional() @Type(() => Number) @Min(0) unitPrice?: number;
|
||||
@IsOptional() @IsString() @MaxLength(64) taskId?: string;
|
||||
}
|
||||
|
||||
export class ClientSmsApplicationDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) scene?: string;
|
||||
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) cmppAccount?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) cmppEnterpriseCode?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) cmppApplicationExtension?: string;
|
||||
@IsOptional() @IsBoolean() cmppAccessNumberFillEnabled?: boolean;
|
||||
@IsOptional() @IsString() @MaxLength(32) cmppAccessNumberFillPrefix?: string;
|
||||
@IsOptional() @IsString() @MaxLength(4096) passwordCipher?: string;
|
||||
@IsOptional() @IsBoolean() interfaceEnabled?: boolean;
|
||||
@IsOptional() @IsString() @MaxLength(32) interfaceType?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) cmppMaxConnections?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1000) cmppWindowSize?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) dailyLimit?: number;
|
||||
@IsOptional() @Type(() => Number) @Min(0) customerUnitPrice?: number;
|
||||
@IsOptional() @IsString() @MaxLength(32) queuePriority?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) templateMismatchMode?: string;
|
||||
@IsOptional() @IsBoolean() downstreamReceiptRetryEnabled?: boolean;
|
||||
@IsOptional() @IsBoolean() downstreamUplinkRetryEnabled?: boolean;
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) ipAllowlist?: string[];
|
||||
}
|
||||
|
||||
export class ClientSmsSignatureDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) purpose?: string;
|
||||
@IsOptional() @IsObject() drainageInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
|
||||
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
|
||||
}
|
||||
|
||||
export class ClientDrainageInfoDto {
|
||||
@IsString() @MinLength(1) @MaxLength(200) siteName!: string;
|
||||
@IsUrl({ require_tld: false }) @MaxLength(2048) url!: string;
|
||||
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
|
||||
@IsOptional() @IsObject() reportValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
|
||||
|
||||
export class ClientSignatureMaterialDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) fileObjectId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(64) materialType!: string;
|
||||
@IsString() @MinLength(1) @MaxLength(200) title!: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) description?: string;
|
||||
}
|
||||
|
||||
class TemplateVariableDto {
|
||||
@IsString() @MinLength(1) @MaxLength(64) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) example?: string;
|
||||
@IsOptional() @IsBoolean() required?: boolean;
|
||||
}
|
||||
|
||||
export class ClientSmsTemplateDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MaxLength(64) applicationId!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) signatureId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(200) name!: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => TemplateVariableDto) variables?: TemplateVariableDto[];
|
||||
}
|
||||
|
||||
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
|
||||
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
|
||||
}
|
||||
|
||||
export class ClientStatusChangeDto {
|
||||
@IsOptional() @IsString() @MaxLength(32) status?: string;
|
||||
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||
@IsOptional() @IsBoolean() force?: boolean;
|
||||
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
|
||||
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
|
||||
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
export const strictValidationPipe = new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
stopAtFirstError: false,
|
||||
transformOptions: { enableImplicitConversion: false },
|
||||
});
|
||||
Reference in New Issue
Block a user