[BUGFIX] ObjectMetadata item server validation (#10699)
# Introduction This PR contains several SNAPSHOT files explaining big + While refactoring the Object Model settings page in https://github.com/twentyhq/twenty/pull/10653, encountered a critical issue when submitting either one or both names with `""` empty string hard corrupting a workspace. This motivate this PR reviewing server side validation I feel like we could share zod schema between front and back ## Refactored server validation What to expect from Names: - Plural and singular have to be different ( case insensitive and trimmed check ) - Contains only a-z A-Z and 0-9 - Follows camelCase - Is not empty => Is not too short ( 1 ) - Is not too long ( 63 ) - Is case insensitive( fooBar and fOoBar now rejected ) What to expect from Labels: - Plural and singular have to be different ( case insensitive and trimmed check ) - Is not empty => Is not too short ( 1 ) - Is not too long ( 63 ) - Is case insensitive ( fooBar and fOoBar now rejected ) close https://github.com/twentyhq/twenty/issues/10694 ## Creation integrations tests Created new integrations tests, following [EachTesting](https://jestjs.io/docs/api#testeachtablename-fn-timeout) pattern and uses snapshot to assert errors message. These tests cover several failing use cases and started to implement ones for the happy path but object metadata item deletion is currently broken unless I'm mistaken @Weiko is on it ## Notes - [ ] As we've added new validation rules towards names and labels we should scan db in order to standardize existing values using either a migration command or manual check - [ ] Will review in an other PR the update path, adding integrations tests and so on
This commit is contained in:
@ -26,9 +26,10 @@ import { ObjectMetadataRelatedRecordsService } from 'src/engine/metadata-modules
|
||||
import { ObjectMetadataRelationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-relation.service';
|
||||
import { buildDefaultFieldsForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util';
|
||||
import {
|
||||
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow,
|
||||
validateNameAndLabelAreSyncOrThrow,
|
||||
validateNameSingularAndNamePluralAreDifferentOrThrow,
|
||||
validateObjectMetadataInputOrThrow,
|
||||
validateObjectMetadataInputLabelsOrThrow,
|
||||
validateObjectMetadataInputNamesOrThrow,
|
||||
} from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
|
||||
import { RemoteTableRelationsService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.service';
|
||||
import { SearchService } from 'src/engine/metadata-modules/search/search.service';
|
||||
@ -88,12 +89,24 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
objectMetadataInput.workspaceId,
|
||||
);
|
||||
|
||||
validateObjectMetadataInputOrThrow(objectMetadataInput);
|
||||
validateObjectMetadataInputNamesOrThrow(objectMetadataInput);
|
||||
validateObjectMetadataInputLabelsOrThrow(objectMetadataInput);
|
||||
|
||||
validateNameSingularAndNamePluralAreDifferentOrThrow(
|
||||
objectMetadataInput.nameSingular,
|
||||
objectMetadataInput.namePlural,
|
||||
);
|
||||
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
|
||||
inputs: [
|
||||
objectMetadataInput.nameSingular,
|
||||
objectMetadataInput.namePlural,
|
||||
],
|
||||
message: 'The singular and plural names cannot be the same for an object',
|
||||
});
|
||||
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
|
||||
inputs: [
|
||||
objectMetadataInput.labelPlural,
|
||||
objectMetadataInput.labelSingular,
|
||||
],
|
||||
message:
|
||||
'The singular and plural labels cannot be the same for an object',
|
||||
});
|
||||
|
||||
if (objectMetadataInput.isLabelSyncedWithName === true) {
|
||||
validateNameAndLabelAreSyncOrThrow(
|
||||
@ -199,7 +212,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
input: UpdateOneObjectInput,
|
||||
workspaceId: string,
|
||||
): Promise<ObjectMetadataEntity> {
|
||||
validateObjectMetadataInputOrThrow(input.update);
|
||||
validateObjectMetadataInputNamesOrThrow(input.update);
|
||||
|
||||
const existingObjectMetadata = await this.objectMetadataRepository.findOne({
|
||||
where: { id: input.id, workspaceId: workspaceId },
|
||||
@ -242,10 +255,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
isDefined(input.update.nameSingular) ||
|
||||
isDefined(input.update.namePlural)
|
||||
) {
|
||||
validateNameSingularAndNamePluralAreDifferentOrThrow(
|
||||
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
|
||||
existingObjectMetadataCombinedWithUpdateInput.namePlural,
|
||||
);
|
||||
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
|
||||
inputs: [
|
||||
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
|
||||
existingObjectMetadataCombinedWithUpdateInput.namePlural,
|
||||
],
|
||||
message:
|
||||
'The singular and plural names cannot be the same for an object',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedObject = await super.updateOne(input.id, input.update);
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when name exceeds maximum length 1`] = `"String "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters limit"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when namePlural has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is a reserved keyword 1`] = `"The name "users" is not available"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is an empty string 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is not camelCased 1`] = `"Name should be in camelCase: Not_Camel_Case"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is a reserved keyword 1`] = `"The name "user" is not available"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is an empty string 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is not camelCased 1`] = `"Name should be in camelCase: Not_Camel_Case"`;
|
||||
@ -1,85 +1,57 @@
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
import { getMockCreateObjectInput } from 'test/integration/utils/object-metadata/generate-mock-create-object-metadata-input';
|
||||
|
||||
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
import { validateObjectMetadataInputOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
|
||||
import { validateObjectMetadataInputNamesOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
|
||||
|
||||
const validObjectInput: UpdateObjectPayload = {
|
||||
labelPlural: 'Car',
|
||||
labelSingular: 'Cars',
|
||||
nameSingular: 'car',
|
||||
namePlural: 'cars',
|
||||
};
|
||||
type ValidateObjectNameTestingContext = EachTestingContext<
|
||||
Partial<UpdateObjectPayload>
|
||||
>;
|
||||
const validateObjectMetadataTestCases: ValidateObjectNameTestingContext[] = [
|
||||
{
|
||||
title: 'when nameSingular has invalid characters',
|
||||
context: { nameSingular: 'μ' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural has invalid characters',
|
||||
context: { namePlural: 'μ' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is a reserved keyword',
|
||||
context: { nameSingular: 'user' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is a reserved keyword',
|
||||
context: { namePlural: 'users' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is not camelCased',
|
||||
context: { nameSingular: 'Not_Camel_Case' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is not camelCased',
|
||||
context: { namePlural: 'Not_Camel_Case' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is an empty string',
|
||||
context: { namePlural: '' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is an empty string',
|
||||
context: { nameSingular: '' },
|
||||
},
|
||||
{
|
||||
title: 'when name exceeds maximum length',
|
||||
context: { nameSingular: 'a'.repeat(64) },
|
||||
},
|
||||
];
|
||||
|
||||
const reservedKeyword = 'user';
|
||||
|
||||
describe('validateObjectName', () => {
|
||||
it('should not throw if names are valid', () => {
|
||||
describe('validateObjectMetadataInputOrThrow should fail', () => {
|
||||
it.each(validateObjectMetadataTestCases)('$title', ({ context }) => {
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(validObjectInput),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw is nameSingular has invalid characters', () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
nameSingular: 'μ',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw is namePlural has invalid characters', () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
namePlural: 'μ',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if nameSingular is a reserved keyword', async () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
nameSingular: reservedKeyword,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if namePlural is a reserved keyword', async () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
namePlural: reservedKeyword,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if nameSingular is not camelCased', async () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
nameSingular: 'notACamelCase1a',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if namePlural is a not camelCased', async () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
namePlural: 'notACamelCase1b',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
validateObjectMetadataInputNamesOrThrow(
|
||||
getMockCreateObjectInput(context),
|
||||
),
|
||||
).toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { slugify } from 'transliteration';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
@ -6,138 +7,72 @@ import {
|
||||
ObjectMetadataException,
|
||||
ObjectMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
|
||||
import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
|
||||
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
|
||||
import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils';
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils';
|
||||
import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils';
|
||||
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
|
||||
import { camelCase } from 'src/utils/camel-case';
|
||||
|
||||
const coreObjectNames = [
|
||||
'approvedAccessDomain',
|
||||
'approvedAccessDomains',
|
||||
'appToken',
|
||||
'appTokens',
|
||||
'billingCustomer',
|
||||
'billingCustomers',
|
||||
'billingEntitlement',
|
||||
'billingEntitlements',
|
||||
'billingMeter',
|
||||
'billingMeters',
|
||||
'billingProduct',
|
||||
'billingProducts',
|
||||
'billingSubscription',
|
||||
'billingSubscriptions',
|
||||
'billingSubscriptionItem',
|
||||
'billingSubscriptionItems',
|
||||
'featureFlag',
|
||||
'featureFlags',
|
||||
'keyValuePair',
|
||||
'keyValuePairs',
|
||||
'postgresCredential',
|
||||
'postgresCredentials',
|
||||
'twoFactorMethod',
|
||||
'twoFactorMethods',
|
||||
'user',
|
||||
'users',
|
||||
'userWorkspace',
|
||||
'userWorkspaces',
|
||||
'workspace',
|
||||
'workspaces',
|
||||
|
||||
'role',
|
||||
'roles',
|
||||
'userWorkspaceRole',
|
||||
'userWorkspaceRoles',
|
||||
];
|
||||
|
||||
const reservedKeywords = [
|
||||
...coreObjectNames,
|
||||
'event',
|
||||
'events',
|
||||
'field',
|
||||
'fields',
|
||||
'link',
|
||||
'links',
|
||||
'currency',
|
||||
'currencies',
|
||||
'fullName',
|
||||
'fullNames',
|
||||
'address',
|
||||
'addresses',
|
||||
'type',
|
||||
'types',
|
||||
'object',
|
||||
'objects',
|
||||
'index',
|
||||
'relation',
|
||||
'relations',
|
||||
];
|
||||
|
||||
export const validateObjectMetadataInputOrThrow = <
|
||||
export const validateObjectMetadataInputNamesOrThrow = <
|
||||
T extends UpdateObjectPayload | CreateObjectInput,
|
||||
>(
|
||||
objectMetadataInput: T,
|
||||
): void => {
|
||||
validateNameCamelCasedOrThrow(objectMetadataInput.nameSingular);
|
||||
validateNameCamelCasedOrThrow(objectMetadataInput.namePlural);
|
||||
|
||||
validateNameCharactersOrThrow(objectMetadataInput.nameSingular);
|
||||
validateNameCharactersOrThrow(objectMetadataInput.namePlural);
|
||||
|
||||
validateNameIsNotReservedKeywordOrThrow(objectMetadataInput.nameSingular);
|
||||
validateNameIsNotReservedKeywordOrThrow(objectMetadataInput.namePlural);
|
||||
|
||||
validateNameIsNotTooLongThrow(objectMetadataInput.nameSingular);
|
||||
validateNameIsNotTooLongThrow(objectMetadataInput.namePlural);
|
||||
};
|
||||
|
||||
const validateNameIsNotReservedKeywordOrThrow = (name?: string) => {
|
||||
if (name) {
|
||||
if (reservedKeywords.includes(name)) {
|
||||
throw new ObjectMetadataException(
|
||||
`The name "${name}" is not available`,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
>({
|
||||
namePlural,
|
||||
nameSingular,
|
||||
}: T): void =>
|
||||
[namePlural, nameSingular].forEach((name) => {
|
||||
if (!isDefined(name)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
validateObjectMetadataInputNameOrThrow(name);
|
||||
});
|
||||
|
||||
const validateNameCamelCasedOrThrow = (name?: string) => {
|
||||
if (name && name !== camelCase(name)) {
|
||||
throw new ObjectMetadataException(
|
||||
`Name should be in camelCase: ${name}`,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateNameIsNotTooLongThrow = (name?: string) => {
|
||||
if (name && exceedsDatabaseIdentifierMaximumLength(name)) {
|
||||
throw new ObjectMetadataException(
|
||||
`Name exceeds 63 characters: ${name}`,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateNameCharactersOrThrow = (name?: string) => {
|
||||
export const validateObjectMetadataInputNameOrThrow = (name: string): void => {
|
||||
try {
|
||||
if (name) {
|
||||
validateMetadataNameValidityOrThrow(name);
|
||||
}
|
||||
validateMetadataNameOrThrow(name);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidStringException) {
|
||||
if (error instanceof InvalidMetadataNameException) {
|
||||
throw new ObjectMetadataException(
|
||||
`Characters used in name "${name}" are not supported`,
|
||||
error.message,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateObjectMetadataInputLabelsOrThrow = <
|
||||
T extends CreateObjectInput,
|
||||
>({
|
||||
labelPlural,
|
||||
labelSingular,
|
||||
}: T): void =>
|
||||
[labelPlural, labelSingular].forEach((label) =>
|
||||
validateObjectMetadataInputLabelOrThrow(label),
|
||||
);
|
||||
|
||||
const validateObjectMetadataInputLabelOrThrow = (name: string): void => {
|
||||
const validators = [
|
||||
validateMetadataNameIsNotTooShortOrThrow,
|
||||
validateMetadataNameIsNotTooLongOrThrow,
|
||||
];
|
||||
|
||||
try {
|
||||
validators.forEach((validator) => validator(name.trim()));
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidMetadataNameException) {
|
||||
throw new ObjectMetadataException(
|
||||
error.message,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const computeMetadataNameFromLabel = (label: string): string => {
|
||||
if (!label) {
|
||||
if (!isDefined(label)) {
|
||||
throw new ObjectMetadataException(
|
||||
'Label is required',
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
@ -180,13 +115,17 @@ export const validateNameAndLabelAreSyncOrThrow = (
|
||||
}
|
||||
};
|
||||
|
||||
export const validateNameSingularAndNamePluralAreDifferentOrThrow = (
|
||||
nameSingular: string,
|
||||
namePlural: string,
|
||||
) => {
|
||||
if (nameSingular === namePlural) {
|
||||
type ValidateLowerCasedAndTrimmedStringAreDifferentOrThrowArgs = {
|
||||
inputs: [string, string];
|
||||
message: string;
|
||||
};
|
||||
export const validateLowerCasedAndTrimmedStringsAreDifferentOrThrow = ({
|
||||
message,
|
||||
inputs: [firstString, secondString],
|
||||
}: ValidateLowerCasedAndTrimmedStringAreDifferentOrThrowArgs) => {
|
||||
if (firstString.trim().toLowerCase() === secondString.trim().toLowerCase()) {
|
||||
throw new ObjectMetadataException(
|
||||
'The singular and plural name cannot be the same for an object',
|
||||
message,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user