/* * Copyright 2016 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at: * * 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 com.linecorp.armeria.server.file; import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.FileTime; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.zip.GZIPInputStream; import javax.annotation.Nullable; import org.apache.http.HttpHeaders; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.ArgumentsProvider; import org.junit.jupiter.params.provider.ArgumentsSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.io.ByteStreams; import com.google.common.io.Resources; import com.linecorp.armeria.common.util.OsType; import com.linecorp.armeria.common.util.SystemInfo; import com.linecorp.armeria.internal.common.PathAndQuery; import com.linecorp.armeria.server.ServerBuilder; import com.linecorp.armeria.server.logging.LoggingService; import com.linecorp.armeria.testing.junit5.server.ServerExtension; import io.netty.handler.codec.DateFormatter; class FileServiceTest { private static final Logger logger = LoggerFactory.getLogger(FileServiceTest.class); private static final ZoneId UTC = ZoneId.of("UTC"); private static final Pattern ETAG_PATTERN = Pattern.compile("^\"[^\"]+\"$"); private static final String baseResourceDir = FileServiceTest.class.getPackage().getName().replace('.', '/') + '/'; @TempDir static Path tmpDir; @RegisterExtension static final ServerExtension server = new ServerExtension() { @Override protected void configure(ServerBuilder sb) { final ClassLoader classLoader = getClass().getClassLoader(); sb.serviceUnder( "/cached/fs/", FileService.builder(tmpDir) .autoIndex(true) .build()); sb.serviceUnder( "/uncached/fs/", FileService.builder(tmpDir) .maxCacheEntries(0) .autoIndex(true) .build()); sb.serviceUnder( "/cached/compressed/", FileService.builder(classLoader, baseResourceDir + "foo") .serveCompressedFiles(true) .build()); sb.serviceUnder( "/uncached/compressed/", FileService.builder(classLoader, baseResourceDir + "foo") .serveCompressedFiles(true) .maxCacheEntries(0) .build()); sb.serviceUnder( "/cached/classes/", FileService.of(classLoader, "/")); sb.serviceUnder( "/uncached/classes/", FileService.builder(classLoader, "/") .maxCacheEntries(0) .build()); sb.serviceUnder( "/cached/by-entry/classes/", FileService.builder(classLoader, "/") .entryCacheSpec("maximumSize=512") .build()); sb.serviceUnder( "/uncached/by-entry/classes/", FileService.builder(classLoader, "/") .entryCacheSpec("off") .build()); sb.serviceUnder( "/cached/", FileService.of(classLoader, baseResourceDir + "foo") .orElse(FileService.of(classLoader, baseResourceDir + "bar"))); sb.serviceUnder( "/uncached/", FileService.builder(classLoader, baseResourceDir + "foo") .maxCacheEntries(0) .build() .orElse(FileService.builder(classLoader, baseResourceDir + "bar") .maxCacheEntries(0) .build())); sb.decorator(LoggingService.newDecorator()); } }; @AfterAll static void stopSynchronously() { if (SystemInfo.osType() == OsType.WINDOWS) { // Shut down the server completely so that no files // are open before deleting the temporary directory. server.stop().join(); } } @BeforeEach void setUp() { PathAndQuery.clearCachedPaths(); } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testClassPathGet(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { final String lastModified; final String etag; try (CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + "/foo.txt"))) { assert200Ok(res, "text/plain", "foo"); lastModified = header(res, HttpHeaders.LAST_MODIFIED); etag = header(res, HttpHeaders.ETAG); } assert304NotModified(hc, baseUri, "/foo.txt", etag, lastModified); // Confirm file service paths are cached when cache is enabled. if (baseUri.contains("/cached")) { assertThat(PathAndQuery.cachedPaths()).contains("/cached/foo.txt"); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testClassPathGetUtf8(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { try (CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + "/%C2%A2.txt"))) { assert200Ok(res, "text/plain", "¢"); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testClassPathGetFromModule(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { // Read a class from a JDK module (java.base). try (CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + "/classes/java/lang/Object.class"))) { assert200Ok(res, null, content -> assertThat(content).isNotEmpty()); } // Read a class from a JDK module (java.base). try (CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + "/by-entry/classes/java/lang/Object.class"))) { assert200Ok(res, null, content -> assertThat(content).isNotEmpty()); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testClassPathGetFromJar(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { // Read a class from a third-party library JAR. try (CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + "/classes/io/netty/util/NetUtil.class"))) { assert200Ok(res, null, content -> assertThat(content).isNotEmpty()); } // Read a class from a third-party library JAR. try (CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + "/by-entry/classes/io/netty/util/NetUtil.class"))) { assert200Ok(res, null, content -> assertThat(content).isNotEmpty()); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testClassPathOrElseGet(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal(); CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + "/bar.txt"))) { assert200Ok(res, "text/plain", "bar"); } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testIndexHtml(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { try (CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + '/'))) { assert200Ok(res, "text/html", "<html><body></body></html>"); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testAutoIndex(String baseUri) throws Exception { final Path rootDir = tmpDir.resolve("auto_index"); final Path childFile = rootDir.resolve("child_file"); final Path childDir = rootDir.resolve("child_dir"); final Path grandchildFile = childDir.resolve("grandchild_file"); final Path emptyChildDir = rootDir.resolve("empty_child_dir"); final Path childDirWithCustomIndex = rootDir.resolve("child_dir_with_custom_index"); final Path customIndexFile = childDirWithCustomIndex.resolve("index.html"); Files.createDirectories(childDir); Files.createDirectories(emptyChildDir); Files.createDirectories(childDirWithCustomIndex); writeFile(childFile, "child_file"); writeFile(grandchildFile, "grandchild_file"); writeFile(customIndexFile, "custom_index_file"); final String basePath = new URI(baseUri).getPath(); try (CloseableHttpClient hc = HttpClients.createMinimal()) { // Ensure auto-redirect works as expected. HttpUriRequest req = new HttpGet(baseUri + "/fs/auto_index"); try (CloseableHttpResponse res = hc.execute(req)) { assertStatusLine(res, "HTTP/1.1 307 Temporary Redirect"); assertThat(header(res, "location")).isEqualTo(basePath + "/fs/auto_index/"); } // Ensure directory listing works as expected. req = new HttpGet(baseUri + "/fs/auto_index/"); try (CloseableHttpResponse res = hc.execute(req)) { assertStatusLine(res, "HTTP/1.1 200 OK"); final String content = contentString(res); assertThat(content) .contains("Directory listing: " + basePath + "/fs/auto_index/") .contains("4 file(s) total") .contains("<a href=\"../\">../</a>") .contains("<a href=\"child_dir/\">child_dir/</a>") .contains("<a href=\"child_file\">child_file</a>") .contains("<a href=\"child_dir_with_custom_index/\">child_dir_with_custom_index/</a>") .contains("<a href=\"empty_child_dir/\">empty_child_dir/</a>"); } // Ensure directory listing on an empty directory works as expected. req = new HttpGet(baseUri + "/fs/auto_index/empty_child_dir/"); try (CloseableHttpResponse res = hc.execute(req)) { assertStatusLine(res, "HTTP/1.1 200 OK"); final String content = contentString(res); assertThat(content) .contains("Directory listing: " + basePath + "/fs/auto_index/empty_child_dir/") .contains("0 file(s) total") .contains("<a href=\"../\">../</a>"); } // Ensure custom index.html takes precedence over auto-generated directory listing. req = new HttpGet(baseUri + "/fs/auto_index/child_dir_with_custom_index/"); try (CloseableHttpResponse res = hc.execute(req)) { assertStatusLine(res, "HTTP/1.1 200 OK"); assertThat(contentString(res)).isEqualTo("custom_index_file"); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testUnknownMediaType(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal(); CloseableHttpResponse res = hc.execute(new HttpGet(baseUri + "/bar.unknown"))) { assert200Ok(res, null, "Unknown Media Type"); final String lastModified = header(res, HttpHeaders.LAST_MODIFIED); final String etag = header(res, HttpHeaders.ETAG); assert304NotModified(hc, baseUri, "/bar.unknown", etag, lastModified); } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testGetPreCompressedSupportsNone(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { final HttpGet request = new HttpGet(baseUri + "/compressed/foo.txt"); try (CloseableHttpResponse res = hc.execute(request)) { assertThat(res.getFirstHeader("Content-Encoding")).isNull(); assertThat(headerOrNull(res, "Content-Type")).isEqualTo( "text/plain; charset=utf-8"); final byte[] content = content(res); assertThat(new String(content, StandardCharsets.UTF_8)).isEqualTo("foo"); // Confirm path not cached when cache disabled. assertThat(PathAndQuery.cachedPaths()) .doesNotContain("/compressed/foo.txt"); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testGetWithoutPreCompression(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { final HttpGet request = new HttpGet(baseUri + "/compressed/foo_alone.txt"); request.setHeader("Accept-Encoding", "gzip"); try (CloseableHttpResponse res = hc.execute(request)) { assertThat(res.getFirstHeader("Content-Encoding")).isNull(); assertThat(headerOrNull(res, "Content-Type")).isEqualTo( "text/plain; charset=utf-8"); final byte[] content = content(res); assertThat(new String(content, StandardCharsets.UTF_8)).isEqualTo("foo_alone"); // Confirm path not cached when cache disabled. assertThat(PathAndQuery.cachedPaths()) .doesNotContain("/compressed/foo_alone.txt"); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testGetPreCompressedSupportsGzip(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { final HttpGet request = new HttpGet(baseUri + "/compressed/foo.txt"); request.setHeader("Accept-Encoding", "gzip"); try (CloseableHttpResponse res = hc.execute(request)) { assertThat(headerOrNull(res, "Content-Encoding")).isEqualTo("gzip"); assertThat(headerOrNull(res, "Content-Type")).isEqualTo( "text/plain; charset=utf-8"); final byte[] content; try (GZIPInputStream unzipper = new GZIPInputStream(res.getEntity().getContent())) { content = ByteStreams.toByteArray(unzipper); } assertThat(new String(content, StandardCharsets.UTF_8)).isEqualTo("foo"); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testGetPreCompressedSupportsBrotli(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { final HttpGet request = new HttpGet(baseUri + "/compressed/foo.txt"); request.setHeader("Accept-Encoding", "br"); try (CloseableHttpResponse res = hc.execute(request)) { assertThat(headerOrNull(res, "Content-Encoding")).isEqualTo("br"); assertThat(headerOrNull(res, "Content-Type")).isEqualTo( "text/plain; charset=utf-8"); // Test would be more readable and fun by decompressing like the gzip one, but since JDK doesn't // support brotli yet, just compare the compressed content to avoid adding a complex dependency. final byte[] content = content(res); assertThat(content).containsExactly( Resources.toByteArray(Resources.getResource(baseResourceDir + "foo/foo.txt.br"))); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testGetPreCompressedSupportsBothPrefersBrotli(String baseUri) throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { final HttpGet request = new HttpGet(baseUri + "/compressed/foo.txt"); request.setHeader("Accept-Encoding", "gzip, br"); try (CloseableHttpResponse res = hc.execute(request)) { assertThat(headerOrNull(res, "Content-Encoding")).isEqualTo("br"); assertThat(headerOrNull(res, "Content-Type")).isEqualTo( "text/plain; charset=utf-8"); // Test would be more readable and fun by decompressing like the gzip one, but since JDK doesn't // support brotli yet, just compare the compressed content to avoid adding a complex dependency. final byte[] content = content(res); assertThat(content).containsExactly( Resources.toByteArray(Resources.getResource(baseResourceDir + "foo/foo.txt.br"))); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testFileSystemGet(String baseUri) throws Exception { final Path barFile = tmpDir.resolve("bar.html"); final String expectedContentA = "<html/>"; final String expectedContentB = "<html><body/></html>"; writeFile(barFile, expectedContentA); try (CloseableHttpClient hc = HttpClients.createMinimal()) { final String lastModified; final String etag; HttpUriRequest req = new HttpGet(baseUri + "/fs/bar.html"); try (CloseableHttpResponse res = hc.execute(req)) { assert200Ok(res, "text/html", expectedContentA); lastModified = header(res, HttpHeaders.LAST_MODIFIED); etag = header(res, HttpHeaders.ETAG); } assert304NotModified(hc, baseUri, "/fs/bar.html", etag, lastModified); // Test if the 'If-Modified-Since' header works as expected after the file is modified. req = new HttpGet(baseUri + "/fs/bar.html"); final Instant now = Instant.now(); req.setHeader(HttpHeaders.IF_MODIFIED_SINCE, DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.ofInstant(now, UTC))); // HTTP-date has no sub-second precision; just add a few seconds to the time. writeFile(barFile, expectedContentB); Files.setLastModifiedTime(barFile, FileTime.fromMillis(now.toEpochMilli() + 5000)); final String newLastModified; final String newETag; try (CloseableHttpResponse res = hc.execute(req)) { assert200Ok(res, "text/html", expectedContentB); newLastModified = header(res, HttpHeaders.LAST_MODIFIED); newETag = header(res, HttpHeaders.ETAG); // Ensure that both 'Last-Modified' and 'ETag' changed. assertThat(newLastModified).isNotEqualTo(lastModified); assertThat(newETag).isNotEqualTo(etag); } // Test if the 'If-None-Match' header works as expected after the file is modified. req = new HttpGet(baseUri + "/fs/bar.html"); req.setHeader(HttpHeaders.IF_NONE_MATCH, etag); try (CloseableHttpResponse res = hc.execute(req)) { assert200Ok(res, "text/html", expectedContentB); // Ensure that both 'Last-Modified' and 'ETag' changed. assertThat(header(res, HttpHeaders.LAST_MODIFIED)).isEqualTo(newLastModified); assertThat(header(res, HttpHeaders.ETAG)).isEqualTo(newETag); } // Test if the cache detects the file removal correctly. final boolean deleted = Files.deleteIfExists(barFile); assertThat(deleted).isTrue(); req = new HttpGet(baseUri + "/fs/bar.html"); req.setHeader(HttpHeaders.IF_MODIFIED_SINCE, currentHttpDate()); req.setHeader(HttpHeaders.CONNECTION, "close"); try (CloseableHttpResponse res = hc.execute(req)) { assert404NotFound(res); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testFileSystemGet_modifiedFile(String baseUri) throws Exception { final Path barFile = tmpDir.resolve("modifiedFile.html"); final String expectedContentA = "<html/>"; final String expectedContentB = "<html><body/></html>"; writeFile(barFile, expectedContentA); final long barFileLastModified = Files.getLastModifiedTime(barFile).toMillis(); try (CloseableHttpClient hc = HttpClients.createMinimal()) { final HttpUriRequest req = new HttpGet(baseUri + "/fs/modifiedFile.html"); try (CloseableHttpResponse res = hc.execute(req)) { assert200Ok(res, "text/html", expectedContentA); } // Modify the file cached by the service. Update last modification time explicitly // so that it differs from the old value. writeFile(barFile, expectedContentB); Files.setLastModifiedTime(barFile, FileTime.fromMillis(barFileLastModified + 5000)); try (CloseableHttpResponse res = hc.execute(req)) { assert200Ok(res, "text/html", expectedContentB); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testFileSystemGet_newFile(String baseUri) throws Exception { final String barFileName = baseUri.substring(baseUri.lastIndexOf('/') + 1) + "_newFile.html"; assertThat(barFileName).isIn("cached_newFile.html", "uncached_newFile.html"); final Path barFile = tmpDir.resolve(barFileName); final String expectedContentA = "<html/>"; try (CloseableHttpClient hc = HttpClients.createMinimal()) { final HttpUriRequest req = new HttpGet(baseUri + "/fs/" + barFileName); try (CloseableHttpResponse res = hc.execute(req)) { assert404NotFound(res); } writeFile(barFile, expectedContentA); try (CloseableHttpResponse res = hc.execute(req)) { assert200Ok(res, "text/html", expectedContentA); } } } @ParameterizedTest @ArgumentsSource(BaseUriProvider.class) void testFileSystemGetUtf8(String baseUri) throws Exception { final Path barFile = tmpDir.resolve("¢.txt"); final String expectedContentA = "¢"; writeFile(barFile, expectedContentA); try (CloseableHttpClient hc = HttpClients.createMinimal()) { final HttpUriRequest req = new HttpGet(baseUri + "/fs/%C2%A2.txt"); try (CloseableHttpResponse res = hc.execute(req)) { assert200Ok(res, "text/plain", expectedContentA); } } } private static void writeFile(Path path, String content) throws Exception { // Retry to work around the `AccessDeniedException` in Windows. for (int i = 9; i >= 0; i--) { try { Files.write(path, content.getBytes(StandardCharsets.UTF_8)); return; } catch (Exception e) { if (i == 0) { throw e; } logger.warn("Unexpected exception while writing to {}:", path, e); } try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } private static void assert200Ok(CloseableHttpResponse res, @Nullable String expectedContentType, String expectedContent) throws Exception { assert200Ok(res, expectedContentType, content -> assertThat(content).isEqualTo(expectedContent)); } private static void assert200Ok(CloseableHttpResponse res, @Nullable String expectedContentType, Consumer<String> contentAssertions) throws Exception { assertStatusLine(res, "HTTP/1.1 200 OK"); // Ensure that the 'Date' header exists and is well-formed. final String date = headerOrNull(res, HttpHeaders.DATE); assertThat(date).isNotNull(); DateFormatter.parseHttpDate(date); // Ensure that the 'Last-Modified' header exists and is well-formed. final String lastModified = headerOrNull(res, HttpHeaders.LAST_MODIFIED); assertThat(lastModified).isNotNull(); DateFormatter.parseHttpDate(lastModified); // Ensure that the 'ETag' header exists and is well-formed. final String entityTag = headerOrNull(res, HttpHeaders.ETAG); assertThat(entityTag).matches(ETAG_PATTERN); // Ensure the content type is correct. if (expectedContentType != null) { assertThat(headerOrNull(res, HttpHeaders.CONTENT_TYPE)).startsWith(expectedContentType); } else { assertThat(res.containsHeader(HttpHeaders.CONTENT_TYPE)).isFalse(); } // Ensure the content satisfies the condition. contentAssertions.accept(EntityUtils.toString(res.getEntity()).trim()); } private static void assert304NotModified( CloseableHttpClient hc, String baseUri, String path, String expectedETag, String expectedLastModified) throws IOException { final String uri = baseUri + path; // Test if the 'If-None-Match' header works as expected. (a single etag) final HttpUriRequest req1 = new HttpGet(uri); req1.setHeader(HttpHeaders.IF_NONE_MATCH, expectedETag); try (CloseableHttpResponse res = hc.execute(req1)) { assert304NotModified(res, expectedETag, expectedLastModified); } // Test if the 'If-None-Match' header works as expected. (multiple etags) final HttpUriRequest req2 = new HttpGet(uri); req2.setHeader(HttpHeaders.IF_NONE_MATCH, "\"an-etag-that-never-matches\", " + expectedETag); try (CloseableHttpResponse res = hc.execute(req2)) { assert304NotModified(res, expectedETag, expectedLastModified); } // Test if the 'If-None-Match' header works as expected. (an asterisk) final HttpUriRequest req3 = new HttpGet(uri); req3.setHeader(HttpHeaders.IF_NONE_MATCH, "*"); try (CloseableHttpResponse res = hc.execute(req3)) { assert304NotModified(res, expectedETag, expectedLastModified); } // Test if the 'If-Modified-Since' header works as expected. final HttpUriRequest req4 = new HttpGet(uri); req4.setHeader(HttpHeaders.IF_MODIFIED_SINCE, currentHttpDate()); try (CloseableHttpResponse res = hc.execute(req4)) { assert304NotModified(res, expectedETag, expectedLastModified); } // 'If-Modified-Since' should never be evaluated if 'If-None-Match' exists. final HttpUriRequest req5 = new HttpGet(uri); req5.setHeader(HttpHeaders.IF_NONE_MATCH, "\"an-etag-that-never-matches\""); req5.setHeader(HttpHeaders.IF_MODIFIED_SINCE, currentHttpDate()); try (CloseableHttpResponse res = hc.execute(req5)) { // Should not receive '304 Not Modified' because the etag did not match. assertStatusLine(res, "HTTP/1.1 200 OK"); // Read the content fully so that Apache HC does not close the connection prematurely. ByteStreams.exhaust(res.getEntity().getContent()); } } private static void assert304NotModified( CloseableHttpResponse res, String expectedEtag, String expectedLastModified) { assertStatusLine(res, "HTTP/1.1 304 Not Modified"); // Ensure that the 'ETag' header did not change. assertThat(headerOrNull(res, HttpHeaders.ETAG)).isEqualTo(expectedEtag); // Ensure that the 'Last-Modified' header did not change. assertThat(headerOrNull(res, HttpHeaders.LAST_MODIFIED)).isEqualTo(expectedLastModified); // Ensure that the 'Content-Length' header does not exist. assertThat(res.containsHeader(HttpHeaders.CONTENT_LENGTH)).isFalse(); // Ensure that the content does not exist. assertThat(res.getEntity()).isNull(); } private static void assert404NotFound(CloseableHttpResponse res) { assertStatusLine(res, "HTTP/1.1 404 Not Found"); // Ensure that the 'Last-Modified' header does not exist. assertThat(res.getFirstHeader(HttpHeaders.LAST_MODIFIED)).isNull(); } private static void assertStatusLine(CloseableHttpResponse res, String expectedStatusLine) { assertThat(res.getStatusLine().toString()).isEqualTo(expectedStatusLine); } private static String header(CloseableHttpResponse res, String name) { final String value = headerOrNull(res, name); assertThat(value).withFailMessage("The response must contains the header '%s'.", name).isNotNull(); return value; } @Nullable private static String headerOrNull(CloseableHttpResponse res, String name) { if (!res.containsHeader(name)) { return null; } return res.getFirstHeader(name).getValue(); } private static byte[] content(CloseableHttpResponse res) throws IOException { return ByteStreams.toByteArray(res.getEntity().getContent()); } private static String contentString(CloseableHttpResponse res) throws IOException { return new String(ByteStreams.toByteArray(res.getEntity().getContent()), StandardCharsets.UTF_8); } private static String currentHttpDate() { return DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(UTC)); } private static class BaseUriProvider implements ArgumentsProvider { @Override public Stream<? extends Arguments> provideArguments(ExtensionContext context) { return Stream.of(server.httpUri() + "/cached", server.httpUri() + "/uncached").map(Arguments::of); } } }