Files
twenty/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts
Paul Rastoin 41f3a63962 [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
2025-03-11 12:14:37 +01:00

133 lines
3.9 KiB
TypeScript

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';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
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';
export const validateObjectMetadataInputNamesOrThrow = <
T extends UpdateObjectPayload | CreateObjectInput,
>({
namePlural,
nameSingular,
}: T): void =>
[namePlural, nameSingular].forEach((name) => {
if (!isDefined(name)) {
return;
}
validateObjectMetadataInputNameOrThrow(name);
});
export const validateObjectMetadataInputNameOrThrow = (name: string): void => {
try {
validateMetadataNameOrThrow(name);
} catch (error) {
if (error instanceof InvalidMetadataNameException) {
throw new ObjectMetadataException(
error.message,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
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 (!isDefined(label)) {
throw new ObjectMetadataException(
'Label is required',
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
const prefixedLabel = /^\d/.test(label) ? `n${label}` : label;
if (prefixedLabel === '') {
return '';
}
const formattedString = slugify(prefixedLabel, {
trim: true,
separator: '_',
allowedChars: 'a-zA-Z0-9',
});
if (formattedString === '') {
throw new ObjectMetadataException(
`Invalid label: "${label}"`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
return camelCase(formattedString);
};
export const validateNameAndLabelAreSyncOrThrow = (
label: string,
name: string,
) => {
const computedName = computeMetadataNameFromLabel(label);
if (name !== computedName) {
throw new ObjectMetadataException(
`Name is not synced with label. Expected name: "${computedName}", got ${name}`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
};
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(
message,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
};