These tests close the coverage gap left by {@link HttpReceiverStaticResourceTest}: the unit + * tests verify the fix is present (Content-Length set in the mock response), but cannot + * prove the fix resolves the runtime behavior — specifically that Jetty 12 does not prematurely + * commit the response when Content-Length is set. Running against a real Jetty 12 + HTTP/1.1 + * stack proves the full write path behaves correctly for large (100 KB) resources. + * + *
HTTP/1.1 is sufficient: the {@code ERR_HTTP2_PROTOCOL_ERROR} the browser reports is a + * symptom of Jetty sending a malformed HTTP/2 stream when Content-Length is absent. Once + * Content-Length is set correctly on HTTP/1.1, the HTTP/2 path is also fixed because Jetty uses + * Content-Length to correctly signal end-of-stream in both protocols. + * + *
Verified against Jetty 12.0.x.
+ */
+public class HttpReceiverStaticResourceIntegrationTest {
+
+ private static final String LOOPBACK = "127.0.0.1";
+ private static final int LARGE_SIZE = 100 * 1024; // 100 KB — well above the ~30 KB failure threshold
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private HttpReceiver receiver;
+ private Server jettyServer;
+ private ServerConnector serverConnector;
+
+ // -------------------------------------------------------------------------
+ // Class-level setup — Guice / Mockito
+ // -------------------------------------------------------------------------
+
+ @BeforeClass
+ public static void setUpClass() {
+ ControllerFactory controllerFactory = mock(ControllerFactory.class);
+ ConfigurationController configurationController = mock(ConfigurationController.class);
+ when(controllerFactory.createConfigurationController()).thenReturn(configurationController);
+ EventController eventController = mock(EventController.class);
+ when(controllerFactory.createEventController()).thenReturn(eventController);
+ Guice.createInjector(new AbstractModule() {
+ @Override
+ protected void configure() {
+ requestStaticInjection(ControllerFactory.class);
+ bind(ControllerFactory.class).toInstance(controllerFactory);
+ }
+ }).getInstance(ControllerFactory.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // Per-test setup / teardown
+ // -------------------------------------------------------------------------
+
+ @Before
+ public void setUp() throws Exception {
+ receiver = new HttpReceiver();
+ HttpReceiverProperties props = new HttpReceiverProperties();
+ props.setCharset("UTF-8");
+
+ Channel channel = mock(Channel.class);
+ doReturn("test-channel-id").when(channel).getChannelId();
+ doReturn("Test Channel").when(channel).getName();
+ receiver.setChannel(channel);
+ receiver.setConnectorProperties(props);
+
+ Field arrayField = HttpReceiver.class.getDeclaredField("binaryMimeTypesArray");
+ arrayField.setAccessible(true);
+ arrayField.set(receiver, new String[0]);
+
+ Field regexField = HttpReceiver.class.getDeclaredField("binaryMimeTypesRegex");
+ regexField.setAccessible(true);
+ regexField.set(receiver, java.util.regex.Pattern.compile("$^"));
+
+ HttpConfiguration httpConfig = mock(HttpConfiguration.class);
+ when(httpConfig.getRequestInformation(any())).thenReturn(new HashMap<>());
+ Field configField = HttpReceiver.class.getDeclaredField("configuration");
+ configField.setAccessible(true);
+ configField.set(receiver, httpConfig);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (jettyServer != null && jettyServer.isRunning()) {
+ jettyServer.stop();
+ jettyServer = null;
+ }
+ }
+
+ // =========================================================================
+ // GROUP A — ResourceType.CUSTOM (inline string value)
+ // =========================================================================
+
+ @Test
+ public void testCustom_large_contentLengthPresentOnWire() throws Exception {
+ String content = repeat('A', LARGE_SIZE);
+ long expectedLen = content.getBytes(StandardCharsets.UTF_8).length;
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/resource"))) {
+ assertEquals(200, resp.getStatusLine().getStatusCode());
+ Header h = resp.getFirstHeader("Content-Length");
+ assertNotNull("Content-Length must be present for large CUSTOM resource", h);
+ assertEquals(expectedLen, Long.parseLong(h.getValue()));
+ }
+ }
+
+ @Test
+ public void testCustom_large_bodyIntegrity() throws Exception {
+ String content = repeat('A', LARGE_SIZE);
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/resource"))) {
+ byte[] body = IOUtils.toByteArray(resp.getEntity().getContent());
+ assertArrayEquals(content.getBytes(StandardCharsets.UTF_8), body);
+ }
+ }
+
+ @Test
+ public void testCustom_gzip_contentEncodingHeaderPresent() throws Exception {
+ String content = repeat('A', LARGE_SIZE);
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) {
+ assertEquals(200, resp.getStatusLine().getStatusCode());
+ Header h = resp.getFirstHeader("Content-Encoding");
+ assertNotNull("Content-Encoding header must be present for gzip CUSTOM response", h);
+ assertTrue("Content-Encoding must be gzip", h.getValue().contains("gzip"));
+ }
+ }
+
+ @Test
+ public void testCustom_gzip_bodyDecompressesToOriginalContent() throws Exception {
+ String content = repeat('A', LARGE_SIZE);
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) {
+ byte[] compressed = IOUtils.toByteArray(resp.getEntity().getContent());
+ String decompressed;
+ try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
+ decompressed = new String(gzis.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ assertEquals(content, decompressed);
+ }
+ }
+
+ // =========================================================================
+ // GROUP B — ResourceType.FILE
+ // =========================================================================
+
+ @Test
+ public void testFile_large_contentLengthPresentOnWire() throws Exception {
+ byte[] content = randomBytes(LARGE_SIZE);
+ File f = writeTempFile("large.bin", content);
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/resource"))) {
+ assertEquals(200, resp.getStatusLine().getStatusCode());
+ Header h = resp.getFirstHeader("Content-Length");
+ assertNotNull("Content-Length must be present for large FILE resource", h);
+ assertEquals(content.length, Long.parseLong(h.getValue()));
+ }
+ }
+
+ @Test
+ public void testFile_large_bodyIntegrity() throws Exception {
+ byte[] content = randomBytes(LARGE_SIZE);
+ File f = writeTempFile("large-body.bin", content);
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/resource"))) {
+ byte[] body = IOUtils.toByteArray(resp.getEntity().getContent());
+ assertArrayEquals(content, body);
+ }
+ }
+
+ @Test
+ public void testFile_gzip_contentEncodingHeaderPresent() throws Exception {
+ byte[] content = new byte[LARGE_SIZE];
+ Arrays.fill(content, (byte) 'Z');
+ File f = writeTempFile("gzip-check.bin", content);
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) {
+ assertEquals(200, resp.getStatusLine().getStatusCode());
+ Header h = resp.getFirstHeader("Content-Encoding");
+ assertNotNull("Content-Encoding header must be present for gzip FILE response", h);
+ assertTrue("Content-Encoding must be gzip", h.getValue().contains("gzip"));
+ }
+ }
+
+ @Test
+ public void testFile_gzip_bodyDecompressesToOriginalContent() throws Exception {
+ byte[] content = new byte[LARGE_SIZE];
+ Arrays.fill(content, (byte) 'Z');
+ File f = writeTempFile("gzip-body.bin", content);
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) {
+ byte[] compressed = IOUtils.toByteArray(resp.getEntity().getContent());
+ byte[] decompressed;
+ try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
+ decompressed = gzis.readAllBytes();
+ }
+ assertArrayEquals(content, decompressed);
+ }
+ }
+
+ // =========================================================================
+ // GROUP C — Routing: requests outside context path must return 404 (IRT-831)
+ // =========================================================================
+
+ @Test
+ public void testRequestOutsideResourceContextPathReturns404() throws Exception {
+ String content = "hello";
+ int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content,
+ "text/plain", Collections.emptyMap()));
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/other"))) {
+ assertEquals(404, resp.getStatusLine().getStatusCode());
+ }
+ }
+
+ // =========================================================================
+ // GROUP D — ResourceType.DIRECTORY fallthrough to channel handler (IRT-831 fix)
+ // Verifies that StaticResourceHandler does NOT mark the request as handled when
+ // a requested file is absent, allowing the channel context to process it.
+ // =========================================================================
+
+ @Test
+ public void testDirectory_missingFile_fallsThroughToChannelHandler() throws Exception {
+ File dir = tempFolder.newFolder("dir-missing");
+ int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test");
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/test/data/nonexistent.txt"))) {
+ assertEquals("Missing file under DIRECTORY resource must fall through to channel handler",
+ 200, resp.getStatusLine().getStatusCode());
+ assertEquals("channel",
+ IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8));
+ }
+ }
+
+ @Test
+ public void testDirectory_existingFile_isServedByStaticHandler() throws Exception {
+ File dir = tempFolder.newFolder("dir-serve");
+ File file = new File(dir, "hello.txt");
+ try (FileOutputStream fos = new FileOutputStream(file)) {
+ fos.write("static-content".getBytes(StandardCharsets.UTF_8));
+ }
+ int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test");
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/test/data/hello.txt"))) {
+ assertEquals(200, resp.getStatusLine().getStatusCode());
+ assertEquals("static-content",
+ IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8));
+ }
+ }
+
+ @Test
+ public void testDirectory_subdirectoryInPath_fallsThroughToChannelHandler() throws Exception {
+ File dir = tempFolder.newFolder("dir-subpath");
+ int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test");
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/test/data/sub/file.txt"))) {
+ assertEquals("Subdirectory path must fall through to channel handler",
+ 200, resp.getStatusLine().getStatusCode());
+ assertEquals("channel",
+ IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8));
+ }
+ }
+
+ // =========================================================================
+ // GROUP E — CUSTOM and FILE fallthrough to channel handler (IRT-831, commit a200466b0)
+ // Before this fix, only DIRECTORY resources were embedded inside the channel
+ // ContextHandler. CUSTOM and FILE still had their own ContextHandler, so
+ // CoreContextHandler returned true on any prefix match — blocking fallthrough
+ // for sub-paths the StaticResourceHandler could not serve.
+ // =========================================================================
+
+ @Test
+ public void testCustom_subPath_fallsThroughToChannelHandler() throws Exception {
+ String content = "custom-content";
+ int port = startServerWithResourceAndChannel(
+ "/test/data", ResourceType.CUSTOM, content, "/test");
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/test/data/wrong"))) {
+ assertEquals(
+ "Sub-path miss on CUSTOM resource must fall through to channel handler",
+ 200, resp.getStatusLine().getStatusCode());
+ assertEquals("channel",
+ IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8));
+ }
+ }
+
+ @Test
+ public void testFile_subPath_fallsThroughToChannelHandler() throws Exception {
+ byte[] content = "file-content".getBytes(StandardCharsets.UTF_8);
+ File f = writeTempFile("serve.bin", content);
+ int port = startServerWithResourceAndChannel(
+ "/test/data", ResourceType.FILE, f.getAbsolutePath(), "/test");
+
+ try (CloseableHttpClient client = noCompressionClient();
+ CloseableHttpResponse resp = client.execute(get(port, "/test/data/wrong"))) {
+ assertEquals(
+ "Sub-path miss on FILE resource must fall through to channel handler",
+ 200, resp.getStatusLine().getStatusCode());
+ assertEquals("channel",
+ IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8));
+ }
+ }
+
+ // =========================================================================
+ // Helpers — Jetty server setup
+ // =========================================================================
+
+ /**
+ * Starts a real Jetty 12 server on a random loopback port using the IRT-831 handler chain
+ * ({@code ContextHandlerCollection} + {@code DefaultHandler} + {@code Handler.Sequence})
+ * with {@code StaticResourceHandler} for a single static resource.
+ * Uses reflection to instantiate the private inner class.
+ *
+ * @return the local port the server is listening on
+ */
+ private int startServer(HttpStaticResource resource) throws Exception {
+ jettyServer = new Server();
+
+ org.eclipse.jetty.server.HttpConfiguration httpCfg = new org.eclipse.jetty.server.HttpConfiguration();
+ httpCfg.setSendServerVersion(false);
+ serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg));
+ serverConnector.setHost(LOOPBACK);
+ serverConnector.setPort(0); // random free port
+ serverConnector.setIdleTimeout(30_000);
+ jettyServer.addConnector(serverConnector);
+
+ // Reflectively instantiate the private StaticResourceHandler inner class
+ Class> handlerClass = null;
+ for (Class> c : HttpReceiver.class.getDeclaredClasses()) {
+ if ("StaticResourceHandler".equals(c.getSimpleName())) {
+ handlerClass = c;
+ break;
+ }
+ }
+ assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass);
+ Constructor> ctor = handlerClass.getDeclaredConstructor(HttpReceiver.class, HttpStaticResource.class);
+ ctor.setAccessible(true);
+ AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource);
+
+ // Replicate the EE8 handler chain from HttpReceiver.onStart()
+ ContextHandler resourceContext = new ContextHandler();
+ resourceContext.setContextPath(resource.getContextPath());
+ resourceContext.setAllowNullPathInfo(true);
+ resourceContext.setHandler(staticHandler);
+
+ org.eclipse.jetty.server.handler.ContextHandlerCollection coreHandlers =
+ new org.eclipse.jetty.server.handler.ContextHandlerCollection();
+ resourceContext.setServer(jettyServer);
+ coreHandlers.addHandler(resourceContext.getCoreContextHandler());
+
+ org.eclipse.jetty.server.handler.DefaultHandler defaultHandler =
+ new org.eclipse.jetty.server.handler.DefaultHandler();
+ defaultHandler.setServeFavIcon(false);
+
+ jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence(coreHandlers, defaultHandler));
+
+ jettyServer.start();
+ return serverConnector.getLocalPort();
+ }
+
+ /**
+ * Starts a server with a single static resource handler (any {@link ResourceType}) and a
+ * {@link ChannelEchoHandler} embedded inside one channel {@link ContextHandler}, replicating
+ * the architecture from commit a200466b0. Used by GROUP E tests.
+ */
+ private int startServerWithResourceAndChannel(
+ String resourceContextPath, ResourceType resourceType,
+ String resourceValue, String channelContextPath) throws Exception {
+ jettyServer = new Server();
+ org.eclipse.jetty.server.HttpConfiguration httpCfg =
+ new org.eclipse.jetty.server.HttpConfiguration();
+ httpCfg.setSendServerVersion(false);
+ serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg));
+ serverConnector.setHost(LOOPBACK);
+ serverConnector.setPort(0);
+ serverConnector.setIdleTimeout(30_000);
+ jettyServer.addConnector(serverConnector);
+
+ Class> handlerClass = null;
+ for (Class> c : HttpReceiver.class.getDeclaredClasses()) {
+ if ("StaticResourceHandler".equals(c.getSimpleName())) {
+ handlerClass = c;
+ break;
+ }
+ }
+ assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass);
+ Constructor> ctor = handlerClass.getDeclaredConstructor(
+ HttpReceiver.class, HttpStaticResource.class);
+ ctor.setAccessible(true);
+ HttpStaticResource resource = new HttpStaticResource(
+ resourceContextPath, resourceType,
+ resourceValue, "text/plain", Collections.emptyMap());
+ AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource);
+
+ HandlerCollection innerChain = new HandlerCollection() {
+ @Override
+ public void handle(String target, Request baseRequest,
+ HttpServletRequest request, HttpServletResponse response)
+ throws IOException, ServletException {
+ for (org.eclipse.jetty.ee8.nested.Handler h : getHandlers()) {
+ h.handle(target, baseRequest, request, response);
+ if (baseRequest.isHandled()) {
+ return;
+ }
+ }
+ }
+ };
+ innerChain.addHandler(staticHandler);
+ innerChain.addHandler(new ChannelEchoHandler());
+
+ ContextHandler channelCtx = new ContextHandler();
+ channelCtx.setContextPath(channelContextPath);
+ channelCtx.setAllowNullPathInfo(true);
+ channelCtx.setHandler(innerChain);
+ channelCtx.setServer(jettyServer);
+
+ org.eclipse.jetty.server.handler.DefaultHandler defaultHandler =
+ new org.eclipse.jetty.server.handler.DefaultHandler();
+ defaultHandler.setServeFavIcon(false);
+
+ jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence(
+ channelCtx.getCoreContextHandler(), defaultHandler));
+
+ jettyServer.start();
+ return serverConnector.getLocalPort();
+ }
+
+ // =========================================================================
+ // Helpers — HTTP client
+ // =========================================================================
+
+ /**
+ * Returns a client with content compression disabled — no automatic {@code Accept-Encoding}
+ * header is added and gzip responses are NOT auto-decompressed. Required so that tests can
+ * inspect the raw {@code Content-Length} and {@code Content-Encoding} response headers.
+ */
+ private static CloseableHttpClient noCompressionClient() {
+ return HttpClients.custom().disableContentCompression().build();
+ }
+
+ private static HttpGet get(int port, String path) {
+ return new HttpGet("http://" + LOOPBACK + ":" + port + path);
+ }
+
+ private static HttpGet getGzip(int port, String path) {
+ HttpGet request = new HttpGet("http://" + LOOPBACK + ":" + port + path);
+ request.setHeader("Accept-Encoding", "gzip");
+ return request;
+ }
+
+ // =========================================================================
+ // Helpers — data generation and file I/O
+ // =========================================================================
+
+ private static byte[] randomBytes(int size) {
+ byte[] buf = new byte[size];
+ new Random().nextBytes(buf);
+ return buf;
+ }
+
+ private File writeTempFile(String name, byte[] content) throws Exception {
+ File f = tempFolder.newFile(name);
+ try (FileOutputStream fos = new FileOutputStream(f)) {
+ fos.write(content);
+ }
+ return f;
+ }
+
+ private static String repeat(char c, int count) {
+ char[] buf = new char[count];
+ Arrays.fill(buf, c);
+ return new String(buf);
+ }
+
+ /**
+ * Starts a server with a DIRECTORY {@link StaticResourceHandler} at {@code resourceContextPath}
+ * and a simple channel echo handler at {@code channelContextPath}. Used by GROUP D tests to
+ * verify that unanswered requests fall through from the resource context to the channel.
+ */
+ private int startServerWithDirectoryAndChannel(
+ String resourceContextPath, File dir, String channelContextPath) throws Exception {
+ jettyServer = new Server();
+ org.eclipse.jetty.server.HttpConfiguration httpCfg =
+ new org.eclipse.jetty.server.HttpConfiguration();
+ httpCfg.setSendServerVersion(false);
+ serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg));
+ serverConnector.setHost(LOOPBACK);
+ serverConnector.setPort(0);
+ serverConnector.setIdleTimeout(30_000);
+ jettyServer.addConnector(serverConnector);
+
+ Class> handlerClass = null;
+ for (Class> c : HttpReceiver.class.getDeclaredClasses()) {
+ if ("StaticResourceHandler".equals(c.getSimpleName())) {
+ handlerClass = c;
+ break;
+ }
+ }
+ assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass);
+ Constructor> ctor = handlerClass.getDeclaredConstructor(
+ HttpReceiver.class, HttpStaticResource.class);
+ ctor.setAccessible(true);
+ HttpStaticResource resource = new HttpStaticResource(
+ resourceContextPath, ResourceType.DIRECTORY,
+ dir.getAbsolutePath(), "application/octet-stream", Collections.emptyMap());
+ AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource);
+
+ // Both handlers live inside ONE channel ContextHandler with a stop-on-first-handled
+ // HandlerCollection. Separate contexts don't work: the EE8-to-core bridge
+ // (CoreContextHandler) returns true once the context path matches, even when the
+ // inner EE8 handler never sets isHandled(). Subclassing HandlerCollection (rather
+ // than using an anonymous AbstractHandler) ensures Jetty's lifecycle calls
+ // (setServer, start) propagate through addHandler() to both inner handlers.
+ HandlerCollection innerChain = new HandlerCollection() {
+ @Override
+ public void handle(String target, Request baseRequest,
+ HttpServletRequest request, HttpServletResponse response)
+ throws IOException, ServletException {
+ for (org.eclipse.jetty.ee8.nested.Handler h : getHandlers()) {
+ h.handle(target, baseRequest, request, response);
+ if (baseRequest.isHandled()) {
+ return;
+ }
+ }
+ }
+ };
+ innerChain.addHandler(staticHandler);
+ innerChain.addHandler(new ChannelEchoHandler());
+
+ ContextHandler channelCtx = new ContextHandler();
+ channelCtx.setContextPath(channelContextPath);
+ channelCtx.setAllowNullPathInfo(true);
+ channelCtx.setHandler(innerChain);
+ channelCtx.setServer(jettyServer);
+
+ org.eclipse.jetty.server.handler.DefaultHandler defaultHandler =
+ new org.eclipse.jetty.server.handler.DefaultHandler();
+ defaultHandler.setServeFavIcon(false);
+
+ jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence(
+ channelCtx.getCoreContextHandler(),
+ defaultHandler));
+
+ jettyServer.start();
+ return serverConnector.getLocalPort();
+ }
+
+ private static class ChannelEchoHandler extends AbstractHandler {
+ @Override
+ public void handle(String target, Request baseRequest,
+ HttpServletRequest request, HttpServletResponse response)
+ throws IOException, ServletException {
+ baseRequest.setHandled(true);
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.getWriter().write("channel");
+ }
+ }
+}
diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java
new file mode 100644
index 000000000..54f174466
--- /dev/null
+++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java
@@ -0,0 +1,521 @@
+package com.mirth.connect.connectors.http;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Random;
+import java.util.zip.GZIPInputStream;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.eclipse.jetty.ee8.nested.Request;
+import org.eclipse.jetty.http.HttpURI;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+
+import com.mirth.connect.connectors.http.HttpStaticResource.ResourceType;
+import com.mirth.connect.donkey.server.channel.Channel;
+import com.mirth.connect.server.controllers.ConfigurationController;
+import com.mirth.connect.server.controllers.ControllerFactory;
+import com.mirth.connect.server.controllers.EventController;
+
+public class HttpReceiverStaticResourceTest {
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private HttpReceiver receiver;
+ private HttpReceiverProperties props;
+
+ @BeforeClass
+ public static void setupBeforeClass() {
+ ControllerFactory controllerFactory = mock(ControllerFactory.class);
+ ConfigurationController configurationController = mock(ConfigurationController.class);
+ when(controllerFactory.createConfigurationController()).thenReturn(configurationController);
+ EventController eventController = mock(EventController.class);
+ when(controllerFactory.createEventController()).thenReturn(eventController);
+ Guice.createInjector(new AbstractModule() {
+ @Override
+ protected void configure() {
+ requestStaticInjection(ControllerFactory.class);
+ bind(ControllerFactory.class).toInstance(controllerFactory);
+ }
+ }).getInstance(ControllerFactory.class);
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ receiver = new HttpReceiver();
+ props = new HttpReceiverProperties();
+ props.setCharset("UTF-8");
+
+ Channel channel = mock(Channel.class);
+ doReturn("test-channel-id").when(channel).getChannelId();
+ receiver.setChannel(channel);
+ receiver.setConnectorProperties(props);
+
+ // Required to avoid NPE in createRequestMessage's binary content type check
+ java.lang.reflect.Field arrayField = HttpReceiver.class.getDeclaredField("binaryMimeTypesArray");
+ arrayField.setAccessible(true);
+ arrayField.set(receiver, new String[0]);
+ java.lang.reflect.Field regexField = HttpReceiver.class.getDeclaredField("binaryMimeTypesRegex");
+ regexField.setAccessible(true);
+ regexField.set(receiver, java.util.regex.Pattern.compile("$^"));
+
+ // configuration is null until onDeploy(); inject a minimal mock
+ HttpConfiguration httpConfig = mock(HttpConfiguration.class);
+ when(httpConfig.getRequestInformation(any())).thenReturn(new HashMap<>());
+ java.lang.reflect.Field configField = HttpReceiver.class.getDeclaredField("configuration");
+ configField.setAccessible(true);
+ configField.set(receiver, httpConfig);
+ }
+
+ // =========================================================================
+ // Infrastructure helpers
+ // =========================================================================
+
+ /**
+ * Reflectively invokes StaticResourceHandler.handle() without starting a real Jetty server.
+ * Unwraps InvocationTargetException so callers see the real exception if one escapes.
+ */
+ private void invokeHandler(HttpStaticResource resource, Request baseRequest,
+ HttpServletResponse response) throws Exception {
+ Class> cls = null;
+ for (Class> c : HttpReceiver.class.getDeclaredClasses()) {
+ if ("StaticResourceHandler".equals(c.getSimpleName())) {
+ cls = c;
+ break;
+ }
+ }
+ assertNotNull("StaticResourceHandler inner class not found via reflection", cls);
+
+ Constructor> ctor = cls.getDeclaredConstructor(HttpReceiver.class, HttpStaticResource.class);
+ ctor.setAccessible(true);
+ Object handler = ctor.newInstance(receiver, resource);
+
+ Method handle = cls.getMethod("handle", String.class, Request.class,
+ HttpServletRequest.class, HttpServletResponse.class);
+ try {
+ // target and servletRequest are unused inside the handler
+ handle.invoke(handler, null, baseRequest, null, response);
+ } catch (InvocationTargetException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof RuntimeException) throw (RuntimeException) cause;
+ if (cause instanceof Exception) throw (Exception) cause;
+ throw new RuntimeException(cause);
+ }
+ }
+
+ /**
+ * Builds a mocked Jetty Request for a GET at the given context path.
+ * Pass acceptGzip=true to include an Accept-Encoding: gzip header.
+ */
+ private Request createGetRequest(String contextPath, boolean acceptGzip) {
+ Request req = mock(Request.class);
+ when(req.getMethod()).thenReturn("GET");
+ when(req.getRemoteAddr()).thenReturn("127.0.0.1");
+ when(req.getRemotePort()).thenReturn(12345);
+ when(req.getLocalAddr()).thenReturn("127.0.0.1");
+ when(req.getLocalPort()).thenReturn(8080);
+ when(req.getQueryString()).thenReturn("");
+ when(req.getRequestURL()).thenReturn(new StringBuffer("http://localhost" + contextPath));
+ when(req.getContentType()).thenReturn(null);
+ when(req.getCharacterEncoding()).thenReturn("UTF-8");
+ when(req.getParameterMap()).thenReturn(new HashMap<>());
+ when(req.getProtocol()).thenReturn("HTTP/1.1");
+
+ HttpURI httpURI = mock(HttpURI.class);
+ when(httpURI.getPathQuery()).thenReturn(contextPath);
+ when(req.getHttpURI()).thenReturn(httpURI);
+
+ if (acceptGzip) {
+ when(req.getHeaderNames()).thenReturn(
+ Collections.enumeration(Collections.singletonList("Accept-Encoding")));
+ when(req.getHeaders("Accept-Encoding")).thenReturn(
+ Collections.enumeration(Collections.singletonList("gzip")));
+ } else {
+ when(req.getHeaderNames()).thenReturn(
+ Collections.enumeration(Collections.emptyList()));
+ }
+
+ return req;
+ }
+
+ // =========================================================================
+ // GROUP A — ResourceType.CUSTOM (inline string value)
+ // =========================================================================
+
+ @Test
+ public void testCustomResource_smallString_setsContentLength() throws Exception {
+ String value = "Hello, static world!";
+ int expectedLen = value.getBytes(StandardCharsets.UTF_8).length;
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertEquals(expectedLen, response.capturedContentLength);
+ }
+
+ @Test
+ public void testCustomResource_largeString_setsContentLength() throws Exception {
+ // 40 KB — the regression size that triggered ERR_HTTP2_PROTOCOL_ERROR
+ String value = "A".repeat(40 * 1024);
+ int expectedLen = value.getBytes(StandardCharsets.UTF_8).length;
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertEquals(expectedLen, response.capturedContentLength);
+ }
+
+ @Test
+ public void testCustomResource_bodyBytesMatchContentLength() throws Exception {
+ String value = "Body content for length verification";
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertEquals(response.capturedContentLength, response.outputStream.size());
+ }
+
+ @Test
+ public void testCustomResource_bodyContentIsCorrect() throws Exception {
+ String value = "Expected body content";
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertEquals(value, response.outputStream.toString("UTF-8"));
+ }
+
+ @Test
+ public void testCustomResource_gzip_contentLengthNotSet() throws Exception {
+ String value = "Gzipped static content";
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", true), response);
+
+ assertEquals(-1, response.capturedContentLength);
+ }
+
+ @Test
+ public void testCustomResource_gzip_bodyIsValidGzip() throws Exception {
+ String value = "Gzipped static content";
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", true), response);
+
+ byte[] compressed = response.outputStream.toByteArray();
+ String decompressed;
+ try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
+ decompressed = new String(gzis.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ assertEquals(value, decompressed);
+ }
+
+ @Test
+ public void testCustomResource_nonGetMethod_noBodyWritten() throws Exception {
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, "value", "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ Request postRequest = mock(Request.class);
+ when(postRequest.getMethod()).thenReturn("POST");
+
+ invokeHandler(resource, postRequest, response);
+
+ assertEquals(0, response.outputStream.size());
+ assertEquals(-1, response.capturedContentLength);
+ }
+
+ @Test
+ public void testCustomResource_contentTypeIsSet() throws Exception {
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, "js content", "application/javascript", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertTrue(response.contentTypeValue.contains("application/javascript"));
+ }
+
+ @Test
+ public void testCustomResource_invalidCharset_fallsBackToTextPlain() throws Exception {
+ // An unrecognized charset causes UnsupportedCharsetException in ContentType.parse(),
+ // which the handler catches and falls back to text/plain with the connector's default charset.
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.CUSTOM, "value",
+ "text/html; charset=TOTALLY-INVALID-CHARSET-XYZ", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertTrue(response.contentTypeValue.contains("text/plain"));
+ }
+
+ // =========================================================================
+ // GROUP B — ResourceType.FILE
+ // =========================================================================
+
+ @Test
+ public void testFileResource_smallFile_setsContentLength() throws Exception {
+ File f = tempFolder.newFile("small.txt");
+ byte[] content = "small file content".getBytes(StandardCharsets.UTF_8);
+ try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); }
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertEquals(f.length(), response.capturedContentLength);
+ }
+
+ @Test
+ public void testFileResource_largeFile_setsContentLength() throws Exception {
+ // 40 KB — the regression size
+ File f = tempFolder.newFile("large.bin");
+ byte[] content = new byte[40 * 1024];
+ new Random().nextBytes(content);
+ try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); }
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertEquals(f.length(), response.capturedContentLength);
+ }
+
+ @Test
+ public void testFileResource_bodyMatchesFileContent() throws Exception {
+ File f = tempFolder.newFile("content.txt");
+ byte[] content = "exact file bytes".getBytes(StandardCharsets.UTF_8);
+ try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); }
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertArrayEquals(content, response.outputStream.toByteArray());
+ }
+
+ @Test
+ public void testFileResource_gzip_contentLengthNotSet() throws Exception {
+ File f = tempFolder.newFile("gzip.txt");
+ try (FileOutputStream fos = new FileOutputStream(f)) { fos.write("gzip test".getBytes()); }
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", true), response);
+
+ assertEquals(-1, response.capturedContentLength);
+ }
+
+ @Test
+ public void testFileResource_gzip_bodyDecompressesToFileContent() throws Exception {
+ File f = tempFolder.newFile("gzip_content.txt");
+ byte[] content = "file content for gzip".getBytes(StandardCharsets.UTF_8);
+ try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); }
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", true), response);
+
+ byte[] compressed = response.outputStream.toByteArray();
+ byte[] decompressed;
+ try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
+ decompressed = gzis.readAllBytes();
+ }
+ assertArrayEquals(content, decompressed);
+ }
+
+ // =========================================================================
+ // GROUP C — ResourceType.DIRECTORY
+ // =========================================================================
+
+ @Test
+ public void testDirectoryResource_validFile_setsContentLength() throws Exception {
+ File dir = tempFolder.newFolder("static");
+ File f = new File(dir, "test.js");
+ byte[] content = new byte[1024];
+ new Random().nextBytes(content);
+ try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); }
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/static", ResourceType.DIRECTORY, dir.getAbsolutePath(), "application/javascript", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/static/test.js", false), response);
+
+ assertEquals(f.length(), response.capturedContentLength);
+ }
+
+ @Test
+ public void testDirectoryResource_validFile_bodyMatchesContent() throws Exception {
+ File dir = tempFolder.newFolder("staticbody");
+ File f = new File(dir, "data.txt");
+ byte[] content = "directory file content".getBytes(StandardCharsets.UTF_8);
+ try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); }
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/staticbody", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/staticbody/data.txt", false), response);
+
+ assertArrayEquals(content, response.outputStream.toByteArray());
+ }
+
+ @Test
+ public void testDirectoryResource_subdirectoryRequest_resetsResponse() throws Exception {
+ File dir = tempFolder.newFolder("staticdir");
+ new File(dir, "sub").mkdir();
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/staticdir", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap());
+ ResettableResponse response = new ResettableResponse();
+
+ // childPath "sub/file.txt" contains "/" — triggers the subdirectory guard
+ invokeHandler(resource, createGetRequest("/staticdir/sub/file.txt", false), response);
+
+ assertTrue(response.resetCalled);
+ assertEquals(0, response.outputStream.size());
+ }
+
+ @Test
+ public void testDirectoryResource_missingFile_resetsResponse() throws Exception {
+ File dir = tempFolder.newFolder("staticmissing");
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/staticmissing", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap());
+ ResettableResponse response = new ResettableResponse();
+
+ invokeHandler(resource, createGetRequest("/staticmissing/nonexistent.js", false), response);
+
+ assertTrue(response.resetCalled);
+ assertEquals(0, response.outputStream.size());
+ }
+
+ @Test
+ public void testDirectoryResource_directoryRequestedAsFile_resetsResponse() throws Exception {
+ File dir = tempFolder.newFolder("staticdirfile");
+ new File(dir, "subdir").mkdir();
+
+ HttpStaticResource resource = new HttpStaticResource(
+ "/staticdirfile", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap());
+ ResettableResponse response = new ResettableResponse();
+
+ // "subdir" exists but is itself a directory — should reset
+ invokeHandler(resource, createGetRequest("/staticdirfile/subdir", false), response);
+
+ assertTrue(response.resetCalled);
+ assertEquals(0, response.outputStream.size());
+ }
+
+ // =========================================================================
+ // GROUP D — Error path / committed response
+ // =========================================================================
+
+ @Test
+ public void testStaticResource_committedResponse_swallowsIllegalStateException() throws Exception {
+ // Non-existent file triggers FileNotFoundException in the handler catch block.
+ // The catch block calls reset() — on a committed response that throws ISE.
+ // The fix wraps this in try/catch so the ISE is swallowed rather than propagated.
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.FILE,
+ "/this/path/does/not/exist/anywhere.txt", "text/plain", Collections.emptyMap());
+ CommittedResponse response = new CommittedResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response); // must not throw
+ }
+
+ @Test
+ public void testStaticResource_error_returns500WithStackTrace() throws Exception {
+ HttpStaticResource resource = new HttpStaticResource(
+ "/resource", ResourceType.FILE,
+ "/this/path/does/not/exist/anywhere.txt", "text/plain", Collections.emptyMap());
+ ContentLengthCapturingResponse response = new ContentLengthCapturingResponse();
+
+ invokeHandler(resource, createGetRequest("/resource", false), response);
+
+ assertEquals(500, response.statusCode);
+ String body = response.outputStream.toString("UTF-8");
+ assertTrue("Expected stack trace to mention file not found",
+ body.contains("FileNotFoundException") || body.contains("NoSuchFileException"));
+ }
+
+ // =========================================================================
+ // Helper response implementations
+ // =========================================================================
+
+ /** Captures the value passed to setContentLength / setContentLengthLong. */
+ static class ContentLengthCapturingResponse extends HttpReceiverTest.TestHttpServletResponse {
+ long capturedContentLength = -1;
+
+ @Override public void setContentLength(int len) { capturedContentLength = len; }
+ @Override public void setContentLengthLong(long len) { capturedContentLength = len; }
+ }
+
+ /** Tracks whether reset() was called; clears the output stream on reset. */
+ static class ResettableResponse extends ContentLengthCapturingResponse {
+ boolean resetCalled = false;
+
+ @Override
+ public void reset() {
+ resetCalled = true;
+ outputStream.reset();
+ }
+ }
+
+ /** Simulates a Jetty-committed response: reset() throws IllegalStateException. */
+ static class CommittedResponse extends ContentLengthCapturingResponse {
+ @Override public boolean isCommitted() { return true; }
+ @Override public void reset() { throw new IllegalStateException("Response already committed"); }
+ }
+}
diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java
index 1fa1730b7..eb8ce145b 100644
--- a/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java
+++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java
@@ -1,5 +1,6 @@
package com.mirth.connect.connectors.http;
+import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
@@ -987,6 +988,68 @@ public void testSendErrorResponseWithDispatchResult() throws Exception {
assertTrue(dr.isAttemptedResponse());
}
+ // ========== IRT-832: Content-Length tests ==========
+
+ @Test
+ public void testSendResponseNonGzipSetsContentLength() throws Exception {
+ TestHttpServletResponse servletResponse = new TestHttpServletResponse();
+ Request baseRequest = mock(Request.class);
+ when(baseRequest.getHeaders("Accept-Encoding")).thenReturn(Collections.enumeration(Collections.emptyList()));
+
+ props.setResponseDataTypeBinary(false);
+ props.setCharset("UTF-8");
+ props.setResponseStatusCode("200");
+
+ String body = "Hello Content-Length";
+ byte[] expectedBytes = body.getBytes(StandardCharsets.UTF_8);
+
+ Response selectedResponse = mock(Response.class);
+ when(selectedResponse.getMessage()).thenReturn(body);
+ when(selectedResponse.getStatus()).thenReturn(Status.SENT);
+ DispatchResult dr = new TestDispatchResult(1L, message, selectedResponse, true, true);
+
+ receiver.sendResponse(baseRequest, servletResponse, dr);
+
+ assertEquals("Content-Length must match body byte length", expectedBytes.length, servletResponse.contentLength);
+ assertArrayEquals(expectedBytes, servletResponse.outputStream.toByteArray());
+ }
+
+ @Test
+ public void testSendResponseGzipDoesNotSetContentLength() throws Exception {
+ TestHttpServletResponse servletResponse = new TestHttpServletResponse();
+ Request baseRequest = mock(Request.class);
+ Vector