mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 14:27:02 +08:00
feat(server): use zod parse to impl input validation (#10566)
close CLOUD-124
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
3
packages/backend/server/src/models/common/schema.ts
Normal file
3
packages/backend/server/src/models/common/schema.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const EmailSchema = z.string().trim().email();
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user