feat(server): use zod parse to impl input validation (#10566)

close CLOUD-124
This commit is contained in:
fengmk2
2025-03-06 10:40:00 +00:00
parent 84e2dda3f8
commit d2b45783ea
6 changed files with 141 additions and 1 deletions

View File

@@ -1,20 +1,24 @@
import { import {
applyDecorators, applyDecorators,
Body,
Controller, Controller,
Get, Get,
HttpStatus, HttpStatus,
INestApplication, INestApplication,
Logger, Logger,
LoggerService, LoggerService,
Post,
} from '@nestjs/common'; } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { import {
MessageBody,
SubscribeMessage as RawSubscribeMessage, SubscribeMessage as RawSubscribeMessage,
WebSocketGateway, WebSocketGateway,
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import testFn, { TestFn } from 'ava'; import testFn, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import request from 'supertest'; import request from 'supertest';
import { z } from 'zod';
import { import {
AccessDenied, AccessDenied,
@@ -22,8 +26,14 @@ import {
UserFriendlyError, UserFriendlyError,
} from '../../base'; } from '../../base';
import { Public } from '../../core/auth'; import { Public } from '../../core/auth';
import { EmailSchema } from '../../models/common/schema';
import { createTestingApp } from '../utils'; import { createTestingApp } from '../utils';
const TestSchema = z.object({
email: EmailSchema,
foo: z.string().trim().min(1).optional(),
});
@Public() @Public()
@Resolver(() => String) @Resolver(() => String)
class TestResolver { class TestResolver {
@@ -40,6 +50,12 @@ class TestResolver {
return this.greating; return this.greating;
} }
@Mutation(() => String)
validate(@Args('email') email: string) {
const input = TestSchema.parse({ email });
return input.email;
}
@Query(() => String) @Query(() => String)
errorQuery() { errorQuery() {
throw new AccessDenied(); throw new AccessDenied();
@@ -68,6 +84,12 @@ class TestController {
throwUnknownError() { throwUnknownError() {
throw new Error('Unknown error'); throw new Error('Unknown error');
} }
@Post('/validate')
validate(@Body() body: { email: string }) {
const input = TestSchema.parse(body);
return input;
}
} }
const SubscribeMessage = (event: string) => const SubscribeMessage = (event: string) =>
@@ -91,6 +113,12 @@ class TestGateway {
async throwUnknownError() { async throwUnknownError() {
throw new Error('Unknown error'); throw new Error('Unknown error');
} }
@SubscribeMessage('event:validate')
async validate(@MessageBody() body: { email: string }) {
const input = TestSchema.parse(body);
return input;
}
} }
const test = testFn as TestFn<{ const test = testFn as TestFn<{
@@ -147,6 +175,30 @@ test('should be able to handle unknown internal error in graphql query', async t
t.true(t.context.logger.error.calledOnceWith('internal_server_error')); t.true(t.context.logger.error.calledOnceWith('internal_server_error'));
}); });
test('should be able to handle validation error in graphql query', async t => {
const res = await gql(
t.context.app,
`mutation { validate(email: "invalid-email") }`
);
const err = res.body.errors[0];
t.is(
err.message,
`Validation error, errors: [
{
"validation": "email",
"code": "invalid_string",
"message": "Invalid email",
"path": [
"email"
]
}
]`
);
t.is(err.extensions.status, HttpStatus.BAD_REQUEST);
t.is(err.extensions.name, 'VALIDATION_ERROR');
t.true(t.context.logger.error.notCalled);
});
test('should be able to respond request', async t => { test('should be able to respond request', async t => {
const res = await request(t.context.app.getHttpServer()) const res = await request(t.context.app.getHttpServer())
.get('/ok') .get('/ok')
@@ -179,6 +231,42 @@ test('should be able to handle unknown internal error in http request', async t
); );
}); });
test('should be able to handle validation error in http request', async t => {
const res = await request(t.context.app.getHttpServer())
.post('/validate')
.send({ email: 'invalid-email', foo: '' })
.expect(HttpStatus.BAD_REQUEST);
t.is(
res.body.message,
`Validation error, errors: [
{
"validation": "email",
"code": "invalid_string",
"message": "Invalid email",
"path": [
"email"
]
},
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"exact": false,
"message": "String must contain at least 1 character(s)",
"path": [
"foo"
]
}
]`
);
t.is(res.body.name, 'VALIDATION_ERROR');
t.is(res.body.type, 'INVALID_INPUT');
t.is(res.body.code, 'Bad Request');
t.truthy(res.body.data.errors);
t.true(t.context.logger.error.notCalled);
});
// Hard to test through websocket, will call event handler directly // Hard to test through websocket, will call event handler directly
test('should be able to response websocket event', async t => { test('should be able to response websocket event', async t => {
const gateway = t.context.app.get(TestGateway); const gateway = t.context.app.get(TestGateway);
@@ -208,3 +296,28 @@ test('should be able to handle unknown internal error in websocket event', async
t.is(error.name, 'INTERNAL_SERVER_ERROR'); t.is(error.name, 'INTERNAL_SERVER_ERROR');
t.true(t.context.logger.error.calledOnceWith('internal_server_error')); t.true(t.context.logger.error.calledOnceWith('internal_server_error'));
}); });
test('should be able to handle validation error in graphql mutation', async t => {
const gateway = t.context.app.get(TestGateway);
const { error } = (await gateway.validate({
email: 'invalid-email',
})) as unknown as {
error: UserFriendlyError;
};
t.is(
error.message,
`Validation error, errors: [
{
"validation": "email",
"code": "invalid_string",
"message": "Invalid email",
"path": [
"email"
]
}
]`
);
t.is(error.name, 'VALIDATION_ERROR');
t.true(t.context.logger.error.notCalled);
});

View File

@@ -265,6 +265,12 @@ export const USER_FRIENDLY_ERRORS = {
message: ({ max }) => `Query is too long, max length is ${max}.`, message: ({ max }) => `Query is too long, max length is ${max}.`,
}, },
validation_error: {
type: 'invalid_input',
args: { errors: 'string' },
message: ({ errors }) => `Validation error, errors: ${errors}`,
},
// User Errors // User Errors
user_not_found: { user_not_found: {
type: 'resource_not_found', type: 'resource_not_found',

View File

@@ -48,6 +48,16 @@ export class QueryTooLong extends UserFriendlyError {
super('invalid_input', 'query_too_long', message, args); super('invalid_input', 'query_too_long', message, args);
} }
} }
@ObjectType()
class ValidationErrorDataType {
@Field() errors!: string
}
export class ValidationError extends UserFriendlyError {
constructor(args: ValidationErrorDataType, message?: string | ((args: ValidationErrorDataType) => string)) {
super('invalid_input', 'validation_error', message, args);
}
}
export class UserNotFound extends UserFriendlyError { export class UserNotFound extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
@@ -846,6 +856,7 @@ export enum ErrorNames {
BAD_REQUEST, BAD_REQUEST,
GRAPHQL_BAD_REQUEST, GRAPHQL_BAD_REQUEST,
QUERY_TOO_LONG, QUERY_TOO_LONG,
VALIDATION_ERROR,
USER_NOT_FOUND, USER_NOT_FOUND,
USER_AVATAR_NOT_FOUND, USER_AVATAR_NOT_FOUND,
EMAIL_ALREADY_USED, EMAIL_ALREADY_USED,
@@ -954,5 +965,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({ export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion', name: 'ErrorDataUnion',
types: () => types: () =>
[GraphqlBadRequestDataType, QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType] as const, [GraphqlBadRequestDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType] as const,
}); });

View File

@@ -13,6 +13,7 @@ import { Response } from 'express';
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
import { ZodError } from 'zod';
import { import {
GraphqlBadRequest, GraphqlBadRequest,
@@ -20,6 +21,7 @@ import {
NotFound, NotFound,
TooManyRequest, TooManyRequest,
UserFriendlyError, UserFriendlyError,
ValidationError,
} from '../error'; } from '../error';
import { metrics } from '../metrics'; import { metrics } from '../metrics';
import { getRequestIdFromHost } from '../utils'; import { getRequestIdFromHost } from '../utils';
@@ -52,6 +54,10 @@ export function mapAnyError(error: any): UserFriendlyError {
return new TooManyRequest(); return new TooManyRequest();
} else if (error instanceof NotFoundException) { } else if (error instanceof NotFoundException) {
return new NotFound(); return new NotFound();
} else if (error instanceof ZodError) {
return new ValidationError({
errors: error.message,
});
} else { } else {
const e = new InternalServerError(); const e = new InternalServerError();
e.cause = error; e.cause = error;

View File

@@ -0,0 +1,3 @@
import { z } from 'zod';
export const EmailSchema = z.string().trim().email();

View File

@@ -431,6 +431,7 @@ enum ErrorNames {
UNSUPPORTED_SUBSCRIPTION_PLAN UNSUPPORTED_SUBSCRIPTION_PLAN
USER_AVATAR_NOT_FOUND USER_AVATAR_NOT_FOUND
USER_NOT_FOUND USER_NOT_FOUND
VALIDATION_ERROR
VERSION_REJECTED VERSION_REJECTED
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION