diff --git a/spring-test/spring-test.gradle b/spring-test/spring-test.gradle index ef413bc466c..99b051667b0 100644 --- a/spring-test/spring-test.gradle +++ b/spring-test/spring-test.gradle @@ -74,6 +74,7 @@ dependencies { testCompile("org.hsqldb:hsqldb") testCompile("org.apache.httpcomponents:httpclient") testCompile("io.projectreactor.netty:reactor-netty") + testCompile("commons-fileupload:commons-fileupload") testCompile("de.bechte.junit:junit-hierarchicalcontextrunner") testRuntime("org.junit.vintage:junit-vintage-engine") { exclude group: "junit", module: "junit" diff --git a/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java b/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java index a2486726849..f26449fc110 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java @@ -158,6 +158,13 @@ public class ContentRequestMatchers { }; } + /** + * Access to request body matchers. Matches content type {@link MediaType#MULTIPART_FORM_DATA} + */ + public MultipartFormDataRequestMatchers multipart() { + return new MultipartFormDataRequestMatchers(); + } + /** * Parse the request body and the given String as XML and assert that the * two are "similar" - i.e. they contain the same elements and attributes diff --git a/spring-test/src/main/java/org/springframework/test/web/client/match/MultipartFormDataRequestMatchers.java b/spring-test/src/main/java/org/springframework/test/web/client/match/MultipartFormDataRequestMatchers.java new file mode 100644 index 00000000000..74beeac7775 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/client/match/MultipartFormDataRequestMatchers.java @@ -0,0 +1,238 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.test.web.client.match; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.commons.io.IOUtils; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.jetbrains.annotations.NotNull; + +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.client.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.multipart.commons.CommonsMultipartResolver; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.fail; + +/** + * Factory for assertions on the multipart form data parameters. Handles only {@link MediaType#MULTIPART_FORM_DATA} + * + *

An instance of this class is typically accessed via {@link ContentRequestMatchers#multipart()} + * + * @author Valentin Spac + * @since 5.3 + */ +public class MultipartFormDataRequestMatchers { + + public RequestMatcher param(String parameter, String... expectedValues) { + List> matcherList = Arrays.stream(expectedValues) + .map(Matchers::equalTo) + .collect(Collectors.toList()); + + return this.param(parameter, matcherList); + } + + @SafeVarargs + @SuppressWarnings("varargs") + public final RequestMatcher param(String parameter, Matcher... matchers) { + return this.param(parameter, Arrays.stream(matchers).collect(Collectors.toList())); + } + + public RequestMatcher param(String parameter, Matcher> matchers) { + return request -> { + Map requestParams = MultipartRequestParser.parameterMap(request); + assertValueCount(parameter, requestParams, 1); + + String[] values = requestParams.get(parameter); + assertThat("Request parameter [" + parameter + "]", Arrays.asList(values), matchers); + }; + } + + private RequestMatcher param(String parameter, List> matchers) { + return request -> { + Map requestParams = MultipartRequestParser.parameterMap(request); + assertValueCount(parameter, requestParams, matchers.size()); + + String[] values = requestParams.get(parameter); + + Assert.state(values != null, "No values for request parameter " + parameter); + for (int i = 0; i < matchers.size(); i++) { + assertThat("Request parameter [" + parameter + "]", values[i], matchers.get(i)); + } + }; + } + + public RequestMatcher params(MultiValueMap expectedParameters) { + return request -> { + Map requestParams = MultipartRequestParser.parameterMap(request); + + expectedParameters.forEach((param, values) -> { + String[] actualValues = requestParams.get(param); + Assert.state(actualValues != null, "No values for request parameter " + param); + + assertValueCount(param, requestParams, values.size()); + + assertEquals("Parameter " + param, values, Arrays.asList(actualValues)); + }); + }; + } + + public RequestMatcher file(String parameter, byte[]... resources) { + return request -> { + MultiValueMap files = MultipartRequestParser.multiFileMap(request); + + assertValueCount(parameter, files, resources.length); + + assertByteArrayMatch(parameter, Arrays.asList(resources), files.get(parameter)); + }; + } + + @SafeVarargs + @SuppressWarnings("varargs") + public final RequestMatcher file(String parameter, Matcher... matchers) { + return request -> { + MultiValueMap files = MultipartRequestParser.multiFileMap(request); + assertValueCount(parameter, files, matchers.length); + List parts = files.get(parameter); + + for (int i = 0; i < matchers.length; i++) { + assertThat("File [" + parameter + "]", parts.get(i).getResource(), matchers[i]); + } + }; + } + + public RequestMatcher file(String parameter, Resource... resources) { + return request -> { + MultiValueMap files = MultipartRequestParser.multiFileMap(request); + assertValueCount(parameter, files, resources.length); + + assertResourceMatch(parameter, Arrays.asList(resources), files.get(parameter)); + }; + } + + public RequestMatcher files(MultiValueMap expectedFiles) { + return request -> { + MultiValueMap actualFiles = MultipartRequestParser.multiFileMap(request); + + expectedFiles.forEach((param, parts) -> { + assertValueCount(param, actualFiles, parts.size()); + assertResourceMatch(param, parts, actualFiles.get(param)); + }); + }; + } + + + private void assertByteArrayMatch(String parameterName, List expectedFiles, + List actualFiles) { + for (int index = 0; index < actualFiles.size(); index++) { + MultipartFile multiPartFile = actualFiles.get(index); + byte[] expectedContent = expectedFiles.get(index); + + try { + assertEquals("Content mismatch for file " + parameterName, expectedContent, + multiPartFile.getBytes()); + } + catch (IOException ex) { + throw new AssertionError("Could not get bytes from actual multipart files", ex); + } + } + } + + private void assertResourceMatch(String parameterName, List expectedFiles, + List actualFiles) { + for (int index = 0; index < actualFiles.size(); index++) { + MultipartFile multiPartFile = actualFiles.get(index); + Resource expectedResource = expectedFiles.get(index); + try { + byte[] fileContent = IOUtils.toByteArray(expectedResource.getInputStream()); + + assertEquals("Content mismatch for file " + parameterName, fileContent, + multiPartFile.getBytes()); + assertEquals("Filename ", expectedResource.getFilename(), multiPartFile.getOriginalFilename()); + } + catch (IOException ex) { + throw new AssertionError("Could not get bytes from actual multipart files", ex); + } + } + } + + private static void assertValueCount(String parameter, Map map, int count) { + String[] values = map.get(parameter); + if (values == null) { + fail("Expected <" + parameter + "> to exist but was null"); + } + assertValueCount(parameter, count, Arrays.asList(values)); + } + + private static void assertValueCount(String parameter, MultiValueMap map, int count) { + List values = map.get(parameter); + assertValueCount(parameter, count, values); + } + + private static void assertValueCount(String parameter, int count, List values) { + String message = "Expected multipart file <" + parameter + ">"; + if (count > values.size()) { + fail(message + " to have at least <" + count + "> values but found " + values.size()); + } + } + + + private static class MultipartRequestParser { + private static MultipartHttpServletRequest extract(ClientHttpRequest request) { + MockClientHttpRequest mockRequest = (MockClientHttpRequest) request; + final MockHttpServletRequest mockHttpServletRequest = toMockHttpServletRequest(mockRequest); + + CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver(); + return multipartResolver.resolveMultipart(mockHttpServletRequest); + } + + @NotNull + private static MockHttpServletRequest toMockHttpServletRequest(MockClientHttpRequest mockRequest) { + final MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + mockHttpServletRequest.setContent(mockRequest.getBodyAsBytes()); + + // copy headers + mockRequest.getHeaders() + .forEach((headerName, headerValue) -> + headerValue.forEach(value -> mockHttpServletRequest.addHeader(headerName, value))); + return mockHttpServletRequest; + } + + private static Map parameterMap(ClientHttpRequest request) { + return extract(request).getParameterMap(); + } + + private static MultiValueMap multiFileMap(ClientHttpRequest request) { + return extract(request).getMultiFileMap(); + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/client/match/MultipartFormDatRequestMatchersTests.java b/spring-test/src/test/java/org/springframework/test/web/client/match/MultipartFormDatRequestMatchersTests.java new file mode 100644 index 00000000000..cdeaaa29cec --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/client/match/MultipartFormDatRequestMatchersTests.java @@ -0,0 +1,320 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.test.web.client.match; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; + +/** + * Unit tests for {@link MultipartFormDataRequestMatchers}. + * + * @author Valentin Spac + */ +public class MultipartFormDatRequestMatchersTests { + + private MockClientHttpRequest request = new MockClientHttpRequest(); + private MultipartFormDataRequestMatchers multipartRequestMatchers = MockRestRequestMatchers.content().multipart(); + + @BeforeEach + public void setUp() { + this.request.getHeaders().setContentType(MediaType.MULTIPART_FORM_DATA); + } + + @Test + public void testContains() throws Exception { + MultiValueMap payload = new LinkedMultiValueMap<>(); + payload.add("foo", "bar"); + payload.add("foo", "baz"); + payload.add("lorem", "ipsum"); + + writeForm(payload); + + multipartRequestMatchers.param("foo", containsInAnyOrder("bar", "baz")).match(request); + } + + @Test + public void testNoContains() throws Exception { + MultiValueMap payload = new LinkedMultiValueMap<>(); + payload.add("foo", "bar"); + payload.add("foo", "baz"); + payload.add("lorem", "ipsum"); + + writeForm(payload); + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + multipartRequestMatchers.param("foo", containsInAnyOrder("wrongValue")).match(request)); + } + + @Test + public void testEqualMatcher() throws Exception { + MultiValueMap payload = new LinkedMultiValueMap<>(); + payload.add("foo", "bar"); + payload.add("baz", "foobar"); + + writeForm(payload); + multipartRequestMatchers.param("foo", equalTo("bar")).match(request); + } + + @Test + public void testNoEqualMatcher() throws Exception { + MultiValueMap payload = new LinkedMultiValueMap<>(); + payload.add("foo", "bar"); + payload.add("baz", "foobar"); + + writeForm(payload); + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + multipartRequestMatchers.param("foo", equalTo("wrongValue")).match(request)); + } + + @Test + public void testParamMatch() throws Exception { + MultiValueMap payload = new LinkedMultiValueMap<>(); + payload.add("foo", "bar"); + payload.add("baz", "foobar"); + + writeForm(payload); + multipartRequestMatchers.param("foo", "bar").match(request); + } + + @Test + public void testParamNoMatch() throws Exception { + MultiValueMap payload = new LinkedMultiValueMap<>(); + payload.add("foo", "bar"); + payload.add("baz", "foobar"); + + writeForm(payload); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + multipartRequestMatchers.param("foo", "wrongValue").match(request)); + } + + @Test + public void testParamsMultimapMatch() throws Exception { + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("foo", "value 1"); + map.add("bar", "value A"); + map.add("baz", "value B"); + + writeForm(map); + + multipartRequestMatchers.params(map).match(this.request); + } + + @Test + public void testParamsMultimapNoMatch() throws Exception { + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("foo", "foo value"); + map.add("bar", "bar value"); + map.add("baz", "baz value"); + map.add("baz", "second baz value"); + + writeForm(map); + + map.set("baz", "wrong baz value"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + multipartRequestMatchers.params(map).match(this.request)); + } + + + @Test + public void testResourceMatch() throws Exception { + MockMultipartFile foo = new MockMultipartFile("fooFile", "foo.txt", "text/plain", "Foo Lorem ipsum".getBytes()); + MockMultipartFile bar = new MockMultipartFile("fooFile", "bar.txt", "text/plain", "Bar Lorem ipsum".getBytes()); + MockMultipartFile foobar = new MockMultipartFile("foobarFile", "foobar.txt", "text/plain", "Foobar Lorem ipsum".getBytes()); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("fooParam", "foo value"); + map.add("barParam", "bar value"); + map.add(foo.getName(), foo.getResource()); + map.add(bar.getName(), bar.getResource()); + map.add(foobar.getName(), foobar.getResource()); + + writeForm(map); + + multipartRequestMatchers.file(foo.getName(), foo.getResource(), bar.getResource()).match(this.request); + } + + @Test + public void testResourceNoMatch() throws Exception { + MockMultipartFile foo = new MockMultipartFile("fooFile", "foo.txt", "text/plain", "Foo Lorem ipsum".getBytes()); + MockMultipartFile bar = new MockMultipartFile("barFile", "bar.txt", "text/plain", "Bar Lorem ipsum".getBytes()); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("fooParam", "foo value"); + map.add("barParam", "bar value"); + map.add(foo.getName(), foo.getResource()); + map.add(bar.getName(), bar.getResource()); + + writeForm(map); + + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + multipartRequestMatchers.file(foo.getName(), foo.getResource(), bar.getResource()).match(this.request)); + } + + @Test + public void testByteArrayMatch() throws Exception { + MockMultipartFile foo = new MockMultipartFile("fooFile", "foo.txt", "text/plain", "Foo Lorem ipsum".getBytes()); + MockMultipartFile bar = new MockMultipartFile("fooFile", "bar.txt", "text/plain", "Bar Lorem ipsum".getBytes()); + MockMultipartFile foobar = new MockMultipartFile("foobarFile", "foobar.txt", "text/plain", "Foobar Lorem ipsum".getBytes()); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("fooParam", "foo value"); + map.add("barParam", "bar value"); + map.add(foo.getName(), foo.getResource()); + map.add(bar.getName(), bar.getResource()); + map.add(foobar.getName(), foobar.getResource()); + + writeForm(map); + + multipartRequestMatchers.file(foo.getName(), foo.getBytes(), bar.getBytes()).match(this.request); + } + + @Test + public void testByteArrayNoMatch() throws Exception { + MockMultipartFile foo = new MockMultipartFile("fooFile", "foo.txt", "text/plain", "Foo Lorem ipsum".getBytes()); + MockMultipartFile bar = new MockMultipartFile("barFile", "bar.txt", "text/plain", "Bar Lorem ipsum".getBytes()); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("fooParam", "foo value"); + map.add("barParam", "bar value"); + map.add(foo.getName(), foo.getResource()); + map.add(bar.getName(), bar.getResource()); + + writeForm(map); + + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + multipartRequestMatchers.file(foo.getName(), bar.getBytes()).match(this.request)); + } + + @Test + public void testResourceMatcher() throws Exception { + MockMultipartFile foo = new MockMultipartFile("fooFile", "foo.txt", "text/plain", "Foo Lorem ipsum".getBytes()); + MockMultipartFile bar = new MockMultipartFile("barFile", "bar.txt", "text/plain", "Bar Lorem ipsum".getBytes()); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("fooParam", "foo value"); + map.add("barParam", "bar value"); + map.add(foo.getName(), foo.getResource()); + map.add(bar.getName(), bar.getResource()); + + writeForm(map); + multipartRequestMatchers.file(foo.getName(), resourceMatcher(foo.getResource())).match(this.request); + } + + @Test + public void testResourceMatcherNoMatch() throws Exception { + MockMultipartFile foo = new MockMultipartFile("fooFile", "foo.txt", "text/plain", "Foo Lorem ipsum".getBytes()); + MockMultipartFile bar = new MockMultipartFile("barFile", "bar.txt", "text/plain", "Bar Lorem ipsum".getBytes()); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("fooParam", "foo value"); + map.add("barParam", "bar value"); + map.add(foo.getName(), foo.getResource()); + map.add(bar.getName(), bar.getResource()); + + writeForm(map); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + multipartRequestMatchers.file(foo.getName(), resourceMatcher(bar.getResource())).match(this.request)); + } + + @NotNull + private Matcher resourceMatcher(Resource expectedResource) { + return new TypeSafeMatcher() { + + @Override + public void describeTo(Description description) { + description.appendValue(expectedResource.getDescription()); + } + + @Override + protected boolean matchesSafely(Resource resource) { + try { + byte[] actual = IOUtils.toByteArray(resource.getInputStream()); + byte[] expected = IOUtils.toByteArray(expectedResource.getInputStream()); + + return StringUtils.equals(expectedResource.getFilename(), resource.getFilename()) + && Arrays.equals(expected, actual); + } + catch (IOException e) { + throw new RuntimeException("Could not read resource content"); + } + } + }; + } + + @Test + public void testResourceMultimapMatch() throws Exception { + MockMultipartFile foo = new MockMultipartFile("fooFile", "foo.txt", "text/plain", "Foo Lorem ipsum".getBytes()); + MockMultipartFile bar = new MockMultipartFile("barFile", "bar.txt", "text/plain", "Bar Lorem ipsum".getBytes()); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("fooParam", "foo value"); + map.add("barParam", "bar value"); + map.add(foo.getName(), foo.getResource()); + map.add(bar.getName(), bar.getResource()); + + writeForm(map); + + MultiValueMap files = new LinkedMultiValueMap<>(); + files.add(foo.getName(), foo.getResource()); + files.add(bar.getName(), bar.getResource()); + + multipartRequestMatchers.files(files).match(this.request); + } + + + private void writeForm(MultiValueMap payload) throws IOException { + FormHttpMessageConverter formHttpMessageConverter = new FormHttpMessageConverter(); + formHttpMessageConverter.write(payload, MediaType.MULTIPART_FORM_DATA, new HttpOutputMessage() { + @Override + public OutputStream getBody() throws IOException { + return MultipartFormDatRequestMatchersTests.this.request.getBody(); + } + + @Override + public HttpHeaders getHeaders() { + return MultipartFormDatRequestMatchersTests.this.request.getHeaders(); + } + }); + } +}