diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 8d30dda..2bac95d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -9,7 +9,7 @@ public class ValidationMessages { // Entity Template validation messages public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; - public static final String PROPERTY_DEFINITIONS_MANDATORY = "Entity Template property definitions are mandatory and cannot be empty"; + public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; @@ -23,8 +23,38 @@ public class ValidationMessages { // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target entity identifier is mandatory and cannot be blank"; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; + + // Property Rules validation messages - templates and specific constraints + public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; + public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; + + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + + // Helper method to construct rules incompatibility message + public static String rulesAreIncompatible(String rule1, String rule2) { + return PROPERTY_RULES_MUTUALLY_EXCLUSIVE + .replace("{rule1}", rule1) + .replace("{rule2}", rule2); + } + + // Helper method to construct rule-not-allowed message + public static String ruleNotAllowed(String rule, String propertyType) { + return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE + .replace("{rule}", rule) + .replace("{type}", propertyType); + } + + // Helper method to construct min/max constraint violation message + public static String minMaxConstraintViolated(String constraint) { + return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED + .replace("{constraint}", constraint); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java similarity index 92% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java index aff0ad4..adbb597 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java @@ -1,9 +1,9 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; /// Exception thrown when attempting to create an [EntityTemplate] with an identifier that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java new file mode 100644 index 0000000..3d0a149 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_CANNOT_CHANGE; + +/// Exception thrown when attempting to change an [EntityTemplate] identifier after creation. +/// +/// **Why this exception exists:** +/// - Entity template identifiers are immutable once the template is created +/// - Prevents accidental or malicious modifications to template identity +/// - Maintains separation of concerns between domain rules and HTTP status codes +/// +/// **Usage patterns:** +/// - Thrown from [EntityTemplateService] when identifier modification is attempted +/// - Caught by [ApiExceptionHandler] and mapped to HTTP 400 status +/// - Contains the identifier that was attempted to be changed for debugging +public class EntityTemplateIdentifierCannotChangeException extends RuntimeException { + + public EntityTemplateIdentifierCannotChangeException(String identifier) { + super(TEMPLATE_IDENTIFIER_CANNOT_CHANGE + identifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java similarity index 87% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java index 885bc83..b690d2a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java @@ -1,9 +1,9 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; /// Exception thrown when attempting to create or update an [EntityTemplate] with a name that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java similarity index 97% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java index bca9ccd..c765a4f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import java.util.UUID; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java new file mode 100644 index 0000000..650637d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Domain exception for property rule validation violations. +/// +/// **Business purpose:** Represents the business rule violation when property rules +/// conflict with their assigned property type. This ensures data integrity +/// by preventing invalid rule configurations before persistence. +/// +/// **Usage patterns:** +/// - Property template creation with invalid rules +/// - Property template updates introducing rule conflicts +public class PropertyDefinitionRulesConflictException extends RuntimeException { + + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + + ": " + violationMessage); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java index b09cff9..b21d871 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java similarity index 78% rename from src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java index d9cf376..06690e2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java @@ -1,9 +1,8 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.entity_template; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; @@ -12,9 +11,9 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; @@ -43,6 +42,7 @@ public class EntityTemplateService { private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final EntityTemplateValidationService entityTemplateValidationService; /// Retrieves paginated entity templates for management interface display. /// @@ -79,6 +79,7 @@ public EntityTemplate getEntityTemplateByIdentifier(String identifier) { /// **Business rules enforced:** /// - If `identifier` is provided it must not already exist in the system. /// - If `name` is provided it must not already exist in the system. + /// - Validation of property rules according to their defined constraints. /// /// @param entityTemplate validated template to create and persist /// @return the persisted template with generated identifiers @@ -86,14 +87,7 @@ public EntityTemplate getEntityTemplateByIdentifier(String identifier) { /// @throws EntityTemplateNameAlreadyExistsException when name already exists @Transactional public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) { - if (entityTemplate.identifier() != null && - entityTemplateRepositoryPort.existsByIdentifier(entityTemplate.identifier())) { - throw new EntityTemplateAlreadyExistsException(entityTemplate.identifier()); - } - if (entityTemplate.name() != null && - entityTemplateRepositoryPort.existsByName(entityTemplate.name())) { - throw new EntityTemplateNameAlreadyExistsException(entityTemplate.name()); - } + entityTemplateValidationService.validateForCreation(entityTemplate); return entityTemplateRepositoryPort.save(entityTemplate); } @@ -112,41 +106,44 @@ public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) /// - *Matched by name* → existing ID is preserved, other fields are overwritten. /// - *Not matched* → treated as a new definition (no ID yet). /// - *Missing from update* → removed (handled downstream by the persistence adapter). + /// - Validation of property rules according to their defined constraints. /// /// @param identifier current business identifier of the template to update - /// @param updatedTemplate validated template carrying the desired state + /// @param entityTemplate validated template carrying the desired state /// @return the persisted template after merge, with generated or preserved identifiers /// @throws EntityTemplateNotFoundException when no template matches `identifier` /// @throws EntityTemplateAlreadyExistsException when renaming would cause a duplicate @Transactional - public EntityTemplate putEntityTemplate(String identifier, @Valid EntityTemplate updatedTemplate) { + public EntityTemplate updateEntityTemplate(String identifier, @Valid EntityTemplate entityTemplate) { EntityTemplate existingTemplate = getEntityTemplateByIdentifier(identifier); - - if (!identifier.equals(updatedTemplate.identifier()) && - entityTemplateRepositoryPort.existsByIdentifier(updatedTemplate.identifier())) { - throw new EntityTemplateAlreadyExistsException(updatedTemplate.identifier()); - } - - if (updatedTemplate.name() != null && - !Objects.equals(existingTemplate.name(), updatedTemplate.name()) && - entityTemplateRepositoryPort.existsByName(updatedTemplate.name())) { - throw new EntityTemplateNameAlreadyExistsException(updatedTemplate.name()); - } - EntityTemplate mergedTemplate = new EntityTemplate( existingTemplate.id(), - updatedTemplate.identifier(), - updatedTemplate.name(), - updatedTemplate.description(), + entityTemplate.identifier(), + entityTemplate.name(), + entityTemplate.description(), mergePropertyDefinitions(existingTemplate.propertiesDefinitions(), - updatedTemplate.propertiesDefinitions()), + entityTemplate.propertiesDefinitions()), mergeRelationDefinitions(existingTemplate.relationsDefinitions(), - updatedTemplate.relationsDefinitions()) + entityTemplate.relationsDefinitions()) ); - + entityTemplateValidationService.validateForUpdate(identifier, existingTemplate.name(), mergedTemplate); return entityTemplateRepositoryPort.save(mergedTemplate); } + /// Deletes an entity template by business identifier with existence validation. + /// + /// **Contract:** Validates template existence before deletion to ensure referential + /// integrity. Deletion cascades through persistence layer according to configured + /// relationships. This operation is irreversible once committed. + /// + /// @param identifier unique business identifier of template to delete + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public void deleteEntityTemplate(String identifier) { + entityTemplateValidationService.validateForDeletion(identifier); + entityTemplateRepositoryPort.deleteByIdentifier(identifier); + } + private List mergePropertyDefinitions( List existing, List updated) { @@ -162,16 +159,14 @@ private List mergePropertyDefinitions( for (PropertyDefinition prop : updated) { PropertyDefinition existingProp = existingMap.get(prop.name()); if (existingProp != null) { - // Records are immutable - create a new instance - PropertyDefinition merged = new PropertyDefinition( + result.add(new PropertyDefinition( existingProp.id(), prop.name(), prop.description(), prop.type(), prop.required(), mergePropertyRules(existingProp.rules(), prop.rules()) - ); - result.add(merged); + )); } else { result.add(prop); } @@ -188,7 +183,6 @@ private PropertyRules mergePropertyRules(PropertyRules existingRules, PropertyRu return newRules; } - // Records are immutable - create a new instance return new PropertyRules( existingRules.id(), newRules.format(), @@ -216,15 +210,13 @@ private List mergeRelationDefinitions( for (RelationDefinition rel : updated) { RelationDefinition existingRel = existingMap.get(rel.name()); if (existingRel != null) { - // Records are immutable - create a new instance - RelationDefinition merged = new RelationDefinition( + result.add(new RelationDefinition( existingRel.id(), rel.name(), rel.targetTemplateIdentifier(), rel.required(), rel.toMany() - ); - result.add(merged); + )); } else { result.add(rel); } @@ -233,24 +225,4 @@ private List mergeRelationDefinitions( return result; } - - /// Deletes an entity template by business identifier with existence validation. - /// - /// **Contract:** Validates template existence before deletion to ensure referential - /// integrity. Deletion cascades through persistence layer according to configured - /// relationships. This operation is irreversible once committed. - /// - /// @param identifier unique business identifier of template to delete - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public void deleteEntityTemplate(String identifier) { - if (identifier == null) { - throw new IllegalArgumentException("Template identifier must not be null"); - } - if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { - throw new EntityTemplateNotFoundException("identifier", identifier); - } - entityTemplateRepositoryPort.deleteByIdentifier(identifier); - } - } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java new file mode 100644 index 0000000..afd5377 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -0,0 +1,122 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import java.util.Objects; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; + +import lombok.RequiredArgsConstructor; + +/// Domain service to centralize all functional validation rules for [EntityTemplate] operations. +/// +/// **Key responsibilities:** +/// - Identifier and name uniqueness enforcement for create and update operations +/// - Property-rule compatibility validation (type vs. rule constraints) delegated to [PropertyDefinitionValidationService] +/// - Template existence verification before deletion +@Service +@RequiredArgsConstructor +public class EntityTemplateValidationService { + + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final PropertyDefinitionValidationService propertyDefinitionValidationService; + + /// Validates all business rules before creating a new entity template. + /// + /// **Business rules enforced:** + /// - If `identifier` is provided it must not already exist in the system. + /// - If `name` is provided it must not already exist in the system. + /// - Property rules must be compatible with their declared property type. + /// + /// @param entityTemplate the template candidate to validate + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants + public void validateForCreation(EntityTemplate entityTemplate) { + validateIdentifierUniqueness(entityTemplate.identifier()); + validateNameUniqueness(entityTemplate.name()); + validatePropertyRules(entityTemplate); + } + + /// Validates all business rules before persisting an updated entity template. + /// + /// **Business rules enforced:** + /// - If the identifier changed, the new value must not collide with another template. + /// - If the name changed, the new value must not collide with another template. + /// - Property rules in the merged template must be compatible with their declared type. + /// + /// @param currentIdentifier the identifier of the template being replaced + /// @param existingName the current name of the template being replaced + /// @param mergedTemplate the fully-merged template carrying the desired state + /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken + /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken + /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants + public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate mergedTemplate) { + if (!currentIdentifier.equals(mergedTemplate.identifier())) { + throw new EntityTemplateIdentifierCannotChangeException(mergedTemplate.identifier()); + } + if (!Objects.equals(existingName, mergedTemplate.name())) { + validateNameUniqueness(mergedTemplate.name()); + } + validatePropertyRules(mergedTemplate); + } + + /// Validates that a template identifier is non-null and refers to an existing template. + /// + /// @param identifier the identifier of the template to delete + /// @throws EntityTemplateNotFoundException when `identifier` is null + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + public void validateForDeletion(String identifier) { + if (identifier == null) { + throw new EntityTemplateNotFoundException("identifier", "null"); + } + validateTemplateExists(identifier); + } + + /// Checks that the entity template exists. + /// + /// @param identifier the identifier to check for existence + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + public void validateTemplateExists(String identifier) { + if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException("identifier", identifier); + } + } + + /// Checks that no other template already uses the given identifier. + /// + /// @param identifier the identifier to check for uniqueness + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + public void validateIdentifierUniqueness(String identifier) { + if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); + } + } + + /// Checks that no other template already uses the given name. + /// + /// @param name the name to check for uniqueness + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + public void validateNameUniqueness(String name) { + if (entityTemplateRepositoryPort.existsByName(name)) { + throw new EntityTemplateNameAlreadyExistsException(name); + } + } + + public void validatePropertyRules(EntityTemplate entityTemplate) { + if (entityTemplate.propertiesDefinitions() == null) { + return; + } + for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { + propertyDefinitionValidationService.validatePropertyDefinitionRules(property); + } + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java new file mode 100644 index 0000000..9ce60f1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -0,0 +1,284 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MAX_LENGTH_POSITIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.minMaxConstraintViolated; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; + +/// Domain service for validating property rule compatibility with property types. +/// +/// **Business rules:** +/// - STRING: Allows format, enum_values, regex, max_length, min_length. Rejects numeric rules. +/// - NUMBER: Allows max_value, min_value. Rejects string and format rules. +/// - BOOLEAN: Rejects all rules; rules field must be null or empty. +/// +/// **Key responsibilities:** +/// - Type-to-rule compatibility validation +/// - Constraint ordering validation (min ≤ max) +/// - Regex pattern validation (delegated to [PropertyRegexValidationService]) +/// +@Service +@RequiredArgsConstructor +public class PropertyDefinitionValidationService { + + private final PropertyRegexValidationService propertyRegexValidationService; + + // Rule name constants + public static final String REGEX = "regex"; + public static final String LENGTH = "length"; + public static final String VALUE = "value"; + public static final String FORMAT = "format"; + public static final String ENUM_VALUES = "enum_values"; + public static final String MAX_LENGTH = "max_length"; + public static final String MIN_LENGTH = "min_length"; + public static final String MAX_VALUE = "max_value"; + public static final String MIN_VALUE = "min_value"; + + /// Validates property rules are compatible with the property's data type. + /// + /// **Contract:** Performs comprehensive validation including: + /// - Rule type compatibility with property type + /// - Numeric constraint ordering (min ≤ max) + /// - Boolean properties reject all rules + /// + /// @param propertyDefinition the property definition containing type and rules + /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants + public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { + if (propertyDefinition.rules() == null) { + return; + } + + PropertyRules rules = propertyDefinition.rules(); + PropertyType type = propertyDefinition.type(); + + switch (type) { + case STRING: + validateStringPropertyRules(propertyDefinition.name(), rules); + break; + case NUMBER: + validateNumberPropertyRules(propertyDefinition.name(), rules); + break; + case BOOLEAN: + validateBooleanPropertyRules(propertyDefinition.name(), rules); + break; + default: + throw new IllegalArgumentException("Unknown property type: " + type); + } + } + + /// Validates rules for STRING property type. + /// + /// **Allowed rules:** format, enum_values, regex, max_length, min_length + /// **Rejected rules:** max_value, min_value (numeric) + /// **Conflicting rules:** format, regex, and enum_values are mutually exclusive; + /// enum_values is also mutually exclusive with max_length and min_length + /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when rules defined violate any of the above constraints + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { + validateStringIncompatibleRules(propertyName, rules); + validateStringConstraints(propertyName, rules); + + // Validate regex pattern is valid + if (rules.regex() != null && !rules.regex().isBlank()) { + propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); + } + } + + /// Validates numeric constraints for STRING property rules. + /// + /// **Constraints enforced:** + /// - min_length must be non-negative (≥ 0) + /// - max_length must be positive (> 0) + /// - min_length must be less than or equal to max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any constraint is violated + private void validateStringConstraints(String propertyName, PropertyRules rules) { + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE + ); + } + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_MAX_LENGTH_POSITIVE + ); + } + // Validate min_length is below or equal to max_length + if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + minMaxConstraintViolated(LENGTH) + ); + } + } + + /// Validates rule compatibility and mutual exclusivity for STRING property rules. + /// + /// **Incompatibility rules enforced:** + /// - Numeric rules (max_value, min_value) are not allowed for STRING type + /// - format, regex, and enum_values are mutually exclusive + /// - enum_values and length constraints (max_length, min_length) are mutually exclusive + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when incompatible rules are both present + private void validateStringIncompatibleRules(String propertyName, PropertyRules rules){ + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) + ); + } + + // format, regex, and enum_values are incompatible with each other + if (rules.format() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(FORMAT, ENUM_VALUES) + ); + } + if (rules.format() != null && rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(FORMAT, REGEX) + ); + } + if (rules.regex() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(REGEX, ENUM_VALUES) + ); + } + + // enum_values and length constraints are incompatible with each other + if (rules.enumValues() != null && rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH) + ); + } + if (rules.enumValues() != null && rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH) + ); + } + + } + + /// Validates rules for NUMBER property type. + /// + /// **Allowed rules:** max_value, min_value + /// **Rejected rules:** format, enum_values, regex, max_length, min_length (string) + /// **Constraints:** min_value ≤ max_value + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when string rules are present + /// or min/max value constraints are violated + private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) + ); + } + + if (rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) + ); + } + + if (rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) + ); + } + + if (rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) + ); + } + + if (rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) + ); + } + + if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + minMaxConstraintViolated(VALUE) + ); + } + } + + /// Validates rules for BOOLEAN property type. + /// + /// **Allowed rules:** None + /// **Rejected rules:** All rules must be null or empty + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null || + rules.enumValues() != null || + rules.regex() != null || + rules.maxLength() != null || + rules.minLength() != null || + rules.maxValue() != null || + rules.minValue() != null) { + + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED + ); + } + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java new file mode 100644 index 0000000..2d70bc1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java @@ -0,0 +1,326 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Domain service for validating regex patterns and detecting ReDoS (Regular Expression Denial of Service) vulnerabilities. +/// +/// **Key responsibilities:** +/// - Pattern compilation and syntax validation +/// - ReDoS detection through timeout-based execution +/// - Static analysis for dangerous regex constructs (nested quantifiers, lookarounds, etc.) +/// - Comprehensive security guardrails against regex injection and catastrophic backtracking +/// +@Service +public class PropertyRegexValidationService { + + // Static and thread-safe regex validator executor + private static final ExecutorService VALIDATION_EXECUTOR = Executors.newFixedThreadPool( + Runtime.getRuntime().availableProcessors(), + runnable -> { + Thread thread = new Thread(runnable, "regex-validator-thread"); + thread.setDaemon(true); + return thread; + } + ); + private static final int MAX_REGEX_LENGTH = 1000; + private static final int VALIDATION_TIMEOUT_MS = 30; + // Validation ReDoS probe string designed to trigger backtracking in vulnerable patterns + private static final String STRESS_PROBE = "a".repeat(50) + "!"; + + /// Validates the user-provided regex pattern against ReDoS and injection risks. + /// + /// **Security checks:** + /// 1. Rejects patterns exceeding 1,000 characters. + /// 2. Rejects known dangerous regex patterns. + /// 3. Ensures the pattern is valid Java regex. + /// 4. Detects ReDoS by executing pattern matching within 10ms timeout. + /// + /// @param propertyName name of the property (for error reporting) + /// @param regexPattern the regex pattern to validate + /// @throws PropertyDefinitionRulesConflictException if any security check fails + public void validateRegexPattern(String propertyName, String regexPattern) { + if (regexPattern.length() > MAX_REGEX_LENGTH) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)"); + } + + if (containsDangerousPatterns(regexPattern)) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern contains potentially unsafe constructs"); + } + + Pattern compiledRegexPattern; + try { + compiledRegexPattern = Pattern.compile(regexPattern); + } catch (PatternSyntaxException e) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Invalid regex pattern: " + e.getMessage()); + } + + validatePatternWithTimeout(propertyName, compiledRegexPattern); + } + + /// Validates pattern matching with a timeout to detect ReDoS (Regular Expression Denial of Service) vulnerabilities. + /// + /// Executes a pattern match against a stress probe within a 10 ms timeout using a shared, bounded executor + /// If the pattern takes longer than the timeout, it is rejected as potentially vulnerable to catastrophic backtracking. + /// + /// @param propertyName name of the property (for error reporting) + /// @param pattern the compiled pattern to test + /// @throws PropertyDefinitionRulesConflictException if the pattern times out or validation fails + private void validatePatternWithTimeout(String propertyName, Pattern pattern) { + Future future = VALIDATION_EXECUTOR.submit(() -> pattern.matcher(STRESS_PROBE).matches()); + try { + future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException _) { + future.cancel(true); + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern validation was interrupted"); + } catch (ExecutionException e) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex validation failed: " + e.getCause().getMessage()); + } + } + + /// Checks for known dangerous regex constructs using static string analysis. + /// + /// **Patterns detected:** + /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// - Quantified alternation groups: `(a|b)+`, `(a|b)*` + /// - Unbounded repetition upper bounds greater than 1,000 (e.g. `{5,9999}`) + /// - Lookarounds with quantifiers: `(?=a+)`, `(?!a*)` + /// + /// **Implementation:** Uses static string analysis without regex matching + /// to avoid ReDoS vulnerabilities in the validator itself. + /// + /// @param pattern the raw regex string to analyse + /// @return `true` if potentially dangerous constructs are detected + private boolean containsDangerousPatterns(String pattern) { + return hasNestedQuantifiers(pattern) || + hasQuantifiedAlternation(pattern) || + hasLargeRepetitionBounds(pattern) || + hasLookaroundsWithQuantifiers(pattern); + } + + /// Detects nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if nested quantifiers are found + private boolean hasNestedQuantifiers(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, this::containsQuantifier)) { + return true; + } + } + return false; + } + + /// Checks if a group starting at index i matches the quantified pattern criteria. + /// The pattern must have a closing paren followed by a quantifier (+, *, ?, {), + /// and the group content must match the provided test. + /// + /// @param pattern the regex pattern string + /// @param groupStartIndex the index of the opening parenthesis + /// @param test the test to apply to group content + /// @return true if the group matches the criteria + private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, Predicate test) { + int closeIndex = findMatchingCloseParenthesis(pattern, groupStartIndex); + if (closeIndex == -1 || closeIndex + 1 >= pattern.length()) { + return false; + } + + char nextChar = pattern.charAt(closeIndex + 1); + if (!isQuantifier(nextChar)) { + return false; + } + + String groupContent = pattern.substring(groupStartIndex + 1, closeIndex); + return test.test(groupContent); + } + + /// Detects quantified alternation groups like `(a|b)+` or `(a|b)*`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if quantified alternation is found + private boolean hasQuantifiedAlternation(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, groupContent -> groupContent.contains("|"))) { + return true; + } + } + return false; + } + + /// Detects repetition bounds with excessively large upper limits like `{5,9999}`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if large repetition bounds are found + private boolean hasLargeRepetitionBounds(String pattern) { + int i = 0; + while (i < pattern.length()) { + if (pattern.charAt(i) == '{') { + if (isLargeRepetitionBound(pattern, i)) { + return true; + } + int closeIndex = pattern.indexOf('}', i); + i = closeIndex != -1 ? closeIndex + 1 : i + 1; + } else { + i++; + } + } + return false; + } + + /// Checks if the repetition bound starting at position i exceeds the safe limit. + /// + /// @param pattern the regex pattern string + /// @param startIndex the index of the opening brace + /// @return true if the upper bound is greater than 1000 + private boolean isLargeRepetitionBound(String pattern, int startIndex) { + int closeIndex = pattern.indexOf('}', startIndex); + if (closeIndex == -1) { + return false; + } + + String bounds = pattern.substring(startIndex + 1, closeIndex); + return hasExcessiveUpperBound(bounds); + } + + /// Parses a repetition bound string and checks if the upper limit exceeds 1000. + /// + /// @param bounds the bounds string (e.g., "5,9999" or "1,100") + /// @return true if upper bound is greater than 1000 + private boolean hasExcessiveUpperBound(String bounds) { + if (!bounds.contains(",")) { + return false; + } + + String[] parts = bounds.split(","); + if (parts.length != 2 || parts[1].trim().isEmpty()) { + return false; + } + + try { + int upper = Integer.parseInt(parts[1].trim()); + return upper > 1000; + } catch (NumberFormatException _) { + return false; + } + } + + /// Detects lookarounds with quantifiers like `(?=a+)`, `(?!a*)`, etc. + /// These can amplify backtracking behavior and pose ReDoS risks. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if lookarounds with quantifiers are found + private boolean hasLookaroundsWithQuantifiers(String pattern) { + for (int i = 0; i < pattern.length() - 3; i++) { + if (isLookaroundAt(pattern, i)) { + int closeIndex = findMatchingCloseParenthesis(pattern, i); + if (closeIndex != -1) { + String lookaroundContent = pattern.substring(i, closeIndex + 1); + if (containsQuantifier(lookaroundContent)) { + return true; + } + } + } + } + return false; + } + + /// Checks if position i in pattern is the start of a lookaround construct. + /// Lookarounds are: `(?=...)`, `(?!...)`, `(?<=...)`, `(? handleEntityTemplateNameAlreadyExistsExcept return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } + /// Handles domain exception when attempting to change an entity template identifier. + /// + /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException to HTTP 400 + /// status indicating validation error for immutable identifier field. + @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) + public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( + EntityTemplateIdentifierCannotChangeException ex) { + log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception for wrong entity template property rules. + /// + /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to HTTP 400 + /// status indicating validation error for wrong property rules. + @ExceptionHandler(PropertyDefinitionRulesConflictException.class) + public ResponseEntity handleWrongPropertyRulesException( + PropertyDefinitionRulesConflictException ex) { + log.warn("Wrong Entity template property rules: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + /// Handles Bean Validation constraint violations from domain model validation. /// /// **Error aggregation:** Combines multiple constraint violation messages into @@ -160,12 +186,43 @@ private String parseDeserializationError(String originalMessage) { if (originalMessage.contains("not one of the values accepted for Enum class")) { return parseEnumDeserializationError(originalMessage); } + return parseTypeDeserializationError(originalMessage); + } + + private String parseTypeDeserializationError(String originalMessage) { + String targetType = extractTargetType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); + + if (!targetType.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property, expected " + targetType; + } else if (!targetType.isEmpty()) { + return "Invalid type: expected " + targetType; + } return "Cannot deserialize request body property"; } + private String extractTargetType(String message) { + Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); + Matcher matcher = typePattern.matcher(message); + if (matcher.find()) { + String fullType = matcher.group(1); + return fullType.substring(fullType.lastIndexOf('.') + 1); + } + return ""; + } + + private String extractInvalidValueFromString(String message) { + Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); + Matcher matcher = valuePattern.matcher(message); + if (matcher.find()) { + return matcher.group(1); + } + return ""; + } + private String parseEnumDeserializationError(String originalMessage) { String enumTypeName = getPropertyNameFromEnumType(originalMessage); - String invalidValue = extractInvalidValue(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; @@ -190,15 +247,6 @@ private String getPropertyNameFromEnumType(String message) { return ""; } - private String extractInvalidValue(String message) { - int valueStart = message.indexOf("from String \"") + 13; - int valueEnd = message.indexOf("\"", valueStart); - if (valueStart > 12 && valueEnd > valueStart) { - return message.substring(valueStart, valueEnd); - } - return ""; - } - /// Handles all unexpected exceptions as safety fallback. /// /// **Security consideration:** Returns generic error message to prevent information diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 2c295a3..2bd2b04 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -21,7 +21,7 @@ import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.decathlon.idp_core.domain.service.EntityService; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.RelationService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; @@ -159,29 +159,33 @@ private Map mapPropertiesDto(Entity entity, EntityTemplate entit .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); return entity.properties().stream() + .filter(prop -> prop.value() != null) .collect(Collectors.toMap( Property::name, - prop -> { - PropertyDefinition def = propertiesDefinitions.get(prop.name()); - if (def != null) { - PropertyType type = def.type(); - String value = prop.value(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return null; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); - } - // Default to string - return value; - } else { - // Fallback if propertyDefinition is missing - return prop.value(); - } - })); + prop -> convertPropertyValue(prop, propertiesDefinitions.get(prop.name())))); + } + + /// Converts a property value to its typed representation based on the property definition. + /// + /// @param property the property to convert + /// @param definition the property definition for type information, may be null + /// @return the typed value, falling back to the raw string value + private Object convertPropertyValue(Property property, PropertyDefinition definition) { + String value = property.value(); + if (definition == null) { + return value; + } + PropertyType type = definition.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(value); + } catch (NumberFormatException _) { + return value; + } + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(value); + } + return value; } /// Maps the relations of an entity to a map of relation names to lists of target diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entitytemplate/EntityTemplateMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java similarity index 96% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entitytemplate/EntityTemplateMapper.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java index aa7074a..3dac279 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entitytemplate/EntityTemplateMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entitytemplate; +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_template; import java.util.List; @@ -13,10 +13,10 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.PropertyDefinitionDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.PropertyRulesDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.RelationDefinitionDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.EntityTemplateDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.PropertyDefinitionDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.PropertyRulesDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.RelationDefinitionDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.PropertyDefinitionDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.PropertyRulesDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.RelationDefinitionDtoOut; /// Mapper component for converting between [EntityTemplate] DTOs and domain entities. /// @@ -196,7 +196,9 @@ public PropertyRules toPropertyRules(PropertyRulesDtoIn dto) { return new PropertyRules( null, dto.getFormat(), - dto.getEnumValues() != null ? List.of(dto.getEnumValues()) : null, + dto.getEnumValues() != null + ? List.of(dto.getEnumValues()).stream().map(String::toUpperCase).toList() + : null, dto.getRegex(), dto.getMaxLength(), dto.getMinLength(), @@ -211,7 +213,6 @@ public PropertyRulesDtoOut toDto(PropertyRules entity) { } return PropertyRulesDtoOut.builder() - .id(entity.id()) .format(entity.format()) .enumValues(entity.enumValues() != null ? entity.enumValues().toArray(new String[0]) : null) .regex(entity.regex()) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c35720b..4b9adad 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,12 @@ spring: jackson: # Serializes all JSON fields as snake_case (for example templateIdentifier → template_identifier). property-naming-strategy: SNAKE_CASE + deserialization: + # Fails deserialization if JSON contains unknown fields not mapped to Java properties. + fail-on-unknown-properties: true + mapper: + # Allow enum values to be read case-insensitively (e.g., "string", "STRING", "String" all map to STRING). + accept-case-insensitive-enums: true profiles: # Activates a Spring profile from the env var SPRING_PROFILE (defaults to none). diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java new file mode 100644 index 0000000..56e1043 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java @@ -0,0 +1,968 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.UUID; + +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyFormat; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +@DisplayName("PropertyDefinitionValidationService Tests") +class PropertyDefinitionValidationServiceTest { + + private PropertyDefinitionValidationService propertyDefinitionValidationService; + + @BeforeEach + void setUp() { + propertyDefinitionValidationService = new PropertyDefinitionValidationService(new PropertyRegexValidationService()); + } + + @Nested + @DisplayName("STRING Property Type") + class StringPropertyTypeTests { + + @Test + @DisplayName("Happy path: STRING with format and max_length rules") + void testStringWithValidRules() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + null, + null, + 255, + 1, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "email", + "Email address", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with min_length and max_length") + void testStringWithLengthConstraints() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 100, + 10, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "description", + "A description", + PropertyType.STRING, + false, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with enum_values") + void testStringWithEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("ACTIVE", "INACTIVE"), + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "status", + "Status", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with regex pattern") + void testStringWithRegex() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "^[a-zA-Z0-9]+$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "username", + "Username", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: STRING with numeric max_value rule") + void testStringRejectsMaxValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 100, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "name", + "Name", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("name")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: STRING with numeric min_value rule") + void testStringRejectsMinValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + null, + 0 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "counter", + "Counter", + PropertyType.STRING, + false, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("counter")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: STRING with min_length > max_length") + void testStringWithInvalidLengthConstraints() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 50, + 100, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("min_length")); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: STRING with negative min_length") + void testStringWithNegativeMinLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 255, + -1, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("min_length")); + assertTrue(ex.getMessage().contains("0")); + } + + @Test + @DisplayName("Error: STRING with invalid regex pattern") + void testStringWithInvalidRegexPattern() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "[invalid-regex", + 255, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("regex")); + assertTrue(ex.getMessage().contains("[invalid-regex")); + } + + @Test + @DisplayName("Happy path: STRING with null rules") + void testStringWithNullRules() { + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + null + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with min_length = 0 and max_length > 0") + void testStringWithZeroMinLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 100, + 0, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "optional_field", + "An optional field", + PropertyType.STRING, + false, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: STRING with max_length <= 0") + void testStringWithNonPositiveMaxLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 0, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("max_length")); + assertTrue(ex.getMessage().contains("greater than 0")); + } + + @Test + @DisplayName("Error: STRING with format and enum_values combined") + void testStringRejectsFormatWithEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + List.of("EMAIL", "POSTAL_CODE"), + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "contact", + "Contact field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("format")); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: STRING with format and regex combined") + void testStringRejectsFormatWithRegex() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + null, + "^[a-zA-Z]+$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "contact", + "Contact field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("format")); + assertTrue(ex.getMessage().contains("regex")); + } + + @Test + @DisplayName("Error: STRING with regex and enum_values combined") + void testStringRejectsRegexWithEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("ACTIVE", "INACTIVE"), + "^[A-Z]+$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "status", + "Status field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("regex")); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: STRING with enum_values and max_length combined") + void testStringRejectsEnumValuesWithMaxLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("EMAIL", "POSTAL_CODE"), + null, + 12, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "contact_type", + "Contact type field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("enum_values")); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: STRING with enum_values and min_length combined") + void testStringRejectsEnumValuesWithMinLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("EMAIL", "POSTAL_CODE"), + null, + null, + 3, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "contact_type", + "Contact type field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("enum_values")); + assertTrue(ex.getMessage().contains("min_length")); + } + } + + @Nested + @DisplayName("NUMBER Property Type") + class NumberPropertyTypeTests { + + @Test + @DisplayName("Happy path: NUMBER with min_value and max_value") + void testNumberWithValidRules() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 1000, + 0 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "score", + "Numeric score", + PropertyType.NUMBER, + true, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with only max_value") + void testNumberWithOnlyMaxValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 100, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "percentage", + "Percentage value", + PropertyType.NUMBER, + false, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: NUMBER with format rule") + void testNumberRejectsFormat() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + null, + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "value", + "Numeric value", + PropertyType.NUMBER, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("value")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertTrue(ex.getMessage().contains("format")); + } + + @Test + @DisplayName("Error: NUMBER with enum_values rule") + void testNumberRejectsEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("1", "2", "3"), + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "category", + "Category", + PropertyType.NUMBER, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: NUMBER with regex rule") + void testNumberRejectsRegex() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "^[0-9]+$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "id", + "ID", + PropertyType.NUMBER, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("regex")); + } + + @Test + @DisplayName("Error: NUMBER with min_length rule") + void testNumberRejectsMinLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + 5, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.NUMBER, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("min_length")); + } + + @Test + @DisplayName("Error: NUMBER with max_length rule") + void testNumberRejectsMaxLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 50, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.NUMBER, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: NUMBER with min_value > max_value") + void testNumberWithInvalidValueConstraints() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 0, + 100 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "range", + "A range", + PropertyType.NUMBER, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("min_value")); + assertTrue(ex.getMessage().contains("max_value")); + } + + @Test + @DisplayName("Happy path: NUMBER with only min_value") + void testNumberWithOnlyMinValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + null, + 10 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "minimum_age", + "Minimum age", + PropertyType.NUMBER, + false, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with negative min_value and max_value") + void testNumberWithNegativeValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 100, + -100 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "temperature", + "Temperature", + PropertyType.NUMBER, + false, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with null rules") + void testNumberWithNullRules() { + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "count", + "A count", + PropertyType.NUMBER, + true, + null + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + } + + @Nested + @DisplayName("BOOLEAN Property Type") + class BooleanPropertyTypeTests { + + @Test + @DisplayName("Happy path: BOOLEAN with no rules") + void testBooleanWithNullRules() { + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "active", + "Is active", + PropertyType.BOOLEAN, + true, + null + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: BOOLEAN with format rule") + void testBooleanRejectsFormat() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + null, + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "enabled", + "Enabled", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + assertTrue(ex.getMessage().contains("rules")); + } + + @Test + @DisplayName("Error: BOOLEAN with enum_values rule") + void testBooleanRejectsEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("true", "false"), + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "flag", + "A flag", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with regex rule") + void testBooleanRejectsRegex() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + ".*", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "test", + "Test", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with min_value rule") + void testBooleanRejectsMinValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + null, + 0 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "valid", + "Valid", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with max_value rule") + void testBooleanRejectsMaxValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 1, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "valid", + "Valid", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java new file mode 100644 index 0000000..1e9827f --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java @@ -0,0 +1,83 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; + +@DisplayName("PropertyRegexValidationService Tests") +class PropertyRegexValidationServiceTest { + + private PropertyRegexValidationService propertyRegexValidationService; + + @BeforeEach + void setUp() { + propertyRegexValidationService = new PropertyRegexValidationService(); + } + + @ParameterizedTest + @ValueSource(strings = { + "^[a-z0-9]+@[a-z0-9]+\\.[a-z]{2,}$", // email-like pattern + "^(foo|bar)$", // safe alternation, not quantified + "a{1,999}", // safe repetition bound + "^[a-zA-Z0-9_-]+$", // alphanumeric slug + "^\\d{4}-\\d{2}-\\d{2}$" // ISO date + }) + @DisplayName("Happy path: safe regex patterns are accepted") + void testSafeRegexPatternsAccepted(String safePattern) { + assertDoesNotThrow(() -> propertyRegexValidationService.validateRegexPattern("field", safePattern)); + } + + @Test + @DisplayName("Error: Regex pattern exceeds maximum length (1000 chars)") + void testRegexPatternTooLong() { + String longPattern = "a".repeat(1001); + String propertyName = "field"; + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern(propertyName, longPattern) + ); + assertTrue(ex.getMessage().contains("too long")); + assertTrue(ex.getMessage().contains("1000")); + } + + @ParameterizedTest + @ValueSource(strings = { + "(a+)+", // nested quantifiers with + + "(a*)*", // nested quantifiers with * + "(a+)*", // mixed nested quantifiers + "(a|b)+", // quantified alternation with + + "(foo|bar)*", // quantified alternation with * + "a{1,5000}" // excessive repetition bound + }) + @DisplayName("Error: Regex patterns with ReDoS vulnerabilities") + void testRegexWithDangerousPatterns(String dangerousPattern) { + String propertyName = "field"; + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern(propertyName, dangerousPattern) + ); + assertTrue(ex.getMessage().contains("unsafe"), + "Expected 'unsafe' in error message for pattern: " + dangerousPattern); + } + + @Test + @DisplayName("Error: Regex with invalid syntax") + void testRegexWithInvalidSyntax() { + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern("field", "[unclosed-bracket") + ); + assertTrue(ex.getMessage().contains("Invalid regex")); + } + +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index b6c873c..da0d1a9 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -431,6 +431,19 @@ void postTemplate_400_property_format_invalid_enum() throws Exception { assertNotNull(res, "Test executed successfully"); } + /// Tests the POST /api/v1/entity-templates endpoint with invalid property rules. + /// This test verifies that rules incompatible with property type are rejected. + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when STRING property has numeric rules") + void postTemplate_400_string_property_with_numeric_rules() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_invalid_rules.json", + "Property 'property-test' of type STRING: Numeric rule max_value is not allowed for STRING properties"); + assertNotNull(res, "Test executed successfully"); + } + /// Tests the POST /api/v1/entity-templates endpoint with no property definitions. /// This test verifies that: /// - Templates can be created without any properties @@ -575,7 +588,6 @@ void putTemplate_updateRelations_200() throws Exception { String updateJson = """ { - "identifier": "template-rel-test", "name": "Template Rel Test", "description": "Updated template with new relation", "properties_definitions": [ diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 4d2a73b..880ade3 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -25,8 +25,8 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import jakarta.validation.ConstraintViolation; diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java index 137e5c5..0771e99 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java @@ -21,11 +21,11 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.PropertyDefinitionDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.PropertyRulesDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.RelationDefinitionDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.EntityTemplateDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.PropertyDefinitionDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.PropertyRulesDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.RelationDefinitionDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entitytemplate.EntityTemplateMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.PropertyDefinitionDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.PropertyRulesDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.RelationDefinitionDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_template.EntityTemplateMapper; @DisplayName("EntityTemplateMapper Tests") class EntityTemplateMapperTest { @@ -319,7 +319,6 @@ void shouldMapPropertyEntityToDtoOut() { assertThat(result.getType()).isEqualTo(PropertyType.STRING); assertThat(result.isRequired()).isTrue(); assertThat(result.getRules()).isNotNull(); - assertThat(result.getRules().getId()).isEqualTo(rules.id()); } @Test @@ -343,7 +342,7 @@ void shouldMapRulesDtoInToEntity() { // Given var dto = PropertyRulesDtoIn.builder() .format(PropertyFormat.URL) - .enumValues(new String[]{"http", "https"}) + .enumValues(new String[]{"HTTP", "HTTPS"}) .regex("^https?://.*") .maxLength(500) .minLength(10) @@ -357,7 +356,7 @@ void shouldMapRulesDtoInToEntity() { // Then assertThat(result).isNotNull(); assertThat(result.format()).isEqualTo(PropertyFormat.URL); - assertThat(result.enumValues()).containsExactly("http", "https"); + assertThat(result.enumValues()).containsExactly("HTTP", "HTTPS"); assertThat(result.regex()).isEqualTo("^https?://.*"); assertThat(result.maxLength()).isEqualTo(500); assertThat(result.minLength()).isEqualTo(10); @@ -365,6 +364,21 @@ void shouldMapRulesDtoInToEntity() { assertThat(result.minValue()).isEqualTo(1); } + @Test + @DisplayName("Should normalize enum_values to uppercase") + void shouldNormalizeEnumValuesToUppercase() { + // Given + var dto = PropertyRulesDtoIn.builder() + .enumValues(new String[]{"EMAil", "postal_code", "ACTIVE"}) + .build(); + + // When + PropertyRules result = mapper.toPropertyRules(dto); + + // Then + assertThat(result.enumValues()).containsExactly("EMAIL", "POSTAL_CODE", "ACTIVE"); + } + @Test @DisplayName("Should handle null PropertyRulesDtoIn") void shouldHandleNullRulesDtoIn() { @@ -395,7 +409,6 @@ void shouldMapRulesEntityToDtoOut() { // Then assertThat(result).isNotNull(); - assertThat(result.getId()).isEqualTo(entity.id()); assertThat(result.getFormat()).isEqualTo(PropertyFormat.URL); assertThat(result.getEnumValues()).containsExactly("http", "https"); assertThat(result.getRegex()).isEqualTo("^https?://.*"); diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json index 82501af..d7b2bd1 100644 --- a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json @@ -10,12 +10,10 @@ "type": "STRING", "rules": { "format": "URL", - "enum_values": [], - "regex": "", + "enum_values": null, + "regex": null, "max_length": 200, - "min_length": 1, - "max_value": 0, - "min_value": 0 + "min_length": 1 } } ], diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json index a1502c8..95dc35f 100644 --- a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json @@ -10,12 +10,12 @@ "type": "STRING", "rules": { "format": "URL", - "enum_values": [], - "regex": "", + "enum_values": null, + "regex": null, "max_length": 200, "min_length": 1, - "max_value": 0, - "min_value": 0 + "max_value": null, + "min_value": null } } ], diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_invalid_rules.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_invalid_rules.json new file mode 100644 index 0000000..9fdbcdc --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_invalid_rules.json @@ -0,0 +1,23 @@ +{ + "identifier": "temp-test-wrong-property-rules", + "name": "Temp Test Wrong Property Rules", + "description": "This is a test template", + "properties_definitions": [ + { + "name": "property-test", + "description": "description", + "required": true, + "type": "STRING", + "rules": { + "format": "URL", + "enum_values": [], + "regex": "", + "max_length": 200, + "min_length": 1, + "max_value": 0, + "min_value": 0 + } + } + ], + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_200.json b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_200.json index f723194..1052d2f 100644 --- a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_200.json +++ b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_200.json @@ -9,8 +9,7 @@ "required": true, "rules": { "regex": "^[a-zA-Z0-9]+$", - "max_length": 255, - "min_value": 0 + "max_length": 255 } }, {