[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:
Paul Rastoin
2025-03-11 12:14:37 +01:00
committed by GitHub
parent de22872c6e
commit 41f3a63962
42 changed files with 803 additions and 391 deletions

View File

@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`validateFieldNameAvailabilityOrThrow throws error with ACTOR suffixes 1`] = `"Name "fieldActorName" is not available"`;
exports[`validateFieldNameAvailabilityOrThrow throws error with ADDRESS suffixes 1`] = `"Name "fieldAddressAddressStreet1" is not available"`;
exports[`validateFieldNameAvailabilityOrThrow throws error with CURRENCY suffixes 1`] = `"Name "fieldCurrencyAmountMicros" is not available"`;
exports[`validateFieldNameAvailabilityOrThrow throws error with FULL_NAME suffixes 1`] = `"Name "fieldFullNameFirstName" is not available"`;
exports[`validateFieldNameAvailabilityOrThrow throws error with LINKS suffixes 1`] = `"Name "fieldLinksPrimaryLinkLabel" is not available"`;

View File

@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`validateMetadataNameOrThrow throw error when string is not in camel case 1`] = `"Name should be in camelCase: TestName"`;
exports[`validateMetadataNameOrThrow throws error when starts with digits 1`] = `"Name should be in camelCase: 123string"`;
exports[`validateMetadataNameOrThrow throws error when string has non latin characters 1`] = `"String "בְרִבְרִ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
exports[`validateMetadataNameOrThrow throws error when string has spaces 1`] = `"Name should be in camelCase: name with spaces"`;
exports[`validateMetadataNameOrThrow throws error when string is a reserved word 1`] = `"The name "role" is not available"`;
exports[`validateMetadataNameOrThrow throws error when string is above 63 characters 1`] = `"String "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters limit"`;
exports[`validateMetadataNameOrThrow throws error when string is empty 1`] = `"Input is too short: """`;
exports[`validateMetadataNameOrThrow throws error when string starts with capital letter 1`] = `"Name should be in camelCase: StringStartingWithCapitalLetter"`;

View File

@ -1,3 +1,5 @@
import { EachTestingContext } from 'twenty-shared';
import {
FIELD_ACTOR_MOCK_NAME,
FIELD_ADDRESS_MOCK_NAME,
@ -6,55 +8,69 @@ import {
FIELD_LINKS_MOCK_NAME,
objectMetadataItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { NameNotAvailableException } from 'src/engine/metadata-modules/utils/exceptions/name-not-available.exception';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
type ValidateFieldNameAvailabilityTestContext = EachTestingContext<{
input: string;
shouldNotThrow?: true;
}>;
const validateFieldNameAvailabilityTestCases: ValidateFieldNameAvailabilityTestContext[] =
[
{
title: 'does not throw if name is not reserved',
context: {
input: 'testName',
shouldNotThrow: true,
},
},
{
title: 'throws error with LINKS suffixes',
context: {
input: `${FIELD_LINKS_MOCK_NAME}PrimaryLinkLabel`,
},
},
{
title: 'throws error with CURRENCY suffixes',
context: {
input: `${FIELD_CURRENCY_MOCK_NAME}AmountMicros`,
},
},
{
title: 'throws error with FULL_NAME suffixes',
context: {
input: `${FIELD_FULL_NAME_MOCK_NAME}FirstName`,
},
},
{
title: 'throws error with ACTOR suffixes',
context: {
input: `${FIELD_ACTOR_MOCK_NAME}Name`,
},
},
{
title: 'throws error with ADDRESS suffixes',
context: {
input: `${FIELD_ADDRESS_MOCK_NAME}AddressStreet1`,
},
},
];
describe('validateFieldNameAvailabilityOrThrow', () => {
const objectMetadata = objectMetadataItemMock;
it('does not throw if name is not reserved', () => {
const name = 'testName';
expect(() =>
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
).not.toThrow();
});
describe('error cases', () => {
it('throws error with LINKS suffixes', () => {
const name = `${FIELD_LINKS_MOCK_NAME}PrimaryLinkLabel`;
expect(() =>
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
).toThrow(NameNotAvailableException);
});
it('throws error with CURRENCY suffixes', () => {
const name = `${FIELD_CURRENCY_MOCK_NAME}AmountMicros`;
expect(() =>
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
).toThrow(NameNotAvailableException);
});
it('throws error with FULL_NAME suffixes', () => {
const name = `${FIELD_FULL_NAME_MOCK_NAME}FirstName`;
expect(() =>
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
).toThrow(NameNotAvailableException);
});
it('throws error with ACTOR suffixes', () => {
const name = `${FIELD_ACTOR_MOCK_NAME}Name`;
expect(() =>
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
).toThrow(NameNotAvailableException);
});
it('throws error with ADDRESS suffixes', () => {
const name = `${FIELD_ADDRESS_MOCK_NAME}AddressStreet1`;
expect(() =>
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
).toThrow(NameNotAvailableException);
});
});
it.each(validateFieldNameAvailabilityTestCases)(
'$title',
({ context: { input, shouldNotThrow } }) => {
if (shouldNotThrow) {
expect(() =>
validateFieldNameAvailabilityOrThrow(input, objectMetadata),
).not.toThrow();
} else {
expect(() =>
validateFieldNameAvailabilityOrThrow(input, objectMetadata),
).toThrowErrorMatchingSnapshot();
}
},
);
});

View File

@ -1,56 +0,0 @@
import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
import { NameTooLongException } from 'src/engine/metadata-modules/utils/exceptions/name-too-long.exception';
import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils';
describe('validateMetadataNameValidityOrThrow', () => {
it('does not throw if string is valid', () => {
const input = 'testName';
expect(validateMetadataNameValidityOrThrow(input)).not.toThrow;
});
it('throws error if string has spaces', () => {
const input = 'name with spaces';
expect(() => validateMetadataNameValidityOrThrow(input)).toThrow(
InvalidStringException,
);
});
it('throws error if string starts with capital letter', () => {
const input = 'StringStartingWithCapitalLetter';
expect(() => validateMetadataNameValidityOrThrow(input)).toThrow(
InvalidStringException,
);
});
it('throws error if string has non latin characters', () => {
const input = 'בְרִבְרִ';
expect(() => validateMetadataNameValidityOrThrow(input)).toThrow(
InvalidStringException,
);
});
it('throws error if starts with digits', () => {
const input = '123string';
expect(() => validateMetadataNameValidityOrThrow(input)).toThrow(
InvalidStringException,
);
});
it('does not throw if string is less than 63 characters', () => {
const inputWith63Characters =
'thisIsAstringWithSixtyThreeCharacters11111111111111111111111111';
expect(validateMetadataNameValidityOrThrow(inputWith63Characters)).not
.toThrow;
});
it('throws error if string is above 63 characters', () => {
const inputWith64Characters =
'thisIsAstringWithSixtyFourCharacters1111111111111111111111111111';
expect(() =>
validateMetadataNameValidityOrThrow(inputWith64Characters),
).toThrow(NameTooLongException);
});
});

View File

@ -0,0 +1,88 @@
import { EachTestingContext } from 'twenty-shared';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
type ValidateMetadataNameTestContext = EachTestingContext<{
input: string;
shouldNotThrow?: true;
}>;
const validateMetadataNameTestCases: ValidateMetadataNameTestContext[] = [
{
title: 'validates when string is valid',
context: {
input: 'testName',
shouldNotThrow: true,
},
},
{
title: 'throw error when string is not in camel case',
context: {
input: 'TestName',
},
},
{
title: 'throws error when string has spaces',
context: {
input: 'name with spaces',
},
},
{
title: 'throws error when string is a reserved word',
context: {
input: 'role',
},
},
{
title: 'throws error when string starts with capital letter',
context: {
input: 'StringStartingWithCapitalLetter',
},
},
{
title: 'throws error when string has non latin characters',
context: {
input: 'בְרִבְרִ',
},
},
{
title: 'throws error when starts with digits',
context: {
input: '123string',
},
},
{
title: 'validates when string is less than 63 characters',
context: {
input: 'a'.repeat(63),
shouldNotThrow: true,
},
},
{
title: 'throws error when string is above 63 characters',
context: {
input: 'a'.repeat(64),
},
},
{
title: 'throws error when string is empty',
context: {
input: '',
},
},
];
describe('validateMetadataNameOrThrow', () => {
it.each(validateMetadataNameTestCases)(
'$title',
({ context: { input, shouldNotThrow } }) => {
if (shouldNotThrow) {
expect(() => validateMetadataNameOrThrow(input)).not.toThrow();
} else {
expect(() =>
validateMetadataNameOrThrow(input),
).toThrowErrorMatchingSnapshot();
}
},
);
});

View File

@ -0,0 +1 @@
export const IDENTIFIER_MIN_CHAR_LENGTH = 1;

View File

@ -0,0 +1,5 @@
export class InvalidMetadataNameException extends Error {
constructor(message: string) {
super(message);
}
}

View File

@ -1,7 +0,0 @@
export class InvalidStringException extends Error {
constructor(string: string) {
const message = `String "${string}" is not valid`;
super(message);
}
}

View File

@ -1,7 +0,0 @@
export class NameNotAvailableException extends Error {
constructor(name: string) {
const message = `Name "${name}" is not available`;
super(message);
}
}

View File

@ -1,7 +0,0 @@
export class NameTooLongException extends Error {
constructor(string: string) {
const message = `String "${string}" exceeds 63 characters limit`;
super(message);
}
}

View File

@ -1,5 +1,8 @@
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
export const exceedsDatabaseIdentifierMaximumLength = (string: string) => {
return string.length > IDENTIFIER_MAX_CHAR_LENGTH;
};
export const exceedsDatabaseIdentifierMaximumLength = (string: string) =>
string.length > IDENTIFIER_MAX_CHAR_LENGTH;
export const beneathDatabaseIdentifierMinimumLength = (string: string) =>
string.length < IDENTIFIER_MIN_CHAR_LENGTH;

View File

@ -2,7 +2,7 @@ import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-meta
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { NameNotAvailableException } from 'src/engine/metadata-modules/utils/exceptions/name-not-available.exception';
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
const getReservedCompositeFieldNames = (
objectMetadata: ObjectMetadataEntity,
@ -33,10 +33,10 @@ export const validateFieldNameAvailabilityOrThrow = (
getReservedCompositeFieldNames(objectMetadata);
if (objectMetadata.fields.some((field) => field.name === name)) {
throw new NameNotAvailableException(name);
throw new InvalidMetadataNameException(`Name "${name}" is not available`);
}
if (reservedCompositeFieldsNames.includes(name)) {
throw new NameNotAvailableException(name);
throw new InvalidMetadataNameException(`Name "${name}" is not available`);
}
};

View File

@ -0,0 +1,11 @@
import camelCase from 'lodash.camelcase';
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
export const validateMetadataNameIsCamelCaseOrThrow = (name: string) => {
if (name !== camelCase(name)) {
throw new InvalidMetadataNameException(
`Name should be in camelCase: ${name}`,
);
}
};

View File

@ -0,0 +1,71 @@
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
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 validateMetadataNameIsNotReservedKeywordOrThrow = (
name: string,
) => {
if (reservedKeywords.includes(name)) {
throw new InvalidMetadataNameException(
`The name "${name}" is not available`,
);
}
};

View File

@ -0,0 +1,10 @@
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
export const validateMetadataNameIsNotTooLongOrThrow = (name: string) => {
if (exceedsDatabaseIdentifierMaximumLength(name)) {
throw new InvalidMetadataNameException(
`String "${name}" exceeds 63 characters limit`,
);
}
};

View File

@ -0,0 +1,8 @@
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
import { beneathDatabaseIdentifierMinimumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
export const validateMetadataNameIsNotTooShortOrThrow = (name: string) => {
if (beneathDatabaseIdentifierMinimumLength(name)) {
throw new InvalidMetadataNameException(`Input is too short: "${name}"`);
}
};

View File

@ -0,0 +1,17 @@
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX =
/^[a-z][a-zA-Z0-9]*$/;
export const validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow =
(name: string) => {
if (
!name.match(
STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX,
)
) {
throw new InvalidMetadataNameException(
`String "${name}" is not valid: must start with lowercase letter and contain only alphanumeric letters`,
);
}
};

View File

@ -1,14 +0,0 @@
import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
import { NameTooLongException } from 'src/engine/metadata-modules/utils/exceptions/name-too-long.exception';
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
const VALID_STRING_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
export const validateMetadataNameValidityOrThrow = (name: string) => {
if (!name.match(VALID_STRING_PATTERN)) {
throw new InvalidStringException(name);
}
if (exceedsDatabaseIdentifierMaximumLength(name)) {
throw new NameTooLongException(name);
}
};

View File

@ -0,0 +1,17 @@
import { validateMetadataNameIsCamelCaseOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils';
import { validateMetadataNameIsNotReservedKeywordOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword';
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 { validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils';
export const validateMetadataNameOrThrow = (name: string): void => {
const validators = [
validateMetadataNameIsNotTooLongOrThrow,
validateMetadataNameIsNotTooShortOrThrow,
validateMetadataNameIsCamelCaseOrThrow,
validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow,
validateMetadataNameIsNotReservedKeywordOrThrow,
];
validators.forEach((validator) => validator(name));
};