From ebfa009f18bd49dd082e11b99f51b3dd98dfb882 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 25 Oct 2023 10:06:15 +0200 Subject: [PATCH] Refactor tests in ResourceHttpRequestHandlerTests --- .../ResourceHttpRequestHandlerTests.java | 1252 +++++++++-------- 1 file changed, 644 insertions(+), 608 deletions(-) diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java index 8b753090594..df581d5c6f4 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java @@ -21,6 +21,7 @@ import java.util.List; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -60,703 +61,738 @@ import static org.mockito.Mockito.mock; @ExtendWith(GzipSupport.class) class ResourceHttpRequestHandlerTests { - private final ClassPathResource testResource = new ClassPathResource("test/", getClass()); - private final ClassPathResource testAlternatePathResource = new ClassPathResource("testalternatepath/", getClass()); - private final ClassPathResource webjarsResource = new ClassPathResource("META-INF/resources/webjars/"); + private static final ClassPathResource testResource = new ClassPathResource("test/", ResourceHttpRequestHandlerTests.class); + private static final ClassPathResource testAlternatePathResource = new ClassPathResource("testalternatepath/", ResourceHttpRequestHandlerTests.class); + private static final ClassPathResource webjarsResource = new ClassPathResource("META-INF/resources/webjars/"); - private ResourceHttpRequestHandler handler; + @Nested + class ResourceHandlingTests { - private MockHttpServletRequest request; + private ResourceHttpRequestHandler handler; - private MockHttpServletResponse response; + private MockHttpServletRequest request; + private MockHttpServletResponse response; - @BeforeEach - void setup() throws Exception { - List locations = List.of( - this.testResource, - this.testAlternatePathResource, - this.webjarsResource); - TestServletContext servletContext = new TestServletContext(); + @BeforeEach + void setup() throws Exception { + TestServletContext servletContext = new TestServletContext(); + this.handler = new ResourceHttpRequestHandler(); + this.handler.setLocations(List.of(testResource, testAlternatePathResource, webjarsResource)); + this.handler.setServletContext(servletContext); + this.handler.afterPropertiesSet(); + this.request = new MockHttpServletRequest(servletContext, "GET", ""); + this.response = new MockHttpServletResponse(); + } - this.handler = new ResourceHttpRequestHandler(); - this.handler.setLocations(locations); - this.handler.setCacheSeconds(3600); - this.handler.setServletContext(servletContext); - this.handler.afterPropertiesSet(); + @Test + void servesResource() throws Exception { + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.handler.handleRequest(this.request, this.response); - this.request = new MockHttpServletRequest(servletContext, "GET", ""); - this.response = new MockHttpServletResponse(); - } + assertThat(this.response.getContentType()).isEqualTo("text/css"); + assertThat(this.response.getContentLength()).isEqualTo(17); + assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); + } + @Test + void supportsHeadRequests() throws Exception { + this.request.setMethod("HEAD"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.handler.handleRequest(this.request, this.response); - @Test - void getResource() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.handler.handleRequest(this.request, this.response); + assertThat(this.response.getStatus()).isEqualTo(200); + assertThat(this.response.getContentType()).isEqualTo("text/css"); + assertThat(this.response.getContentLength()).isEqualTo(17); + assertThat(this.response.getContentAsByteArray()).isEmpty(); + } - assertThat(this.response.getContentType()).isEqualTo("text/css"); - assertThat(this.response.getContentLength()).isEqualTo(17); - assertThat(this.response.getHeader("Cache-Control")).isEqualTo("max-age=3600"); - assertThat(this.response.containsHeader("Last-Modified")).isTrue(); - assertThat(this.response.getDateHeader("Last-Modified") / 1000).isEqualTo(resourceLastModified("test/foo.css") / 1000); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); - } + @Test + void supportsOptionsRequests() throws Exception { + this.request.setMethod("OPTIONS"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.handler.handleRequest(this.request, this.response); - @Test - void getResourceHttpHeader() throws Exception { - this.request.setMethod("HEAD"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getStatus()).isEqualTo(200); - assertThat(this.response.getContentType()).isEqualTo("text/css"); - assertThat(this.response.getContentLength()).isEqualTo(17); - assertThat(this.response.getHeader("Cache-Control")).isEqualTo("max-age=3600"); - assertThat(this.response.containsHeader("Last-Modified")).isTrue(); - assertThat(this.response.getDateHeader("Last-Modified") / 1000).isEqualTo(resourceLastModified("test/foo.css") / 1000); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - assertThat(this.response.getContentAsByteArray()).isEmpty(); - } + assertThat(this.response.getStatus()).isEqualTo(200); + assertThat(this.response.getHeader("Allow")).isEqualTo("GET,HEAD,OPTIONS"); + } - @Test - void getResourceHttpOptions() throws Exception { - this.request.setMethod("OPTIONS"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.handler.handleRequest(this.request, this.response); + @Test + void servesHtmlResources() throws Exception { + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.html"); + this.handler.handleRequest(this.request, this.response); - assertThat(this.response.getStatus()).isEqualTo(200); - assertThat(this.response.getHeader("Allow")).isEqualTo("GET,HEAD,OPTIONS"); - } - - @Test - void getResourceNoCache() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.handler.setCacheSeconds(0); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getHeader("Cache-Control")).isEqualTo("no-store"); - assertThat(this.response.containsHeader("Last-Modified")).isTrue(); - assertThat(this.response.getDateHeader("Last-Modified") / 1000).isEqualTo(resourceLastModified("test/foo.css") / 1000); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + assertThat(this.response.getContentType()).isEqualTo("text/html"); + } - @Test - void getVersionedResource() throws Exception { - VersionResourceResolver versionResolver = new VersionResourceResolver() - .addFixedVersionStrategy("versionString", "/**"); - this.handler.setResourceResolvers(List.of(versionResolver, new PathResourceResolver())); - this.handler.afterPropertiesSet(); + @Test // SPR-13658 + @SuppressWarnings("deprecation") + void getResourceWithRegisteredMediaType() throws Exception { + ContentNegotiationManagerFactoryBean factory = new ContentNegotiationManagerFactoryBean(); + factory.addMediaType("bar", new MediaType("foo", "bar")); + factory.afterPropertiesSet(); + ContentNegotiationManager manager = factory.getObject(); + + List paths = List.of(new ClassPathResource("test/", getClass())); + ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); + handler.setServletContext(new MockServletContext()); + handler.setLocations(paths); + handler.setContentNegotiationManager(manager); + handler.afterPropertiesSet(); + + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.bar"); + handler.handleRequest(this.request, this.response); + + assertThat(this.response.getContentType()).isEqualTo("foo/bar"); + assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); + } - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "versionString/foo.css"); - this.handler.handleRequest(this.request, this.response); + @Test // SPR-14577 + @SuppressWarnings("deprecation") + void getMediaTypeWithFavorPathExtensionOff() throws Exception { + ContentNegotiationManagerFactoryBean factory = new ContentNegotiationManagerFactoryBean(); + factory.setFavorPathExtension(false); + factory.afterPropertiesSet(); + ContentNegotiationManager manager = factory.getObject(); + + List paths = List.of(new ClassPathResource("test/", getClass())); + ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); + handler.setServletContext(new MockServletContext()); + handler.setLocations(paths); + handler.setContentNegotiationManager(manager); + handler.afterPropertiesSet(); + + this.request.addHeader("Accept", "application/json,text/plain,*/*"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.html"); + handler.handleRequest(this.request, this.response); + + assertThat(this.response.getContentType()).isEqualTo("text/html"); + } - assertThat(this.response.getHeader("ETag")).isEqualTo("W/\"versionString\""); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + @Test // SPR-14368 + void getResourceWithMediaTypeResolvedThroughServletContext() throws Exception { + MockServletContext servletContext = new MockServletContext() { + @Override + public String getMimeType(String filePath) { + return "foo/bar"; + } + }; + + List paths = List.of(new ClassPathResource("test/", getClass())); + ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); + handler.setServletContext(servletContext); + handler.setLocations(paths); + handler.afterPropertiesSet(); + + MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", ""); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + handler.handleRequest(request, this.response); + + assertThat(this.response.getContentType()).isEqualTo("foo/bar"); + assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); + } - @Test - @SuppressWarnings("deprecation") - void getResourceHttp10BehaviorCache() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.handler.setCacheSeconds(3600); - this.handler.setUseExpiresHeader(true); - this.handler.setUseCacheControlHeader(true); - this.handler.setAlwaysMustRevalidate(true); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getHeader("Cache-Control")).isEqualTo("max-age=3600, must-revalidate"); - assertThat(this.response.getDateHeader("Expires")).isGreaterThanOrEqualTo( - System.currentTimeMillis() - 1000 + (3600 * 1000)); - assertThat(this.response.containsHeader("Last-Modified")).isTrue(); - assertThat(this.response.getDateHeader("Last-Modified") / 1000).isEqualTo(resourceLastModified("test/foo.css") / 1000); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + @Test + void unsupportedHttpMethod() { + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.request.setMethod("POST"); + assertThatExceptionOfType(HttpRequestMethodNotSupportedException.class).isThrownBy(() -> + this.handler.handleRequest(this.request, this.response)); + } - @Test - @SuppressWarnings("deprecation") - void getResourceHttp10BehaviorNoCache() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.handler.setCacheSeconds(0); - this.handler.setUseExpiresHeader(true); - this.handler.setUseCacheControlNoStore(false); - this.handler.setUseCacheControlHeader(true); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getHeader("Pragma")).isEqualTo("no-cache"); - assertThat(this.response.getHeaderValues("Cache-Control")).hasSize(1); - assertThat(this.response.getHeader("Cache-Control")).isEqualTo("no-cache"); - assertThat(this.response.getDateHeader("Expires")).isLessThanOrEqualTo(System.currentTimeMillis()); - assertThat(this.response.containsHeader("Last-Modified")).isTrue(); - assertThat(this.response.getDateHeader("Last-Modified") / 1000).isEqualTo(resourceLastModified("test/foo.css") / 1000); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + @Test + void testResourceNotFound() { + for (HttpMethod method : HttpMethod.values()) { + this.request = new MockHttpServletRequest("GET", ""); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "not-there.css"); + this.request.setMethod(method.name()); + this.response = new MockHttpServletResponse(); + assertNotFound(); + } + } - @Test - void getResourceWithHtmlMediaType() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.html"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getContentType()).isEqualTo("text/html"); - assertThat(this.response.getHeader("Cache-Control")).isEqualTo("max-age=3600"); - assertThat(this.response.containsHeader("Last-Modified")).isTrue(); - assertThat(this.response.getDateHeader("Last-Modified") / 1000).isEqualTo(resourceLastModified("test/foo.html") / 1000); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + private void assertNotFound() { + assertThatThrownBy(() -> this.handler.handleRequest(this.request, this.response)) + .isInstanceOf(NoResourceFoundException.class); + } - @Test - void getResourceFromAlternatePath() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "baz.css"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getContentType()).isEqualTo("text/css"); - assertThat(this.response.getContentLength()).isEqualTo(17); - assertThat(this.response.getHeader("Cache-Control")).isEqualTo("max-age=3600"); - assertThat(this.response.containsHeader("Last-Modified")).isTrue(); - assertThat(this.response.getDateHeader("Last-Modified") / 1000).isEqualTo(resourceLastModified("testalternatepath/baz.css") / 1000); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); } - @Test - void getResourceFromSubDirectory() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "js/foo.js"); - this.handler.handleRequest(this.request, this.response); - assertThat(this.response.getContentType()).isEqualTo("text/javascript"); - assertThat(this.response.getContentAsString()).isEqualTo("function foo() { console.log(\"hello world\"); }"); - } + @Nested + class RangeRequestTests { - @Test - void getResourceFromSubDirectoryOfAlternatePath() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "js/baz.js"); - this.handler.handleRequest(this.request, this.response); - assertThat(this.response.getContentType()).isEqualTo("text/javascript"); - assertThat(this.response.getContentAsString()).isEqualTo("function foo() { console.log(\"hello world\"); }"); - } + private ResourceHttpRequestHandler handler; - @Test // SPR-13658 - @SuppressWarnings("deprecation") - void getResourceWithRegisteredMediaType() throws Exception { - ContentNegotiationManagerFactoryBean factory = new ContentNegotiationManagerFactoryBean(); - factory.addMediaType("bar", new MediaType("foo", "bar")); - factory.afterPropertiesSet(); - ContentNegotiationManager manager = factory.getObject(); - - List paths = List.of(new ClassPathResource("test/", getClass())); - ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); - handler.setServletContext(new MockServletContext()); - handler.setLocations(paths); - handler.setContentNegotiationManager(manager); - handler.afterPropertiesSet(); - - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.bar"); - handler.handleRequest(this.request, this.response); - - assertThat(this.response.getContentType()).isEqualTo("foo/bar"); - assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); - } + private MockHttpServletRequest request; - @Test // SPR-14577 - @SuppressWarnings("deprecation") - void getMediaTypeWithFavorPathExtensionOff() throws Exception { - ContentNegotiationManagerFactoryBean factory = new ContentNegotiationManagerFactoryBean(); - factory.setFavorPathExtension(false); - factory.afterPropertiesSet(); - ContentNegotiationManager manager = factory.getObject(); - - List paths = List.of(new ClassPathResource("test/", getClass())); - ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); - handler.setServletContext(new MockServletContext()); - handler.setLocations(paths); - handler.setContentNegotiationManager(manager); - handler.afterPropertiesSet(); - - this.request.addHeader("Accept", "application/json,text/plain,*/*"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.html"); - handler.handleRequest(this.request, this.response); - - assertThat(this.response.getContentType()).isEqualTo("text/html"); - } + private MockHttpServletResponse response; - @Test // SPR-14368 - void getResourceWithMediaTypeResolvedThroughServletContext() throws Exception { - MockServletContext servletContext = new MockServletContext() { - @Override - public String getMimeType(String filePath) { - return "foo/bar"; - } - }; - List paths = List.of(new ClassPathResource("test/", getClass())); - ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); - handler.setServletContext(servletContext); - handler.setLocations(paths); - handler.afterPropertiesSet(); + @BeforeEach + void setup() throws Exception { + TestServletContext servletContext = new TestServletContext(); + this.handler = new ResourceHttpRequestHandler(); + this.handler.setLocations(List.of(testResource, testAlternatePathResource, webjarsResource)); + this.handler.setServletContext(servletContext); + this.handler.afterPropertiesSet(); + this.request = new MockHttpServletRequest(servletContext, "GET", ""); + this.response = new MockHttpServletResponse(); + } - MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", ""); - request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - handler.handleRequest(request, this.response); + @Test + void supportsRangeRequest() throws Exception { + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.handler.handleRequest(this.request, this.response); - assertThat(this.response.getContentType()).isEqualTo("foo/bar"); - assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); - } + assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); + assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); + } - @Test // gh-27538, gh-27624 - void filterNonExistingLocations() throws Exception { - List inputLocations = List.of( - new ClassPathResource("test/", getClass()), - new ClassPathResource("testalternatepath/", getClass()), - new ClassPathResource("nosuchpath/", getClass())); - - ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); - handler.setServletContext(new MockServletContext()); - handler.setLocations(inputLocations); - handler.setOptimizeLocations(true); - handler.afterPropertiesSet(); - - List actual = handler.getLocations(); - assertThat(actual).hasSize(2); - assertThat(actual.get(0).getURL().toString()).endsWith("test/"); - assertThat(actual.get(1).getURL().toString()).endsWith("testalternatepath/"); - } + @Test + void partialContentByteRange() throws Exception { + this.request.addHeader("Range", "bytes=0-1"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getStatus()).isEqualTo(206); + assertThat(this.response.getContentType()).isEqualTo("text/plain"); + assertThat(this.response.getContentLength()).isEqualTo(2); + assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 0-1/10"); + assertThat(this.response.getContentAsString()).isEqualTo("So"); + assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); + assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); + } - @Test - void testInvalidPath() throws Exception { - // Use mock ResourceResolver: i.e. we're only testing upfront validations... - - Resource resource = mock(); - given(resource.getFilename()).willThrow(new AssertionError("Resource should not be resolved")); - given(resource.getInputStream()).willThrow(new AssertionError("Resource should not be resolved")); - ResourceResolver resolver = mock(); - given(resolver.resolveResource(any(), any(), any(), any())).willReturn(resource); - - ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); - handler.setLocations(List.of(new ClassPathResource("test/", getClass()))); - handler.setResourceResolvers(List.of(resolver)); - handler.setServletContext(new TestServletContext()); - handler.afterPropertiesSet(); - - testInvalidPath("../testsecret/secret.txt", handler); - testInvalidPath("test/../../testsecret/secret.txt", handler); - testInvalidPath(":/../../testsecret/secret.txt", handler); - - Resource location = new UrlResource(getClass().getResource("./test/")); - this.handler.setLocations(List.of(location)); - Resource secretResource = new UrlResource(getClass().getResource("testsecret/secret.txt")); - String secretPath = secretResource.getURL().getPath(); - - testInvalidPath("file:" + secretPath, handler); - testInvalidPath("/file:" + secretPath, handler); - testInvalidPath("url:" + secretPath, handler); - testInvalidPath("/url:" + secretPath, handler); - testInvalidPath("/../.." + secretPath, handler); - testInvalidPath("/%2E%2E/testsecret/secret.txt", handler); - testInvalidPath("/%2E%2E/testsecret/secret.txt", handler); - testInvalidPath("%2F%2F%2E%2E%2F%2F%2E%2E" + secretPath, handler); - } + @Test + void partialContentByteRangeNoEnd() throws Exception { + this.request.addHeader("Range", "bytes=9-"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getStatus()).isEqualTo(206); + assertThat(this.response.getContentType()).isEqualTo("text/plain"); + assertThat(this.response.getContentLength()).isEqualTo(1); + assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 9-9/10"); + assertThat(this.response.getContentAsString()).isEqualTo("."); + assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); + assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); + } - private void testInvalidPath(String requestPath, ResourceHttpRequestHandler handler) { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, requestPath); - this.response = new MockHttpServletResponse(); - assertThatThrownBy(() -> handler.handleRequest(this.request, this.response)) - .isInstanceOf(NoResourceFoundException.class); - } + @Test + void partialContentByteRangeLargeEnd() throws Exception { + this.request.addHeader("Range", "bytes=9-10000"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getStatus()).isEqualTo(206); + assertThat(this.response.getContentType()).isEqualTo("text/plain"); + assertThat(this.response.getContentLength()).isEqualTo(1); + assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 9-9/10"); + assertThat(this.response.getContentAsString()).isEqualTo("."); + assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); + assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); + } - @Test - void resolvePathWithTraversal() throws Exception { - for (HttpMethod method : HttpMethod.values()) { - this.request = new MockHttpServletRequest("GET", ""); - this.response = new MockHttpServletResponse(); - testResolvePathWithTraversal(method); + @Test + void partialContentSuffixRange() throws Exception { + this.request.addHeader("Range", "bytes=-1"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getStatus()).isEqualTo(206); + assertThat(this.response.getContentType()).isEqualTo("text/plain"); + assertThat(this.response.getContentLength()).isEqualTo(1); + assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 9-9/10"); + assertThat(this.response.getContentAsString()).isEqualTo("."); + assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); + assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); } - } - private void testResolvePathWithTraversal(HttpMethod httpMethod) throws Exception { - this.request.setMethod(httpMethod.name()); - - Resource location = new ClassPathResource("test/", getClass()); - this.handler.setLocations(List.of(location)); - - testResolvePathWithTraversal(location, "../testsecret/secret.txt"); - testResolvePathWithTraversal(location, "test/../../testsecret/secret.txt"); - testResolvePathWithTraversal(location, ":/../../testsecret/secret.txt"); - - location = new UrlResource(getClass().getResource("./test/")); - this.handler.setLocations(List.of(location)); - Resource secretResource = new UrlResource(getClass().getResource("testsecret/secret.txt")); - String secretPath = secretResource.getURL().getPath(); - - testResolvePathWithTraversal(location, "file:" + secretPath); - testResolvePathWithTraversal(location, "/file:" + secretPath); - testResolvePathWithTraversal(location, "url:" + secretPath); - testResolvePathWithTraversal(location, "/url:" + secretPath); - testResolvePathWithTraversal(location, "/" + secretPath); - testResolvePathWithTraversal(location, "////../.." + secretPath); - testResolvePathWithTraversal(location, "/%2E%2E/testsecret/secret.txt"); - testResolvePathWithTraversal(location, "%2F%2F%2E%2E%2F%2Ftestsecret/secret.txt"); - testResolvePathWithTraversal(location, "/ " + secretPath); - } + @Test + void partialContentSuffixRangeLargeSuffix() throws Exception { + this.request.addHeader("Range", "bytes=-11"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getStatus()).isEqualTo(206); + assertThat(this.response.getContentType()).isEqualTo("text/plain"); + assertThat(this.response.getContentLength()).isEqualTo(10); + assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 0-9/10"); + assertThat(this.response.getContentAsString()).isEqualTo("Some text."); + assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); + assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); + } - private void testResolvePathWithTraversal(Resource location, String requestPath) { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, requestPath); - this.response = new MockHttpServletResponse(); - assertNotFound(); - } + @Test + void partialContentInvalidRangeHeader() throws Exception { + this.request.addHeader("Range", "bytes= foo bar"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); + this.handler.handleRequest(this.request, this.response); - @Test - void ignoreInvalidEscapeSequence() { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/%foo%/bar.txt"); - this.response = new MockHttpServletResponse(); - assertNotFound(); - } + assertThat(this.response.getStatus()).isEqualTo(416); + assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes */10"); + assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); + assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); + } - @Test - void processPath() { - // Unchanged - assertThat(this.handler.processPath("/foo/bar")).isSameAs("/foo/bar"); - assertThat(this.handler.processPath("foo/bar")).isSameAs("foo/bar"); - - // leading whitespace control characters (00-1F) - assertThat(this.handler.processPath(" /foo/bar")).isEqualTo("/foo/bar"); - assertThat(this.handler.processPath((char) 1 + "/foo/bar")).isEqualTo("/foo/bar"); - assertThat(this.handler.processPath((char) 31 + "/foo/bar")).isEqualTo("/foo/bar"); - assertThat(this.handler.processPath(" foo/bar")).isEqualTo("foo/bar"); - assertThat(this.handler.processPath((char) 31 + "foo/bar")).isEqualTo("foo/bar"); - - // leading control character 0x7F (DEL) - assertThat(this.handler.processPath((char) 127 + "/foo/bar")).isEqualTo("/foo/bar"); - assertThat(this.handler.processPath((char) 127 + "/foo/bar")).isEqualTo("/foo/bar"); - - // leading control and '/' characters - assertThat(this.handler.processPath(" / foo/bar")).isEqualTo("/foo/bar"); - assertThat(this.handler.processPath(" / / foo/bar")).isEqualTo("/foo/bar"); - assertThat(this.handler.processPath(" // /// //// foo/bar")).isEqualTo("/foo/bar"); - assertThat(this.handler.processPath((char) 1 + " / " + (char) 127 + " // foo/bar")).isEqualTo("/foo/bar"); - - // root or empty path - assertThat(this.handler.processPath(" ")).isEmpty(); - assertThat(this.handler.processPath("/")).isEqualTo("/"); - assertThat(this.handler.processPath("///")).isEqualTo("/"); - assertThat(this.handler.processPath("/ / / ")).isEqualTo("/"); - assertThat(this.handler.processPath("\\/ \\/ \\/ ")).isEqualTo("/"); - - // duplicate slash or backslash - assertThat(this.handler.processPath("//foo/ /bar//baz//")).isEqualTo("/foo/ /bar/baz/"); - assertThat(this.handler.processPath("\\\\foo\\ \\bar\\\\baz\\\\")).isEqualTo("/foo/ /bar/baz/"); - assertThat(this.handler.processPath("foo\\\\/\\////bar")).isEqualTo("foo/bar"); + @Test + void partialContentMultipleByteRanges() throws Exception { + this.request.addHeader("Range", "bytes=0-1, 4-5, 8-9"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); + this.handler.handleRequest(this.request, this.response); - } + assertThat(this.response.getStatus()).isEqualTo(206); + assertThat(this.response.getContentType()).startsWith("multipart/byteranges; boundary="); - @Test - void initAllowedLocations() { - PathResourceResolver resolver = (PathResourceResolver) this.handler.getResourceResolvers().get(0); - Resource[] locations = resolver.getAllowedLocations(); + String boundary = "--" + this.response.getContentType().substring(31); - assertThat(locations).containsExactly(this.testResource, this.testAlternatePathResource, this.webjarsResource); - } + String content = this.response.getContentAsString(); + String[] ranges = StringUtils.tokenizeToStringArray(content, "\r\n", false, true); - @Test - void initAllowedLocationsWithExplicitConfiguration() throws Exception { - ClassPathResource location1 = new ClassPathResource("test/", getClass()); - ClassPathResource location2 = new ClassPathResource("testalternatepath/", getClass()); + assertThat(ranges[0]).isEqualTo(boundary); + assertThat(ranges[1]).isEqualTo("Content-Type: text/plain"); + assertThat(ranges[2]).isEqualTo("Content-Range: bytes 0-1/10"); + assertThat(ranges[3]).isEqualTo("So"); - PathResourceResolver pathResolver = new PathResourceResolver(); - pathResolver.setAllowedLocations(location1); + assertThat(ranges[4]).isEqualTo(boundary); + assertThat(ranges[5]).isEqualTo("Content-Type: text/plain"); + assertThat(ranges[6]).isEqualTo("Content-Range: bytes 4-5/10"); + assertThat(ranges[7]).isEqualTo(" t"); - ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); - handler.setResourceResolvers(List.of(pathResolver)); - handler.setServletContext(new MockServletContext()); - handler.setLocations(List.of(location1, location2)); - handler.afterPropertiesSet(); + assertThat(ranges[8]).isEqualTo(boundary); + assertThat(ranges[9]).isEqualTo("Content-Type: text/plain"); + assertThat(ranges[10]).isEqualTo("Content-Range: bytes 8-9/10"); + assertThat(ranges[11]).isEqualTo("t."); + } - assertThat(pathResolver.getAllowedLocations()).containsExactly(location1); - } + @Test // gh-25976 + void partialContentByteRangeWithEncodedResource(GzipSupport.GzippedFiles gzippedFiles) throws Exception { + String path = "js/foo.js"; + gzippedFiles.create(path); + + ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); + handler.setResourceResolvers(List.of(new EncodedResourceResolver(), new PathResourceResolver())); + handler.setLocations(List.of(testResource)); + handler.setServletContext(new MockServletContext()); + handler.afterPropertiesSet(); + + this.request.addHeader("Accept-Encoding", "gzip"); + this.request.addHeader("Range", "bytes=0-1"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, path); + handler.handleRequest(this.request, this.response); + + assertThat(this.response.getStatus()).isEqualTo(206); + assertThat(this.response.getHeaderNames()).containsExactlyInAnyOrder( + "Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", + "Last-Modified", "Content-Encoding", "Vary"); + + assertThat(this.response.getContentType()).isEqualTo("text/javascript"); + assertThat(this.response.getContentLength()).isEqualTo(2); + assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 0-1/66"); + assertThat(this.response.getHeaderValues("Accept-Ranges")).containsExactly("bytes"); + assertThat(this.response.getHeaderValues("Content-Encoding")).containsExactly("gzip"); + assertThat(this.response.getHeaderValues("Vary")).containsExactly("Accept-Encoding"); + } - @Test - void notModified() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.request.addHeader("If-Modified-Since", resourceLastModified("test/foo.css")); - this.handler.handleRequest(this.request, this.response); - assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_MODIFIED); - } + @Test // gh-25976 + void partialContentWithHttpHead() throws Exception { + this.request.setMethod("HEAD"); + this.request.addHeader("Range", "bytes=0-1"); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getStatus()).isEqualTo(206); + assertThat(this.response.getContentType()).isEqualTo("text/plain"); + assertThat(this.response.getContentLength()).isEqualTo(2); + assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 0-1/10"); + assertThat(this.response.getHeaderValues("Accept-Ranges")).containsExactly("bytes"); + } - @Test - void modified() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.request.addHeader("If-Modified-Since", resourceLastModified("test/foo.css") / 1000 * 1000 - 1); - this.handler.handleRequest(this.request, this.response); - assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); - assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); } - @Test - void directory() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "js/"); - assertNotFound(); - } + @Nested + class HttpCachingTests { - @Test - void directoryInJarFile() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "underscorejs/"); - assertNotFound(); - } + private ResourceHttpRequestHandler handler; - @Test - void missingResourcePath() { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, ""); - assertNotFound(); - } + private MockHttpServletRequest request; - @Test - void noPathWithinHandlerMappingAttribute() { - assertThatIllegalStateException().isThrownBy(() -> - this.handler.handleRequest(this.request, this.response)); - } + private MockHttpServletResponse response; - @Test - void unsupportedHttpMethod() { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.request.setMethod("POST"); - assertThatExceptionOfType(HttpRequestMethodNotSupportedException.class).isThrownBy(() -> - this.handler.handleRequest(this.request, this.response)); - } - @Test - void testResourceNotFound() { - for (HttpMethod method : HttpMethod.values()) { - this.request = new MockHttpServletRequest("GET", ""); + @BeforeEach + void setup() { + TestServletContext servletContext = new TestServletContext(); + this.handler = new ResourceHttpRequestHandler(); + this.handler.setLocations(List.of(testResource, testAlternatePathResource, webjarsResource)); + this.handler.setServletContext(servletContext); + this.request = new MockHttpServletRequest(servletContext, "GET", ""); this.response = new MockHttpServletResponse(); - testResourceNotFound(method); } - } - private void testResourceNotFound(HttpMethod httpMethod) { - this.request.setMethod(httpMethod.name()); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "not-there.css"); - assertNotFound(); - } + @Test + void defaultCachingHeaders() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.handler.handleRequest(this.request, this.response); - @Test - void partialContentByteRange() throws Exception { - this.request.addHeader("Range", "bytes=0-1"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getStatus()).isEqualTo(206); - assertThat(this.response.getContentType()).isEqualTo("text/plain"); - assertThat(this.response.getContentLength()).isEqualTo(2); - assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 0-1/10"); - assertThat(this.response.getContentAsString()).isEqualTo("So"); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + assertThat(this.response.containsHeader("Last-Modified")).isTrue(); + assertThat(this.response.getDateHeader("Last-Modified") / 1000).isEqualTo(resourceLastModified("test/foo.css") / 1000); + } - @Test - void partialContentByteRangeNoEnd() throws Exception { - this.request.addHeader("Range", "bytes=9-"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getStatus()).isEqualTo(206); - assertThat(this.response.getContentType()).isEqualTo("text/plain"); - assertThat(this.response.getContentLength()).isEqualTo(1); - assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 9-9/10"); - assertThat(this.response.getContentAsString()).isEqualTo("."); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + @Test + void configureCacheSeconds() throws Exception { + this.handler.setCacheSeconds(3600); + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.handler.handleRequest(this.request, this.response); - @Test - void partialContentByteRangeLargeEnd() throws Exception { - this.request.addHeader("Range", "bytes=9-10000"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getStatus()).isEqualTo(206); - assertThat(this.response.getContentType()).isEqualTo("text/plain"); - assertThat(this.response.getContentLength()).isEqualTo(1); - assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 9-9/10"); - assertThat(this.response.getContentAsString()).isEqualTo("."); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + assertThat(this.response.getHeader("Cache-Control")).isEqualTo("max-age=3600"); + } - @Test - void partialContentSuffixRange() throws Exception { - this.request.addHeader("Range", "bytes=-1"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getStatus()).isEqualTo(206); - assertThat(this.response.getContentType()).isEqualTo("text/plain"); - assertThat(this.response.getContentLength()).isEqualTo(1); - assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 9-9/10"); - assertThat(this.response.getContentAsString()).isEqualTo("."); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } - @Test - void partialContentSuffixRangeLargeSuffix() throws Exception { - this.request.addHeader("Range", "bytes=-11"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getStatus()).isEqualTo(206); - assertThat(this.response.getContentType()).isEqualTo("text/plain"); - assertThat(this.response.getContentLength()).isEqualTo(10); - assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 0-9/10"); - assertThat(this.response.getContentAsString()).isEqualTo("Some text."); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + @Test + void configureCacheSecondsToZero() throws Exception { + this.handler.setCacheSeconds(0); + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.handler.handleRequest(this.request, this.response); - @Test - void partialContentInvalidRangeHeader() throws Exception { - this.request.addHeader("Range", "bytes= foo bar"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); - this.handler.handleRequest(this.request, this.response); + assertThat(this.response.getHeader("Cache-Control")).isEqualTo("no-store"); + } - assertThat(this.response.getStatus()).isEqualTo(416); - assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes */10"); - assertThat(this.response.getHeader("Accept-Ranges")).isEqualTo("bytes"); - assertThat(this.response.getHeaders("Accept-Ranges")).hasSize(1); - } + @Test + void configureVersionResourceResolver() throws Exception { + VersionResourceResolver versionResolver = new VersionResourceResolver() + .addFixedVersionStrategy("versionString", "/**"); + this.handler.setResourceResolvers(List.of(versionResolver, new PathResourceResolver())); + this.handler.afterPropertiesSet(); - @Test - void partialContentMultipleByteRanges() throws Exception { - this.request.addHeader("Range", "bytes=0-1, 4-5, 8-9"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); - this.handler.handleRequest(this.request, this.response); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "versionString/foo.css"); + this.handler.handleRequest(this.request, this.response); - assertThat(this.response.getStatus()).isEqualTo(206); - assertThat(this.response.getContentType()).startsWith("multipart/byteranges; boundary="); + assertThat(this.response.getHeader("ETag")).isEqualTo("W/\"versionString\""); + } - String boundary = "--" + this.response.getContentType().substring(31); + @Test + void shouldRespondWithNotModified() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.request.addHeader("If-Modified-Since", resourceLastModified("test/foo.css")); + this.handler.handleRequest(this.request, this.response); + assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_MODIFIED); + } - String content = this.response.getContentAsString(); - String[] ranges = StringUtils.tokenizeToStringArray(content, "\r\n", false, true); + @Test + void shouldRespondWithModifiedResource() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.request.addHeader("If-Modified-Since", resourceLastModified("test/foo.css") / 1000 * 1000 - 1); + this.handler.handleRequest(this.request, this.response); + assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); + } - assertThat(ranges[0]).isEqualTo(boundary); - assertThat(ranges[1]).isEqualTo("Content-Type: text/plain"); - assertThat(ranges[2]).isEqualTo("Content-Range: bytes 0-1/10"); - assertThat(ranges[3]).isEqualTo("So"); + @Test // SPR-14005 + void overwritesExistingCacheControlHeaders() throws Exception { + this.handler.setCacheSeconds(3600); + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.response.setHeader("Cache-Control", "no-store"); - assertThat(ranges[4]).isEqualTo(boundary); - assertThat(ranges[5]).isEqualTo("Content-Type: text/plain"); - assertThat(ranges[6]).isEqualTo("Content-Range: bytes 4-5/10"); - assertThat(ranges[7]).isEqualTo(" t"); + this.handler.handleRequest(this.request, this.response); - assertThat(ranges[8]).isEqualTo(boundary); - assertThat(ranges[9]).isEqualTo("Content-Type: text/plain"); - assertThat(ranges[10]).isEqualTo("Content-Range: bytes 8-9/10"); - assertThat(ranges[11]).isEqualTo("t."); - } + assertThat(this.response.getHeader("Cache-Control")).isEqualTo("max-age=3600"); + } - @Test // gh-25976 - void partialContentByteRangeWithEncodedResource(GzipSupport.GzippedFiles gzippedFiles) throws Exception { - String path = "js/foo.js"; - gzippedFiles.create(path); - - ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); - handler.setResourceResolvers(List.of(new EncodedResourceResolver(), new PathResourceResolver())); - handler.setLocations(List.of(new ClassPathResource("test/", getClass()))); - handler.setServletContext(new MockServletContext()); - handler.afterPropertiesSet(); - - this.request.addHeader("Accept-Encoding", "gzip"); - this.request.addHeader("Range", "bytes=0-1"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, path); - handler.handleRequest(this.request, this.response); - - assertThat(this.response.getStatus()).isEqualTo(206); - assertThat(this.response.getHeaderNames()).containsExactlyInAnyOrder( - "Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", - "Last-Modified", "Content-Encoding", "Vary"); - - assertThat(this.response.getContentType()).isEqualTo("text/javascript"); - assertThat(this.response.getContentLength()).isEqualTo(2); - assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 0-1/66"); - assertThat(this.response.getHeaderValues("Accept-Ranges")).containsExactly("bytes"); - assertThat(this.response.getHeaderValues("Content-Encoding")).containsExactly("gzip"); - assertThat(this.response.getHeaderValues("Vary")).containsExactly("Accept-Encoding"); - } + @Test + void ignoreLastModified() throws Exception { + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); + this.handler.setUseLastModified(false); + this.handler.afterPropertiesSet(); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getContentType()).isEqualTo("text/css"); + assertThat(this.response.getContentLength()).isEqualTo(17); + assertThat(this.response.containsHeader("Last-Modified")).isFalse(); + assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); + } + + + private long resourceLastModified(String resourceName) throws IOException { + return new ClassPathResource(resourceName, getClass()).getFile().lastModified(); + } - @Test // gh-25976 - void partialContentWithHttpHead() throws Exception { - this.request.setMethod("HEAD"); - this.request.addHeader("Range", "bytes=0-1"); - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt"); - this.handler.handleRequest(this.request, this.response); - - assertThat(this.response.getStatus()).isEqualTo(206); - assertThat(this.response.getContentType()).isEqualTo("text/plain"); - assertThat(this.response.getContentLength()).isEqualTo(2); - assertThat(this.response.getHeader("Content-Range")).isEqualTo("bytes 0-1/10"); - assertThat(this.response.getHeaderValues("Accept-Ranges")).containsExactly("bytes"); } - @Test // SPR-14005 - void doOverwriteExistingCacheControlHeaders() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.response.setHeader("Cache-Control", "no-store"); - this.handler.handleRequest(this.request, this.response); + @Nested + class ResourceLocationTests { - assertThat(this.response.getHeader("Cache-Control")).isEqualTo("max-age=3600"); - } + private ResourceHttpRequestHandler handler; - @Test - void ignoreLastModified() throws Exception { - this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.css"); - this.handler.setUseLastModified(false); - this.handler.handleRequest(this.request, this.response); + private MockHttpServletRequest request; - assertThat(this.response.getContentType()).isEqualTo("text/css"); - assertThat(this.response.getContentLength()).isEqualTo(17); - assertThat(this.response.containsHeader("Last-Modified")).isFalse(); - assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); - } + private MockHttpServletResponse response; + + + @BeforeEach + void setup() throws Exception { + TestServletContext servletContext = new TestServletContext(); + this.handler = new ResourceHttpRequestHandler(); + this.handler.setLocations(List.of(testResource, testAlternatePathResource, webjarsResource)); + this.handler.setServletContext(servletContext); + this.request = new MockHttpServletRequest(servletContext, "GET", ""); + this.response = new MockHttpServletResponse(); + } + + @Test + void servesResourcesFromAlternatePath() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "baz.css"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getContentType()).isEqualTo("text/css"); + assertThat(this.response.getContentLength()).isEqualTo(17); + assertThat(this.response.getContentAsString()).isEqualTo("h1 { color:red; }"); + } + + @Test + void servesResourcesFromSubDirectory() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "js/foo.js"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getContentType()).isEqualTo("text/javascript"); + assertThat(this.response.getContentAsString()).isEqualTo("function foo() { console.log(\"hello world\"); }"); + } + + @Test + void servesResourcesFromSubDirectoryOfAlternatePath() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "js/baz.js"); + this.handler.handleRequest(this.request, this.response); + + assertThat(this.response.getContentType()).isEqualTo("text/javascript"); + assertThat(this.response.getContentAsString()).isEqualTo("function foo() { console.log(\"hello world\"); }"); + } + + @Test // gh-27538, gh-27624 + void filterNonExistingLocations() throws Exception { + List inputLocations = List.of(testResource, testAlternatePathResource, + new ClassPathResource("nosuchpath/", ResourceHttpRequestHandlerTests.class)); + this.handler.setLocations(inputLocations); + this.handler.setOptimizeLocations(true); + this.handler.afterPropertiesSet(); + + List actual = handler.getLocations(); + assertThat(actual).hasSize(2); + assertThat(actual.get(0).getURL().toString()).endsWith("test/"); + assertThat(actual.get(1).getURL().toString()).endsWith("testalternatepath/"); + } + + @Test + void shouldRejectInvalidPath() throws Exception { + // Use mock ResourceResolver: i.e. we're only testing upfront validations... + Resource resource = mock(); + given(resource.getFilename()).willThrow(new AssertionError("Resource should not be resolved")); + given(resource.getInputStream()).willThrow(new AssertionError("Resource should not be resolved")); + ResourceResolver resolver = mock(); + given(resolver.resolveResource(any(), any(), any(), any())).willReturn(resource); + + this.handler.setLocations(List.of(testResource)); + this.handler.setResourceResolvers(List.of(resolver)); + this.handler.setServletContext(new TestServletContext()); + this.handler.afterPropertiesSet(); + + testInvalidPath("../testsecret/secret.txt"); + testInvalidPath("test/../../testsecret/secret.txt"); + testInvalidPath(":/../../testsecret/secret.txt"); + + Resource location = new UrlResource(ResourceHttpRequestHandlerTests.class.getResource("./test/")); + this.handler.setLocations(List.of(location)); + Resource secretResource = new UrlResource(ResourceHttpRequestHandlerTests.class.getResource("testsecret/secret.txt")); + String secretPath = secretResource.getURL().getPath(); + + testInvalidPath("file:" + secretPath); + testInvalidPath("/file:" + secretPath); + testInvalidPath("url:" + secretPath); + testInvalidPath("/url:" + secretPath); + testInvalidPath("/../.." + secretPath); + testInvalidPath("/%2E%2E/testsecret/secret.txt"); + testInvalidPath("/%2E%2E/testsecret/secret.txt"); + testInvalidPath("%2F%2F%2E%2E%2F%2F%2E%2E" + secretPath); + } - @Test - void servletContextRootValidation() { - StaticWebApplicationContext context = new StaticWebApplicationContext() { - @Override - public Resource getResource(String location) { - return new FileSystemResource("/"); + private void testInvalidPath(String requestPath) { + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, requestPath); + this.response = new MockHttpServletResponse(); + assertNotFound(); + } + + private void assertNotFound() { + assertThatThrownBy(() -> this.handler.handleRequest(this.request, this.response)) + .isInstanceOf(NoResourceFoundException.class); + } + + @Test + void shouldRejectPathWithTraversal() throws Exception { + this.handler.afterPropertiesSet(); + for (HttpMethod method : HttpMethod.values()) { + this.request = new MockHttpServletRequest("GET", ""); + this.response = new MockHttpServletResponse(); + shouldRejectPathWithTraversal(method); } - }; + } - ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); - handler.setLocationValues(List.of("/")); - handler.setApplicationContext(context); + private void shouldRejectPathWithTraversal(HttpMethod httpMethod) throws Exception { + this.request.setMethod(httpMethod.name()); + + Resource location = new ClassPathResource("test/", getClass()); + this.handler.setLocations(List.of(location)); + + testResolvePathWithTraversal(location, "../testsecret/secret.txt"); + testResolvePathWithTraversal(location, "test/../../testsecret/secret.txt"); + testResolvePathWithTraversal(location, ":/../../testsecret/secret.txt"); + + location = new UrlResource(ResourceHttpRequestHandlerTests.class.getResource("./test/")); + this.handler.setLocations(List.of(location)); + Resource secretResource = new UrlResource(ResourceHttpRequestHandlerTests.class.getResource("testsecret/secret.txt")); + String secretPath = secretResource.getURL().getPath(); + + testResolvePathWithTraversal(location, "file:" + secretPath); + testResolvePathWithTraversal(location, "/file:" + secretPath); + testResolvePathWithTraversal(location, "url:" + secretPath); + testResolvePathWithTraversal(location, "/url:" + secretPath); + testResolvePathWithTraversal(location, "/" + secretPath); + testResolvePathWithTraversal(location, "////../.." + secretPath); + testResolvePathWithTraversal(location, "/%2E%2E/testsecret/secret.txt"); + testResolvePathWithTraversal(location, "%2F%2F%2E%2E%2F%2Ftestsecret/secret.txt"); + testResolvePathWithTraversal(location, "/ " + secretPath); + } - assertThatIllegalStateException().isThrownBy(handler::afterPropertiesSet) - .withMessage("The String-based location \"/\" should be relative to the web application root but " + - "resolved to a Resource of type: class org.springframework.core.io.FileSystemResource. " + - "If this is intentional, please pass it as a pre-configured Resource via setLocations."); - } + private void testResolvePathWithTraversal(Resource location, String requestPath) { + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, requestPath); + this.response = new MockHttpServletResponse(); + assertNotFound(); + } + + @Test + void ignoreInvalidEscapeSequence() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/%foo%/bar.txt"); + this.response = new MockHttpServletResponse(); + assertNotFound(); + } + @Test + void processPath() { + // Unchanged + assertThat(this.handler.processPath("/foo/bar")).isSameAs("/foo/bar"); + assertThat(this.handler.processPath("foo/bar")).isSameAs("foo/bar"); + + // leading whitespace control characters (00-1F) + assertThat(this.handler.processPath(" /foo/bar")).isEqualTo("/foo/bar"); + assertThat(this.handler.processPath((char) 1 + "/foo/bar")).isEqualTo("/foo/bar"); + assertThat(this.handler.processPath((char) 31 + "/foo/bar")).isEqualTo("/foo/bar"); + assertThat(this.handler.processPath(" foo/bar")).isEqualTo("foo/bar"); + assertThat(this.handler.processPath((char) 31 + "foo/bar")).isEqualTo("foo/bar"); + + // leading control character 0x7F (DEL) + assertThat(this.handler.processPath((char) 127 + "/foo/bar")).isEqualTo("/foo/bar"); + assertThat(this.handler.processPath((char) 127 + "/foo/bar")).isEqualTo("/foo/bar"); + + // leading control and '/' characters + assertThat(this.handler.processPath(" / foo/bar")).isEqualTo("/foo/bar"); + assertThat(this.handler.processPath(" / / foo/bar")).isEqualTo("/foo/bar"); + assertThat(this.handler.processPath(" // /// //// foo/bar")).isEqualTo("/foo/bar"); + assertThat(this.handler.processPath((char) 1 + " / " + (char) 127 + " // foo/bar")).isEqualTo("/foo/bar"); + + // root or empty path + assertThat(this.handler.processPath(" ")).isEmpty(); + assertThat(this.handler.processPath("/")).isEqualTo("/"); + assertThat(this.handler.processPath("///")).isEqualTo("/"); + assertThat(this.handler.processPath("/ / / ")).isEqualTo("/"); + assertThat(this.handler.processPath("\\/ \\/ \\/ ")).isEqualTo("/"); + + // duplicate slash or backslash + assertThat(this.handler.processPath("//foo/ /bar//baz//")).isEqualTo("/foo/ /bar/baz/"); + assertThat(this.handler.processPath("\\\\foo\\ \\bar\\\\baz\\\\")).isEqualTo("/foo/ /bar/baz/"); + assertThat(this.handler.processPath("foo\\\\/\\////bar")).isEqualTo("foo/bar"); - private void assertNotFound() { - assertThatThrownBy(() -> this.handler.handleRequest(this.request, this.response)) - .isInstanceOf(NoResourceFoundException.class); - } + } + + @Test + void initAllowedLocations() throws Exception { + this.handler.afterPropertiesSet(); + PathResourceResolver resolver = (PathResourceResolver) this.handler.getResourceResolvers().get(0); + Resource[] locations = resolver.getAllowedLocations(); + + assertThat(locations).containsExactly(testResource, testAlternatePathResource, webjarsResource); + } + + @Test + void initAllowedLocationsWithExplicitConfiguration() throws Exception { + PathResourceResolver pathResolver = new PathResourceResolver(); + pathResolver.setAllowedLocations(testResource); + + this.handler.setResourceResolvers(List.of(pathResolver)); + this.handler.setLocations(List.of(testResource, testAlternatePathResource)); + this.handler.afterPropertiesSet(); + + assertThat(pathResolver.getAllowedLocations()).containsExactly(testResource); + } + + @Test + void shouldNotServeDirectory() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "js/"); + assertNotFound(); + } + + @Test + void shouldNotServeDirectoryInJarFile() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "underscorejs/"); + assertNotFound(); + } + + @Test + void shouldNotServeMissingResourcePath() throws Exception { + this.handler.afterPropertiesSet(); + this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, ""); + assertNotFound(); + } + + @Test + void noPathWithinHandlerMappingAttribute() throws Exception { + this.handler.afterPropertiesSet(); + assertThatIllegalStateException().isThrownBy(() -> + this.handler.handleRequest(this.request, this.response)); + } + + @Test + void servletContextRootValidation() { + StaticWebApplicationContext context = new StaticWebApplicationContext() { + @Override + public Resource getResource(String location) { + return new FileSystemResource("/"); + } + }; + + ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler(); + handler.setLocationValues(List.of("/")); + handler.setApplicationContext(context); + + assertThatIllegalStateException().isThrownBy(handler::afterPropertiesSet) + .withMessage("The String-based location \"/\" should be relative to the web application root but " + + "resolved to a Resource of type: class org.springframework.core.io.FileSystemResource. " + + "If this is intentional, please pass it as a pre-configured Resource via setLocations."); + } - private long resourceLastModified(String resourceName) throws IOException { - return new ClassPathResource(resourceName, getClass()).getFile().lastModified(); }