diff --git a/siva-parent/siva-webapp/pom.xml b/siva-parent/siva-webapp/pom.xml index a071f0bf8..d0101d13d 100644 --- a/siva-parent/siva-webapp/pom.xml +++ b/siva-parent/siva-webapp/pom.xml @@ -123,6 +123,14 @@ json test + + + ee.openid.siva + validation-commons + ${project.version} + test + test-jar + diff --git a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/ValidationExceptionHandler.java b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/ValidationExceptionHandler.java index 5a50245eb..e2ac3c786 100644 --- a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/ValidationExceptionHandler.java +++ b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/ValidationExceptionHandler.java @@ -21,6 +21,7 @@ import ee.openeid.siva.validation.exception.MalformedSignatureFileException; import ee.openeid.siva.validation.exception.ValidationServiceException; import ee.openeid.siva.validation.service.signature.policy.InvalidPolicyException; +import ee.openeid.siva.webapp.request.limitation.RequestSizeLimitExceededException; import ee.openeid.siva.webapp.response.erroneus.RequestValidationError; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; @@ -85,6 +86,14 @@ public RequestValidationError handleInvalidPolicyException(InvalidPolicyExceptio return requestValidationError; } + @ExceptionHandler(RequestSizeLimitExceededException.class) + @ResponseStatus(value = HttpStatus.BAD_REQUEST) + public RequestValidationError handleRequestSizeLimitExceededException(RequestSizeLimitExceededException e) { + RequestValidationError requestValidationError = new RequestValidationError(); + requestValidationError.addFieldError("request", e.getMessage()); + return requestValidationError; + } + private String getMessage(String key) { return messageSource.getMessage(key, null, null); diff --git a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/configuration/HttpRequestLimitConfigurationProperties.java b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/configuration/HttpRequestLimitConfigurationProperties.java new file mode 100644 index 000000000..eee15d9ac --- /dev/null +++ b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/configuration/HttpRequestLimitConfigurationProperties.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.configuration; + +import ee.openeid.siva.webapp.request.validation.annotations.DataSizeMin; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.unit.DataSize; +import org.springframework.validation.annotation.Validated; + +@Data +@Validated +@Configuration +@ConfigurationProperties(prefix = "siva.http.request") +public class HttpRequestLimitConfigurationProperties { + @DataSizeMin(1L) + private DataSize maxRequestSizeLimit; +} diff --git a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/ApplicationRequestSizeLimitFilter.java b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/ApplicationRequestSizeLimitFilter.java new file mode 100644 index 000000000..4ac184943 --- /dev/null +++ b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/ApplicationRequestSizeLimitFilter.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.limitation; + +import ee.openeid.siva.webapp.configuration.HttpRequestLimitConfigurationProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Order(Ordered.HIGHEST_PRECEDENCE) +@Component +@ConditionalOnProperty(prefix = "siva.http.request", name = "max-request-size-limit") +public class ApplicationRequestSizeLimitFilter extends OncePerRequestFilter { + + private final long maxRequestSizeLimit; + + public ApplicationRequestSizeLimitFilter(HttpRequestLimitConfigurationProperties requestLimitProperties) { + maxRequestSizeLimit = requestLimitProperties.getMaxRequestSizeLimit().toBytes(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + SizeLimitingHttpServletRequest sizeLimitingHttpServletRequest = new SizeLimitingHttpServletRequest(request, maxRequestSizeLimit); + filterChain.doFilter(sizeLimitingHttpServletRequest, response); + } +} diff --git a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/RequestSizeLimitExceededException.java b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/RequestSizeLimitExceededException.java new file mode 100644 index 000000000..ad6f7a4eb --- /dev/null +++ b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/RequestSizeLimitExceededException.java @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.limitation; + +public class RequestSizeLimitExceededException extends RuntimeException { + + public RequestSizeLimitExceededException(String message) { + super(message); + } +} diff --git a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingHttpServletRequest.java b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingHttpServletRequest.java new file mode 100644 index 000000000..4a98ebe63 --- /dev/null +++ b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingHttpServletRequest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.limitation; + +import lombok.Getter; + +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.IOException; + +@Getter +public class SizeLimitingHttpServletRequest extends HttpServletRequestWrapper { + private final long maximumAllowedReadLimit; + + public SizeLimitingHttpServletRequest(HttpServletRequest request, long maximumAllowedReadLimit) { + super(request); + this.maximumAllowedReadLimit = maximumAllowedReadLimit; + } + + @Override + public ServletInputStream getInputStream() throws IOException { + ensureContentLengthDoesNotExceedLimit(); + + return new SizeLimitingServletInputStream(super.getInputStream(), maximumAllowedReadLimit); + } + + private void ensureContentLengthDoesNotExceedLimit() { + long contentLength = getContentLengthLong(); + + if (contentLength > maximumAllowedReadLimit) { + throw new RequestSizeLimitExceededException(String.format( + "Request content-length (%d bytes) exceeds request size limit (%d bytes)", + contentLength, maximumAllowedReadLimit + )); + } + } +} diff --git a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingServletInputStream.java b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingServletInputStream.java new file mode 100644 index 000000000..5e24f8b6d --- /dev/null +++ b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingServletInputStream.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.limitation; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import java.io.IOException; + +@RequiredArgsConstructor +public class SizeLimitingServletInputStream extends ServletInputStream { + + private final @NonNull ServletInputStream servletInputStream; + private final long maximumAllowedReadLimit; + + private long bytesRead; + + @Override + public int available() throws IOException { + return servletInputStream.available(); + } + + @Override + public void close() throws IOException { + servletInputStream.close(); + } + + @Override + public boolean isFinished() { + return servletInputStream.isFinished(); + } + + @Override + public boolean isReady() { + return servletInputStream.isReady(); + } + + @Override + public synchronized void mark(int readlimit) { + throw new UnsupportedOperationException("Mark not supported"); + } + + @Override + public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException("Setting read listener not supported"); + } + + @Override + public int read() throws IOException { + final int result = servletInputStream.read(); + + if (result >= 0) { + ++bytesRead; + ensureLimitNotExceeded(); + } + + return result; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + final int result = servletInputStream.read(b, off, calculateMaximumBytesToRead(len)); + + if (result >= 0) { + bytesRead += result; + ensureLimitNotExceeded(); + } + + return result; + } + + @Override + public synchronized void reset() { + throw new UnsupportedOperationException("Mark not supported"); + } + + private int calculateMaximumBytesToRead(int requestedBytesToRead) { + final long remainingAllowedReadLength = maximumAllowedReadLimit - bytesRead; + return remainingAllowedReadLength < requestedBytesToRead + ? (int) (remainingAllowedReadLength + 1) + : requestedBytesToRead; + } + + private void ensureLimitNotExceeded() { + if (bytesRead > maximumAllowedReadLimit) { + throw new RequestSizeLimitExceededException(String.format( + "Request body length exceeds request size limit (%d bytes)", + maximumAllowedReadLimit + )); + } + } +} diff --git a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/validation/annotations/DataSizeMin.java b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/validation/annotations/DataSizeMin.java new file mode 100644 index 000000000..ee086334c --- /dev/null +++ b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/validation/annotations/DataSizeMin.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.validation.annotations; + +import ee.openeid.siva.webapp.request.validation.validators.DataSizeMinValidator; +import org.springframework.util.unit.DataUnit; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = DataSizeMinValidator.class) +public @interface DataSizeMin { + + String message() default "must not be less than {value} {unit}"; + Class[] groups() default {}; + Class[] payload() default {}; + long value() default 0L; + DataUnit unit() default DataUnit.BYTES; +} diff --git a/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/validation/validators/DataSizeMinValidator.java b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/validation/validators/DataSizeMinValidator.java new file mode 100644 index 000000000..b9b922ab9 --- /dev/null +++ b/siva-parent/siva-webapp/src/main/java/ee/openeid/siva/webapp/request/validation/validators/DataSizeMinValidator.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.validation.validators; + +import ee.openeid.siva.webapp.request.validation.annotations.DataSizeMin; +import org.springframework.util.unit.DataSize; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class DataSizeMinValidator implements ConstraintValidator { + + private DataSize minAllowedDataSize; + + @Override + public void initialize(DataSizeMin constraintAnnotation) { + minAllowedDataSize = DataSize.of(constraintAnnotation.value(), constraintAnnotation.unit()); + } + + @Override + public boolean isValid(DataSize dataSize, ConstraintValidatorContext constraintValidatorContext) { + return (dataSize == null) || dataSize.compareTo(minAllowedDataSize) >= 0; + } + +} diff --git a/siva-parent/siva-webapp/src/main/resources/application.yml b/siva-parent/siva-webapp/src/main/resources/application.yml index eab6a8ee7..3c58de150 100644 --- a/siva-parent/siva-webapp/src/main/resources/application.yml +++ b/siva-parent/siva-webapp/src/main/resources/application.yml @@ -1,8 +1,12 @@ -server.max-http-post-size: 13981016 # 10 MB + base64 overhead (ceil(10MB / 3) * 4) spring.http.encoding.charset: UTF-8 # Charset of HTTP requests and responses. Added to the "Content-Type" header if not set explicitly. spring.http.encoding.enabled: true # Enable http encoding support. spring.http.encoding.force: true # Force the encoding to the configured charset on HTTP requests and responses. +siva: + http: + request: + max-request-size-limit: 26MB # 20 MB + base64 overhead (ceil(10MB / 3) * 4) + management: health: diskspace: diff --git a/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/configuration/HttpRequestLimitConfigurationPropertiesTest.java b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/configuration/HttpRequestLimitConfigurationPropertiesTest.java new file mode 100644 index 000000000..68afff115 --- /dev/null +++ b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/configuration/HttpRequestLimitConfigurationPropertiesTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.configuration; + +import ee.openeid.siva.validation.helper.AbstractValidationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.util.unit.DataSize; + +class HttpRequestLimitConfigurationPropertiesTest extends AbstractValidationTest { + + HttpRequestLimitConfigurationProperties configurationProperties; + + @BeforeEach + void setUpConfigurationProperties() { + configurationProperties = new HttpRequestLimitConfigurationProperties(); + } + + @Test + void validate_WhenMaxRequestSizeLimitIsMissing_Succeeds() { + configurationProperties.setMaxRequestSizeLimit(null); + validateAndExpectNoErrors(configurationProperties); + } + + @ParameterizedTest + @ValueSource(longs = {1L, 1024L, Long.MAX_VALUE}) + void validate_WhenMaxRequestSizeLimitIsMoreOrEqualThanOneByte_Succeeds(long sizeInBytes) { + configurationProperties.setMaxRequestSizeLimit(DataSize.ofBytes(sizeInBytes)); + validateAndExpectNoErrors(configurationProperties); + } + + @ParameterizedTest + @ValueSource(longs = {Long.MIN_VALUE, -1L, 0L}) + void validate_WhenMaxRequestSizeLimitIsLessThanOneByte_Fails(long sizeInBytes) { + configurationProperties.setMaxRequestSizeLimit(DataSize.ofBytes(sizeInBytes)); + validateAndExpectOneError(configurationProperties, "maxRequestSizeLimit", "must not be less than 1 BYTES"); + } + +} diff --git a/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/ApplicationRequestSizeLimitFilterTest.java b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/ApplicationRequestSizeLimitFilterTest.java new file mode 100644 index 000000000..864c95642 --- /dev/null +++ b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/ApplicationRequestSizeLimitFilterTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.limitation; + +import ee.openeid.siva.webapp.configuration.HttpRequestLimitConfigurationProperties; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.util.unit.DataSize; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +public class ApplicationRequestSizeLimitFilterTest { + + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + @Mock + private FilterChain filterChain; + + @ParameterizedTest + @MethodSource("dataSizeProvider") + void doFilterInternal_WhenApplicationRequestSizeLimitFilterIsSet_WrapperIsInjectedToFilterChain(DataSize maxRequestSizeLimit) throws Exception { + HttpRequestLimitConfigurationProperties requestLimitProperties = new HttpRequestLimitConfigurationProperties(); + requestLimitProperties.setMaxRequestSizeLimit(maxRequestSizeLimit); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(SizeLimitingHttpServletRequest.class); + + ApplicationRequestSizeLimitFilter limitFilter = new ApplicationRequestSizeLimitFilter(requestLimitProperties); + + limitFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(argumentCaptor.capture(), eq(response)); + + SizeLimitingHttpServletRequest capturedRequest = argumentCaptor.getValue(); + + assertEquals(maxRequestSizeLimit.toBytes(), capturedRequest.getMaximumAllowedReadLimit()); + verifyNoMoreInteractions(filterChain); + verifyNoInteractions(request, response); + } + + private static Stream dataSizeProvider() { + return Stream.of( + DataSize.ofBytes(1024), + DataSize.ofKilobytes(10240), + DataSize.ofMegabytes(1024) + ); + } +} diff --git a/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingHttpServletRequestTest.java b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingHttpServletRequestTest.java new file mode 100644 index 000000000..0ff8875af --- /dev/null +++ b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingHttpServletRequestTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.limitation; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +public class SizeLimitingHttpServletRequestTest { + + @Mock + private HttpServletRequest httpServletRequest; + + @ParameterizedTest + @ValueSource(longs = {Long.MIN_VALUE, -1L, 0L, 1L}) + void getInputStream_WhenLimitIsBiggerThanContentLength_ReturnsServletInputStream(long contentLengthLong) throws Exception { + ServletInputStream servletInputStream = mock(ServletInputStream.class); + doReturn(contentLengthLong).when(httpServletRequest).getContentLengthLong(); + doReturn(servletInputStream).when(httpServletRequest).getInputStream(); + + SizeLimitingHttpServletRequest limitingHttpServletRequest = new SizeLimitingHttpServletRequest( + httpServletRequest, 1L); + + ServletInputStream result = limitingHttpServletRequest.getInputStream(); + + assertEquals(SizeLimitingServletInputStream.class, result.getClass()); + verifyNoMoreInteractions(httpServletRequest); + verifyNoInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(longs = {2L, 3L, Long.MAX_VALUE}) + void getInputStream_WhenLimitIsSmallerThanContentLength_ThrowsException(long contentLengthLong) { + ServletInputStream servletInputStream = mock(ServletInputStream.class); + doReturn(contentLengthLong).when(httpServletRequest).getContentLengthLong(); + + SizeLimitingHttpServletRequest limitingHttpServletRequest = new SizeLimitingHttpServletRequest( + httpServletRequest, 1L); + + RequestSizeLimitExceededException caughtException = assertThrows( + RequestSizeLimitExceededException.class, + limitingHttpServletRequest::getInputStream + ); + + assertEquals("Request content-length (" + contentLengthLong + " bytes) exceeds request size limit (1 bytes)", + caughtException.getMessage()); + verifyNoMoreInteractions(httpServletRequest); + verifyNoInteractions(servletInputStream); + } +} diff --git a/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingServletInputStreamTest.java b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingServletInputStreamTest.java new file mode 100644 index 000000000..9d60f9d2e --- /dev/null +++ b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/limitation/SizeLimitingServletInputStreamTest.java @@ -0,0 +1,510 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.limitation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Stubber; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; + +import java.io.IOException; +import java.util.function.IntFunction; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +public class SizeLimitingServletInputStreamTest { + + @Mock + private ServletInputStream servletInputStream; + + @Test + void available_WhenWrappedInputStreamAvailableReturnsGivenValue_ReturnsTheValue() throws IOException { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + doReturn(15).when(servletInputStream).available(); + + int result = limitingServletInputStream.available(); + + assertThat(result, equalTo(15)); + verify(servletInputStream).available(); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void available_WhenWrappedInputStreamAvailableThrowsIOException_ReThrowsTheException() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + IOException ioException = new IOException("Exception message"); + doThrow(ioException).when(servletInputStream).available(); + + IOException caughtException = assertThrows( + IOException.class, + limitingServletInputStream::available + ); + + assertThat(caughtException, sameInstance(ioException)); + verify(servletInputStream).available(); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void close_WhenWrappedInputStreamCloseSucceeds_Succeeds() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + + limitingServletInputStream.close(); + + verify(servletInputStream).close(); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void close_WhenWrappedInputStreamCloseThrowsIOException_ReThrowsTheException() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + IOException ioException = new IOException("Exception message"); + doThrow(ioException).when(servletInputStream).close(); + + IOException caughtException = assertThrows( + IOException.class, + limitingServletInputStream::close + ); + + assertThat(caughtException, sameInstance(ioException)); + verify(servletInputStream).close(); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void idFinished_WhenWrappedInputStreamIsFinishedReturnsGivenValue_ReturnsTheValue(boolean value) { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + doReturn(value).when(servletInputStream).isFinished(); + + boolean result = limitingServletInputStream.isFinished(); + + assertThat(result, equalTo(value)); + verify(servletInputStream).isFinished(); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void idReady_WhenWrappedInputStreamIsReadyReturnsGivenValue_ReturnsTheValue(boolean value) { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + doReturn(value).when(servletInputStream).isReady(); + + boolean result = limitingServletInputStream.isReady(); + + assertThat(result, equalTo(value)); + verify(servletInputStream).isReady(); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void mark_ThrowsUnsupportedOperationExceptionWithoutInteractingWithWrappedInputStream() { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + + UnsupportedOperationException caughtException = assertThrows( + UnsupportedOperationException.class, + () -> limitingServletInputStream.mark(1) + ); + + assertThat(caughtException.getMessage(), equalTo("Mark not supported")); + verifyNoInteractions(servletInputStream); + } + + @Test + void markSupported_ReturnsFalseWithoutInteractingWithWrappedInputStream() { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + + boolean result = limitingServletInputStream.markSupported(); + + assertThat(result, equalTo(false)); + verifyNoInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + void read_WhenReadIsCalledLessTimesThanOrAsManyTimesAsReadLimit_ReturnsReadBytes(int timesToRead) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(3L); + doReturnMultiple(timesToRead, i -> i).when(servletInputStream).read(); + + for (int i = 0; i < timesToRead; ++i) { + int result = limitingServletInputStream.read(); + + assertThat(result, equalTo(i)); + } + verify(servletInputStream, times(timesToRead)).read(); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2}) + void read_WhenReadIsCalledMoreTimesThanReadLimit_ThrowsLimitExceededException(int limit) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(limit); + doReturnMultiple(limit + 1, i -> i).when(servletInputStream).read(); + + for (int i = 0; i < limit; ++i) { + int result = limitingServletInputStream.read(); + assertThat(result, equalTo(i)); + } + + RequestSizeLimitExceededException caughtException = assertThrows( + RequestSizeLimitExceededException.class, + limitingServletInputStream::read + ); + + assertThat(caughtException.getMessage(), equalTo(String.format( + "Request body length exceeds request size limit (%d bytes)", + limit + ))); + verify(servletInputStream, times(limit + 1)).read(); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void read_WhenReadIsCalledMoreTimesThanReadLimitButStreamEndsBefore_ReturnsReadBytes() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(1L); + doReturn(7, -1).when(servletInputStream).read(); + + int result1 = limitingServletInputStream.read(); + int result2 = limitingServletInputStream.read(); + + assertThat(result1, equalTo(7)); + assertThat(result2, equalTo(-1)); + verify(servletInputStream, times(2)).read(); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + void read_WhenReadIsCalledWithArrayShorterThanOrAsLongAsReadLimit_ReturnsNumberOfBytesRead( + int lengthToRead + ) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(3L); + byte[] buffer = new byte[lengthToRead]; + doAnswerToBulkRead(lengthToRead, i -> (byte) i).when(servletInputStream).read(buffer, 0, lengthToRead); + + int result = limitingServletInputStream.read(buffer); + + assertThat(result, equalTo(lengthToRead)); + assertArrayEquals(generateByteArray(lengthToRead, i -> (byte) i), buffer); + verify(servletInputStream).read(buffer, 0, lengthToRead); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2}) + void read_WhenReadIsCalledWithArrayLongerThanReadLimit_ThrowsLimitExceededException(int limit) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(limit); + byte[] buffer = new byte[limit + 1]; + doAnswerToBulkRead(buffer.length, i -> (byte) i).when(servletInputStream).read(buffer, 0, limit + 1); + + RequestSizeLimitExceededException caughtException = assertThrows( + RequestSizeLimitExceededException.class, + () -> limitingServletInputStream.read(buffer) + ); + + assertThat(caughtException.getMessage(), equalTo(String.format( + "Request body length exceeds request size limit (%d bytes)", + limit + ))); + verify(servletInputStream).read(buffer, 0, limit + 1); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void read_WhenReadIsCalledWithArrayLongerThanReadLimitButStreamEndsBefore_ReturnsNumberOfBytesRead() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(1L); + byte[] buffer = new byte[2]; + doAnswer(invocationOnMock -> { + invocationOnMock.getArgument(0, byte[].class)[0] = 7; + return 1; + }).when(servletInputStream).read(buffer, 0, 2); + + int result = limitingServletInputStream.read(buffer); + + assertThat(result, equalTo(1)); + assertArrayEquals(new byte[] {7, 0}, buffer); + verify(servletInputStream).read(buffer, 0, 2); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + void read_WhenReadIsCalledWithLengthShorterThanOrAsLongAsReadLimit_ReturnsNumberOfBytesRead( + int lengthToRead + ) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(3); + byte[] buffer = new byte[lengthToRead + 2]; + doAnswerToBulkRead(lengthToRead, i -> (byte) i).when(servletInputStream).read(buffer, 1, lengthToRead); + + int result = limitingServletInputStream.read(buffer, 1, lengthToRead); + + assertThat(result, equalTo(lengthToRead)); + assertArrayEquals(generateByteArray(lengthToRead + 2, i -> (i < 1 || i > lengthToRead) ? 0 : (byte) (i - 1)), buffer); + verify(servletInputStream).read(buffer, 1, lengthToRead); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2}) + void read_WhenReadIsCalledWithLengthLongerThanReadLimit_ThrowsLimitExceededException(int limit) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(limit); + byte[] buffer = new byte[limit + 2]; + doAnswerToBulkRead(buffer.length, i -> (byte) i).when(servletInputStream).read(buffer, 1, limit + 1); + + RequestSizeLimitExceededException caughtException = assertThrows( + RequestSizeLimitExceededException.class, + () -> limitingServletInputStream.read(buffer, 1, limit + 1) + ); + + assertThat(caughtException.getMessage(), equalTo(String.format( + "Request body length exceeds request size limit (%d bytes)", + limit + ))); + verify(servletInputStream).read(buffer, 1, limit + 1); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void read_WhenReadIsCalledWithLengthLongerThanReadLimitButStreamEndsBefore_ReturnsNumberOfBytesRead() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(1L); + byte[] buffer = new byte[3]; + doAnswer(invocationOnMock -> { + invocationOnMock.getArgument(0, byte[].class)[1] = 7; + return 1; + }).when(servletInputStream).read(buffer, 1, 2); + + int result = limitingServletInputStream.read(buffer, 1, 2); + + assertThat(result, equalTo(1)); + assertArrayEquals(new byte[] {0, 7, 0}, buffer); + verify(servletInputStream).read(buffer, 1, 2); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void readAllBytes_WhenStreamLengthIs0_DelegatesToBulkReadAndReturnsEmptyByteArray() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + doReturn(-1).when(servletInputStream) + .read(any(byte[].class), eq(0), eq(1)); + + byte[] result = limitingServletInputStream.readAllBytes(); + + assertArrayEquals(new byte[0], result); + verify(servletInputStream).read(any(byte[].class), eq(0), eq(1)); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + void readAllBytes_WhenStreamLengthIsLessThanOrEqualToReadLimit_DelegatesToBulkReadAndReturnsEmptyByteArray( + int streamLength + ) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(2L); + doAnswerToBulkRead(streamLength, i -> (byte) i).when(servletInputStream) + .read(any(byte[].class), eq(0), eq(3)); + doReturn(-1).when(servletInputStream) + .read(any(byte[].class), eq(streamLength), eq(3 - streamLength)); + + byte[] result = limitingServletInputStream.readAllBytes(); + + assertArrayEquals(generateByteArray(streamLength, i -> (byte) i), result); + verify(servletInputStream).read(any(byte[].class), eq(0), eq(3)); + verify(servletInputStream).read(any(byte[].class), eq(streamLength), eq(3 - streamLength)); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void readAllBytes_WhenStreamLengthIsMoreThanReadLimit_ThrowsLimitExceededException() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(1L); + doAnswerToBulkRead(2, i -> (byte) i).when(servletInputStream) + .read(any(byte[].class), eq(0), eq(2)); + + RequestSizeLimitExceededException caughtException = assertThrows( + RequestSizeLimitExceededException.class, + limitingServletInputStream::readAllBytes + ); + + assertThat( + caughtException.getMessage(), + equalTo("Request body length exceeds request size limit (1 bytes)") + ); + verify(servletInputStream).read(any(byte[].class), eq(0), eq(2)); + verifyNoMoreInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2}) + void readLine_WhenNewlineCharacterIsReachedBeforeReadLimit_DelegatesToReadAndReturnsNumberOfBytesRead( + int bytesBeforeNewline + ) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(3L); + doReturnMultiple(bytesBeforeNewline + 1, i -> (i < bytesBeforeNewline) ? i : '\n') + .when(servletInputStream).read(); + byte[] buffer = new byte[bytesBeforeNewline + 1]; + + int result = limitingServletInputStream.readLine(buffer, 0, buffer.length); + + assertThat(result, equalTo(bytesBeforeNewline + 1)); + assertArrayEquals( + generateByteArray(bytesBeforeNewline + 1, i -> (i < bytesBeforeNewline) ? (byte) i : (byte) '\n'), + buffer + ); + verify(servletInputStream, times(bytesBeforeNewline + 1)).read(); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void readLine_WhenReadLimitIsReachedBeforeNewlineCharacter_ThrowsLimitExceededException() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + doReturn((int) '\n').when(servletInputStream).read(); + byte[] buffer = new byte[1]; + + RequestSizeLimitExceededException caughtException = assertThrows( + RequestSizeLimitExceededException.class, + () -> limitingServletInputStream.readLine(buffer, 0, buffer.length) + ); + + assertThat( + caughtException.getMessage(), + equalTo("Request body length exceeds request size limit (0 bytes)") + ); + verify(servletInputStream).read(); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void reset_ThrowsUnsupportedOperationExceptionWithoutInteractingWithWrappedInputStream() { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + + UnsupportedOperationException caughtException = assertThrows( + UnsupportedOperationException.class, + limitingServletInputStream::reset + ); + + assertThat(caughtException.getMessage(), equalTo("Mark not supported")); + verifyNoInteractions(servletInputStream); + } + + @Test + void setReadListener_ThrowsUnsupportedOperationExceptionWithoutInteractingWithWrappedInputStream() { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(0L); + ReadListener readListener = mock(ReadListener.class); + + UnsupportedOperationException caughtException = assertThrows( + UnsupportedOperationException.class, + () -> limitingServletInputStream.setReadListener(readListener) + ); + + assertThat(caughtException.getMessage(), equalTo("Setting read listener not supported")); + verifyNoInteractions(servletInputStream); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + void skip_WhenSkipIsCalledWithNumberLessThanOrEqualToReadLimit_ReturnsNumberOfBytesSkipped( + int bytesToSkip + ) throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(2L); + doReturn(bytesToSkip).when(servletInputStream).read(any(byte[].class), eq(0), anyInt()); + + long result = limitingServletInputStream.skip(bytesToSkip); + + assertThat(result, equalTo((long) bytesToSkip)); + verify(servletInputStream).read(any(byte[].class), eq(0), anyInt()); + verifyNoMoreInteractions(servletInputStream); + } + + @Test + void skip_WhenSkipIsCalledWithNumberGreaterThanReadLimit_ReturnsNumberOfBytesSkipped() throws Exception { + SizeLimitingServletInputStream limitingServletInputStream = createLimitingInputStream(1L); + doReturn(2).when(servletInputStream).read(any(byte[].class), eq(0), anyInt()); + + RequestSizeLimitExceededException caughtException = assertThrows( + RequestSizeLimitExceededException.class, + () -> limitingServletInputStream.skip(2L) + ); + + assertThat( + caughtException.getMessage(), + equalTo("Request body length exceeds request size limit (1 bytes)") + ); + verify(servletInputStream).read(any(byte[].class), eq(0), anyInt()); + verifyNoMoreInteractions(servletInputStream); + } + + private SizeLimitingServletInputStream createLimitingInputStream(long limit) { + return new SizeLimitingServletInputStream(servletInputStream, limit); + } + + private static Stubber doReturnMultiple(int count, IntFunction elementResolver) { + if (count > 1) { + Object toBeReturned = elementResolver.apply(0); + Object[] toBeReturnedNext = new Object[count - 1]; + for (int i = 0; i < toBeReturnedNext.length; ++i) { + toBeReturnedNext[i] = elementResolver.apply(i + 1); + } + return doReturn(toBeReturned, toBeReturnedNext); + } else if (count == 1) { + return doReturn(elementResolver.apply(0)); + } else { + throw new IllegalArgumentException("Invalid count: " + count); + } + } + + private static Stubber doAnswerToBulkRead(int maxLengthToRead, IntFunction byteResolver) { + return doAnswer(invocationOnMock -> { + byte[] array = invocationOnMock.getArgument(0, byte[].class); + int offset = invocationOnMock.getArgument(1, Integer.class); + int length = invocationOnMock.getArgument(2, Integer.class); + int bytesRead = Math.min(maxLengthToRead, length); + for (int i = 0; i < bytesRead; ++i) { + array[offset + i] = byteResolver.apply(i); + } + return bytesRead; + }); + } + + private static byte[] generateByteArray(int length, IntFunction byteResolver) { + byte[] array = new byte[length]; + for (int i = 0; i < length; ++i) { + array[i] = byteResolver.apply(i); + } + return array; + } +} diff --git a/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/validation/DataSizeMinValidatorTest.java b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/validation/DataSizeMinValidatorTest.java new file mode 100644 index 000000000..51a0ab9bb --- /dev/null +++ b/siva-parent/siva-webapp/src/test/java/ee/openeid/siva/webapp/request/validation/DataSizeMinValidatorTest.java @@ -0,0 +1,115 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.webapp.request.validation; + +import ee.openeid.siva.webapp.request.validation.annotations.DataSizeMin; +import ee.openeid.siva.webapp.request.validation.validators.DataSizeMinValidator; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.util.unit.DataSize; +import org.springframework.util.unit.DataUnit; + +import javax.validation.ConstraintValidatorContext; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +public class DataSizeMinValidatorTest { + + @InjectMocks + private DataSizeMinValidator dataSizeMinValidator; + + @Mock + private DataSizeMin dataSizeMinAnnotation; + @Mock + private ConstraintValidatorContext constraintValidatorContext; + + @ParameterizedTest + @EnumSource(DataUnit.class) + void isValid_WhenDataSizeIsNull_ReturnsTrue(DataUnit unit) { + initializeAnnotatedLimitAndValidator(0L, unit); + + boolean result = dataSizeMinValidator.isValid(null, constraintValidatorContext); + + assertThat(result, equalTo(true)); + verifyAllInteractions(); + } + + @ParameterizedTest + @EnumSource(DataUnit.class) + void isValid_WhenDataSizeIsEqualToAnnotatedLimit_ReturnsTrue(DataUnit unit) { + initializeAnnotatedLimitAndValidator(7L, unit); + DataSize dataSize = DataSize.of(7L, unit); + + boolean result = dataSizeMinValidator.isValid(dataSize, constraintValidatorContext); + + assertThat(result, equalTo(true)); + verifyAllInteractions(); + } + + @ParameterizedTest + @EnumSource(DataUnit.class) + void isValid_WhenDataSizeIsGreaterThanAnnotatedLimit_ReturnsTrue(DataUnit unit) { + initializeAnnotatedLimitAndValidator(7L, unit); + DataSize dataSize = DataSize.ofBytes( + DataSize.of(7L, unit).toBytes() + 1L + ); + + boolean result = dataSizeMinValidator.isValid(dataSize, constraintValidatorContext); + + assertThat(result, equalTo(true)); + verifyAllInteractions(); + } + + @ParameterizedTest + @EnumSource(DataUnit.class) + void isValid_WhenDataSizeIsLessThanAnnotatedLimit_ReturnsFalse(DataUnit unit) { + initializeAnnotatedLimitAndValidator(7L, unit); + DataSize dataSize = DataSize.ofBytes( + DataSize.of(7L, unit).toBytes() - 1L + ); + + boolean result = dataSizeMinValidator.isValid(dataSize, constraintValidatorContext); + + assertThat(result, equalTo(false)); + verifyAllInteractions(); + } + + private void initializeAnnotatedLimitAndValidator(long value, DataUnit unit) { + doReturn(value).when(dataSizeMinAnnotation).value(); + doReturn(unit).when(dataSizeMinAnnotation).unit(); + + dataSizeMinValidator.initialize(dataSizeMinAnnotation); + } + + private void verifyAllInteractions() { + verify(dataSizeMinAnnotation).value(); + verify(dataSizeMinAnnotation).unit(); + + verifyNoMoreInteractions(dataSizeMinAnnotation); + verifyNoInteractions(constraintValidatorContext); + } +} diff --git a/validation-services-parent/validation-commons/pom.xml b/validation-services-parent/validation-commons/pom.xml index 56c46d4b2..c143fe4b1 100644 --- a/validation-services-parent/validation-commons/pom.xml +++ b/validation-services-parent/validation-commons/pom.xml @@ -95,6 +95,11 @@ + + org.springframework.boot + spring-boot-starter-validation + test + diff --git a/validation-services-parent/validation-commons/src/test/java/ee/openeid/siva/validation/helper/AbstractValidationTest.java b/validation-services-parent/validation-commons/src/test/java/ee/openeid/siva/validation/helper/AbstractValidationTest.java new file mode 100644 index 000000000..f4519c969 --- /dev/null +++ b/validation-services-parent/validation-commons/src/test/java/ee/openeid/siva/validation/helper/AbstractValidationTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Riigi Infosüsteemi Amet + * + * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence is + * distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and limitations under the Licence. + */ + +package ee.openeid.siva.validation.helper; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AbstractValidationTest { + + private static ValidatorFactory validatorFactory; + + protected Validator validator; + + @BeforeAll + public static void setUpDefaultValidatorFactory() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + } + + @BeforeEach + public void setUpDefaultValidator() { + validator = validatorFactory.getValidator(); + } + + protected void validateAndExpectNoErrors(Object objectToValidate) { + Set> violations = validator.validate(objectToValidate); + assertThat(violations, equalTo(Set.of())); + } + + protected void validateAndExpectOneError(Object objectToValidate, String path, String message) { + Set> violations = validator.validate(objectToValidate); + assertEquals(1, violations.size(), () -> "Expected exactly one violation, found:" + + violations.stream().map(ConstraintViolation::getMessage).collect(Collectors.joining(System.lineSeparator())) + ); + + ConstraintViolation violation = violations.stream().findFirst().get(); + assertThat(violation.getPropertyPath().toString(), equalTo(path)); + assertThat(violation.getMessage(), equalTo(message)); + } +}