From d24ce47af8697ae46aa148cfe401768b5624e7de Mon Sep 17 00:00:00 2001 From: oluexpert99 Date: Tue, 17 Mar 2026 15:39:45 +0100 Subject: [PATCH] FINERACT-2511: Return 400 for invalid dateFormat on client create/update - Validate dateFormat in ClientDataValidator (create and update) so invalid patterns (e.g. "02 February 2026") are rejected with 400 instead of 500. - Add @DateFormat constraint and DateFormatValidator in fineract-validation; ClientDataValidator uses DateFormatValidator.isValidPattern() for reuse. - Annotate BusinessDateUpdateRequest.dateFormat with @DateFormat. - Add ClientDataValidatorTest and DateFormatValidationTest. Signed-off-by: oluexpert99 --- .../data/api/BusinessDateUpdateRequest.java | 2 + .../client/data/ClientDataValidator.java | 20 +++ .../client/data/ClientDataValidatorTest.java | 132 ++++++++++++++++++ .../validation/constraints/DateFormat.java | 40 ++++++ .../constraints/DateFormatValidator.java | 55 ++++++++ .../resources/ValidationMessages.properties | 1 + .../constraints/DateFormatValidationTest.java | 105 ++++++++++++++ 7 files changed, 355 insertions(+) create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/client/data/ClientDataValidatorTest.java create mode 100644 fineract-validation/src/main/java/org/apache/fineract/validation/constraints/DateFormat.java create mode 100644 fineract-validation/src/main/java/org/apache/fineract/validation/constraints/DateFormatValidator.java create mode 100644 fineract-validation/src/test/java/org/apache/fineract/validation/constraints/DateFormatValidationTest.java diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/businessdate/data/api/BusinessDateUpdateRequest.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/businessdate/data/api/BusinessDateUpdateRequest.java index 5f44b66a436..5015d8b6b47 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/businessdate/data/api/BusinessDateUpdateRequest.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/businessdate/data/api/BusinessDateUpdateRequest.java @@ -28,6 +28,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.validation.constraints.DateFormat; import org.apache.fineract.validation.constraints.EnumValue; import org.apache.fineract.validation.constraints.LocalDate; import org.apache.fineract.validation.constraints.Locale; @@ -43,6 +44,7 @@ public class BusinessDateUpdateRequest implements Serializable { private static final long serialVersionUID = 1L; @NotBlank(message = "{org.apache.fineract.businessdate.date-format.not-blank}") + @DateFormat private String dateFormat; @Schema(description = "Type of business date", example = "BUSINESS_DATE", allowableValues = { "BUSINESS_DATE", "COB_DATE" }) @EnumValue(enumClass = BusinessDateType.class, message = "{org.apache.fineract.businessdate.type.invalid}") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java index ac9922704c1..23363e3588c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java @@ -41,6 +41,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.client.api.ClientApiConstants; +import org.apache.fineract.validation.constraints.DateFormatValidator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -230,6 +231,8 @@ public void validateForCreate(final String json) { baseDataValidator.reset().parameter(ClientApiConstants.address).value(address).ignoreIfNull().jsonArrayNotEmpty(); } + validateDateFormatWhenPresent(element, dataValidationErrors); + List dataValidationErrorsForClientNonPerson = getDataValidationErrorsForCreateOnClientNonPerson( element.getAsJsonObject().get(ClientApiConstants.clientNonPersonDetailsParamName)); dataValidationErrors.addAll(dataValidationErrorsForClientNonPerson); @@ -513,6 +516,8 @@ public void validateForUpdate(final String json) { baseDataValidator.reset().parameter("isStaff").value(isStaffFlag).notNull(); } + validateDateFormatWhenPresent(element, dataValidationErrors); + Map parameterUpdateStatusDetails = getParameterUpdateStatusAndDataValidationErrorsForUpdateOnClientNonPerson( element.getAsJsonObject().get(ClientApiConstants.clientNonPersonDetailsParamName)); boolean atLeastOneParameterPassedForClientNonPersonUpdate = (boolean) parameterUpdateStatusDetails.get("parameterUpdateStatus"); @@ -604,6 +609,21 @@ public void validateActivation(final JsonCommand command) { throwExceptionIfValidationWarningsExist(dataValidationErrors); } + private void validateDateFormatWhenPresent(final JsonElement element, final List dataValidationErrors) { + if (!this.fromApiJsonHelper.parameterExists(ClientApiConstants.dateFormatParamName, element)) { + return; + } + final String dateFormat = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.dateFormatParamName, element); + if (StringUtils.isBlank(dateFormat)) { + return; + } + if (!DateFormatValidator.isValidPattern(dateFormat)) { + final String message = "Invalid dateFormat: `" + dateFormat + "`. Use a valid Java date/time pattern (e.g. dd MMMM yyyy)."; + dataValidationErrors.add(ApiParameterError.parameterError("validation.msg.invalid.dateFormat.format", message, + ClientApiConstants.dateFormatParamName)); + } + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { // diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/client/data/ClientDataValidatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/client/data/ClientDataValidatorTest.java new file mode 100644 index 00000000000..23ad507fe4c --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/client/data/ClientDataValidatorTest.java @@ -0,0 +1,132 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.data; + +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 static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import org.apache.fineract.infrastructure.configuration.data.GlobalConfigurationPropertyData; +import org.apache.fineract.infrastructure.configuration.service.ConfigurationReadPlatformService; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.client.api.ClientApiConstants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ClientDataValidatorTest { + + @Mock + private ConfigurationReadPlatformService configurationReadPlatformService; + + private ClientDataValidator validator; + + @BeforeEach + void setUp() { + FromJsonHelper fromApiJsonHelper = new FromJsonHelper(); + when(configurationReadPlatformService.retrieveGlobalConfiguration(anyString())) + .thenReturn(new GlobalConfigurationPropertyData().setEnabled(false)); + validator = new ClientDataValidator(fromApiJsonHelper, configurationReadPlatformService); + } + + private static String validMinimalCreateJson(String dateFormat) { + return """ + { + "officeId": 1, + "firstname": "John", + "lastname": "Doe", + "active": false, + "legalFormId": 1, + "locale": "en", + "dateFormat": "%s" + } + """.formatted(dateFormat); + } + + @Test + void validateForCreate_withInvalidDateFormat_throwsPlatformApiDataValidationException() { + String json = validMinimalCreateJson("02 February 2026"); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateForCreate(json)); + + boolean hasDateFormatError = ex.getErrors().stream() + .anyMatch(e -> ClientApiConstants.dateFormatParamName.equals(e.getParameterName())); + assertTrue(hasDateFormatError, "Expected validation error for parameter 'dateFormat'"); + assertTrue( + ex.getErrors().stream().filter(e -> ClientApiConstants.dateFormatParamName.equals(e.getParameterName())) + .anyMatch(e -> e.getDefaultUserMessage().contains("Invalid dateFormat") + || "validation.msg.invalid.dateFormat.format".equals(e.getDeveloperMessage())), + "Expected dateFormat error to mention invalid dateFormat or use validation.msg.invalid.dateFormat.format"); + } + + @Test + void validateForCreate_withValidDateFormat_doesNotThrow() { + String json = validMinimalCreateJson("dd MMMM yyyy"); + + assertDoesNotThrow(() -> validator.validateForCreate(json)); + } + + @Test + void validateForCreate_withAnotherInvalidDateFormat_throwsPlatformApiDataValidationException() { + String json = validMinimalCreateJson("dd bbb yyyy"); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateForCreate(json)); + + assertTrue(ex.getErrors().stream().anyMatch(e -> ClientApiConstants.dateFormatParamName.equals(e.getParameterName()))); + } + + private static String validMinimalUpdateJson(String dateFormat) { + return """ + { + "firstname": "Jane", + "lastname": "Doe", + "locale": "en", + "dateFormat": "%s" + } + """.formatted(dateFormat); + } + + @Test + void validateForUpdate_withInvalidDateFormat_throwsPlatformApiDataValidationException() { + String json = validMinimalUpdateJson("02 February 2026"); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateForUpdate(json)); + + assertTrue(ex.getErrors().stream().anyMatch(e -> ClientApiConstants.dateFormatParamName.equals(e.getParameterName()))); + } + + @Test + void validateForUpdate_withValidDateFormat_doesNotThrow() { + String json = validMinimalUpdateJson("yyyy-MM-dd"); + + assertDoesNotThrow(() -> validator.validateForUpdate(json)); + } +} diff --git a/fineract-validation/src/main/java/org/apache/fineract/validation/constraints/DateFormat.java b/fineract-validation/src/main/java/org/apache/fineract/validation/constraints/DateFormat.java new file mode 100644 index 00000000000..a186b65b04c --- /dev/null +++ b/fineract-validation/src/main/java/org/apache/fineract/validation/constraints/DateFormat.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.validation.constraints; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = DateFormatValidator.class) +@Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface DateFormat { + + String message() default "{org.apache.fineract.validation.date-format}"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/fineract-validation/src/main/java/org/apache/fineract/validation/constraints/DateFormatValidator.java b/fineract-validation/src/main/java/org/apache/fineract/validation/constraints/DateFormatValidator.java new file mode 100644 index 00000000000..aa095bac277 --- /dev/null +++ b/fineract-validation/src/main/java/org/apache/fineract/validation/constraints/DateFormatValidator.java @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.validation.constraints; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.format.DateTimeFormatter; +import org.apache.commons.lang3.StringUtils; + +public class DateFormatValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (StringUtils.isBlank(value)) { + return true; // blank is allowed; use @NotBlank if required + } + return isValidPattern(value); + } + + /** + * Checks whether the given string is a valid {@link DateTimeFormatter} pattern. Can be used by validators that are + * not annotation-based (e.g. when validating JSON commands that do not bind to a DTO). + * + * @param pattern + * the candidate pattern string + * @return {@code true} if the pattern is valid, {@code false} otherwise + */ + public static boolean isValidPattern(String pattern) { + if (StringUtils.isBlank(pattern)) { + return true; + } + try { + DateTimeFormatter.ofPattern(pattern); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/fineract-validation/src/main/resources/ValidationMessages.properties b/fineract-validation/src/main/resources/ValidationMessages.properties index 03b61d377bf..2bdcdef7d79 100644 --- a/fineract-validation/src/main/resources/ValidationMessages.properties +++ b/fineract-validation/src/main/resources/ValidationMessages.properties @@ -21,6 +21,7 @@ org.apache.fineract.validation.local-date=Wrong local date fields. org.apache.fineract.validation.locale=The parameter `locale` has an invalid language value: `${validatedValue}`. +org.apache.fineract.validation.date-format=The parameter `dateFormat` is not a valid Java date/time pattern: `${validatedValue}`. Use e.g. dd MMMM yyyy or yyyy-MM-dd. org.apache.fineract.validation.enum=The parameter has an invalid enum value: `${validatedValue}`. ## Business Date diff --git a/fineract-validation/src/test/java/org/apache/fineract/validation/constraints/DateFormatValidationTest.java b/fineract-validation/src/test/java/org/apache/fineract/validation/constraints/DateFormatValidationTest.java new file mode 100644 index 00000000000..33483bc93f5 --- /dev/null +++ b/fineract-validation/src/test/java/org/apache/fineract/validation/constraints/DateFormatValidationTest.java @@ -0,0 +1,105 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.validation.constraints; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.validator.HibernateValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest +@ContextConfiguration(classes = { DateFormatValidationTest.TestConfig.class }) +class DateFormatValidationTest { + + @Configuration + @Import({ MessageSourceAutoConfiguration.class }) + static class TestConfig { + + @Bean + public Validator validator() { + return Validation.byProvider(HibernateValidator.class).configure().buildValidatorFactory().getValidator(); + } + } + + @Autowired + private Validator validator; + + @Test + void blankIsValid() { + var request = DateFormatModel.builder().dateFormat("").build(); + var errors = validator.validate(request); + assertThat(errors).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { "02 February 2026", // literal date, not a pattern + "invalid", "dd bbb yyyy", // 'b' is not a valid pattern letter + }) + void invalidPatterns(String dateFormat) { + var request = DateFormatModel.builder().dateFormat(dateFormat).build(); + var errors = validator.validate(request); + assertThat(errors).as("Expected dateFormat '%s' to be invalid", dateFormat).hasSize(1); + assertThat(errors).anyMatch(e -> "dateFormat".equals(e.getPropertyPath().toString())); + } + + @ParameterizedTest + @ValueSource(strings = { "dd MMMM yyyy", "yyyy-MM-dd", "dd/MM/yyyy", "MMM dd, yyyy" }) + void validPatterns(String dateFormat) { + var request = DateFormatModel.builder().dateFormat(dateFormat).build(); + var errors = validator.validate(request); + assertThat(errors).as("Expected dateFormat '%s' to be valid", dateFormat).isEmpty(); + } + + @Test + void staticIsValidPattern_invalid() { + assertThat(DateFormatValidator.isValidPattern("02 February 2026")).isFalse(); + assertThat(DateFormatValidator.isValidPattern("unknown")).isFalse(); + } + + @Test + void staticIsValidPattern_valid() { + assertThat(DateFormatValidator.isValidPattern("dd MMMM yyyy")).isTrue(); + assertThat(DateFormatValidator.isValidPattern("yyyy-MM-dd")).isTrue(); + } + + @Builder + @Data + @NoArgsConstructor + @AllArgsConstructor + static class DateFormatModel { + + @DateFormat + private String dateFormat; + } +}