From 37a2326a6bb617da8fe9668b1d55dbb94dce4edd Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 7 Jul 2026 02:55:26 -0600 Subject: [PATCH 1/2] Always set Content-Length on Netty responses toNettyResponse omitted Content-Length for bodyless responses (307 redirects, etc.). Without a Content-Length, a keep-alive HTTP/1.1 client cannot tell the response is complete and stalls until the idle timeout closes the connection. Always emit the length (0 when empty). Ktor and JDK already did this; Netty was the outlier. Surfaced by the new cross-engine conformance suite: the trailing-slash 307 redirect test took 120s on Netty (idle timeout) versus <1s on the other engines. Co-Authored-By: Claude Fable 5 (cherry picked from commit 1f76395d0d3145eeba887a9265f5504080d9c479) (cherry picked from commit b05e864d918fa99dd5a45f007f9a399118f89bd4) --- .../lightningserver/engine/netty/NettyEngine.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/engine-netty/src/main/kotlin/com/lightningkite/lightningserver/engine/netty/NettyEngine.kt b/engine-netty/src/main/kotlin/com/lightningkite/lightningserver/engine/netty/NettyEngine.kt index 2a15cebde..892229462 100644 --- a/engine-netty/src/main/kotlin/com/lightningkite/lightningserver/engine/netty/NettyEngine.kt +++ b/engine-netty/src/main/kotlin/com/lightningkite/lightningserver/engine/netty/NettyEngine.kt @@ -690,9 +690,11 @@ public class NettyEngine( this.body?.mediaType?.let { mt -> res.headers()[CONTENT_TYPE] = mt.toString() } - if (contentBuf !== Unpooled.EMPTY_BUFFER) { - res.headers()[CONTENT_LENGTH] = contentBuf.readableBytes().toString() - } + // Always advertise the body length, including 0 for bodyless responses (redirects, etc.). + // Without a Content-Length (or 0) a keep-alive HTTP/1.1 client cannot tell the response is + // complete and stalls until the idle timeout closes the connection — a cross-engine + // conformance defect caught by EngineHttpConformanceSuite.trailing_slash_redirects_307. + res.headers()[CONTENT_LENGTH] = contentBuf.readableBytes().toString() return res } From d8cf104be8e8829c6d6bed8a9ab6414ca20c003d Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 7 Jul 2026 02:55:40 -0600 Subject: [PATCH 2/2] Add shared cross-engine HTTP conformance suite The Ktor, Netty, and JDK engines each hand-roll request/response translation with no shared test asserting they behave identically against the expectations.md contract. Add one reusable suite in engine-local test fixtures (EngineHttpConformanceSuite) that each engine runs via a ~30-line subclass supplying only how to start itself; the shared HTTP client is the JDK's java.net.http.HttpClient so no new test deps are needed. Checks (all pass on all three engines): nosniff on success and error responses, HEAD fallback, trailing-slash 307 with Location, CORS origin reflection, OPTIONS preflight, HSTS absent over http, timeout 408, and oversized-body 413 (first 413 coverage for Netty). Known gaps are asserted honestly rather than faked: expectations.md's static OPTIONS headers (Allow/Accept-Post/Accept-Patch/Accept-Ranges) and Range/Accept-Ranges are not implemented at the engine level and are documented in the suite as unmet expectations, not passing assertions. Co-Authored-By: Claude Fable 5 (cherry picked from commit 0a4811b683f47b1d5e0054c6d5ed7521b7ad3d2c) (cherry picked from commit af9221dbde861d5156de9e4a6fda7020694ac61c) --- engine-jdk-server/build.gradle.kts | 1 + .../engine/jdk/JdkHttpConformanceTest.kt | 49 +++ engine-ktor/build.gradle.kts | 1 + .../engine/ktor/KtorHttpConformanceTest.kt | 48 +++ engine-local/build.gradle.kts | 6 + .../conformance/EngineHttpConformanceSuite.kt | 317 ++++++++++++++++++ engine-netty/build.gradle.kts | 1 + .../engine/netty/NettyHttpConformanceTest.kt | 58 ++++ 8 files changed, 481 insertions(+) create mode 100644 engine-jdk-server/src/test/kotlin/com/lightningkite/lightningserver/engine/jdk/JdkHttpConformanceTest.kt create mode 100644 engine-ktor/src/test/kotlin/com/lightningkite/lightningserver/engine/ktor/KtorHttpConformanceTest.kt create mode 100644 engine-local/src/testFixtures/kotlin/com/lightningkite/lightningserver/engine/conformance/EngineHttpConformanceSuite.kt create mode 100644 engine-netty/src/test/kotlin/com/lightningkite/lightningserver/engine/netty/NettyHttpConformanceTest.kt diff --git a/engine-jdk-server/build.gradle.kts b/engine-jdk-server/build.gradle.kts index 563574558..75ca7dc05 100644 --- a/engine-jdk-server/build.gradle.kts +++ b/engine-jdk-server/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(libs.kotlin.reflect) testImplementation(libs.kotlin.test) testImplementation(libs.kotlin.test.junit) + testImplementation(testFixtures(project(":engine-local"))) testImplementation(libs.okhttp) testImplementation(libs.openTelemetry.sdk.testing) } diff --git a/engine-jdk-server/src/test/kotlin/com/lightningkite/lightningserver/engine/jdk/JdkHttpConformanceTest.kt b/engine-jdk-server/src/test/kotlin/com/lightningkite/lightningserver/engine/jdk/JdkHttpConformanceTest.kt new file mode 100644 index 000000000..abf6917f6 --- /dev/null +++ b/engine-jdk-server/src/test/kotlin/com/lightningkite/lightningserver/engine/jdk/JdkHttpConformanceTest.kt @@ -0,0 +1,49 @@ +package com.lightningkite.lightningserver.engine.jdk + +import com.lightningkite.lightningserver.definition.generalSettings +import com.lightningkite.lightningserver.definition.loggingSettings +import com.lightningkite.lightningserver.definition.secretBasis +import com.lightningkite.lightningserver.definition.telemetrySettings +import com.lightningkite.lightningserver.engine.conformance.EngineHttpConformanceSuite +import com.lightningkite.lightningserver.engine.local.EngineReliabilitySettings +import com.lightningkite.lightningserver.engine.local.engineCache +import com.lightningkite.lightningserver.engine.local.enginePubSub +import com.lightningkite.lightningserver.engine.local.forceWebSocketPubSub +import com.lightningkite.lightningserver.settings.set +import com.lightningkite.services.data.DataSize.Companion.bytes +import kotlin.time.Duration.Companion.seconds + +/** + * Runs the shared cross-engine HTTP conformance suite against the JDK HttpServer engine. + * See [EngineHttpConformanceSuite] for the behaviors asserted. + */ +class JdkHttpConformanceTest : EngineHttpConformanceSuite() { + override fun startEngine(port: Int, maxBodySize: Long): RunningEngine { + val engine = JdkEngine(conformanceDefinition()) + engine.settings.run { + generalSettings.useDefault() + secretBasis.useDefault() + loggingSettings.useDefault() + telemetrySettings.useDefault() + enginePubSub.useDefault() + engineCache.useDefault() + forceWebSocketPubSub.useDefault() + applyConformanceAppDefaults() + jdkRunConfig set JdkRuntimeSettings( + host = "127.0.0.1", + port = port, + reliability = EngineReliabilitySettings( + maxBodySize = maxBodySize.bytes, + workerThreads = 4, + shutdownDrainTimeout = 1.seconds, // keep close() fast + ), + ) + } + engine.start() // non-blocking: binds and returns + awaitBound(port) + return object : RunningEngine { + override val port: Int = port + override fun close() { engine.shutdown() } + } + } +} diff --git a/engine-ktor/build.gradle.kts b/engine-ktor/build.gradle.kts index 9dc0fb6fc..d1db55890 100644 --- a/engine-ktor/build.gradle.kts +++ b/engine-ktor/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { // Test dependencies testImplementation(libs.kotlin.test) testImplementation(libs.kotlin.test.junit) + testImplementation(testFixtures(project(":engine-local"))) testImplementation(libs.ktor.test.host) testImplementation(libs.ktor.client.cio.jvm) testImplementation(libs.ktor.client.websockets.jvm) diff --git a/engine-ktor/src/test/kotlin/com/lightningkite/lightningserver/engine/ktor/KtorHttpConformanceTest.kt b/engine-ktor/src/test/kotlin/com/lightningkite/lightningserver/engine/ktor/KtorHttpConformanceTest.kt new file mode 100644 index 000000000..f3cf22ffc --- /dev/null +++ b/engine-ktor/src/test/kotlin/com/lightningkite/lightningserver/engine/ktor/KtorHttpConformanceTest.kt @@ -0,0 +1,48 @@ +package com.lightningkite.lightningserver.engine.ktor + +import com.lightningkite.lightningserver.definition.generalSettings +import com.lightningkite.lightningserver.definition.loggingSettings +import com.lightningkite.lightningserver.definition.secretBasis +import com.lightningkite.lightningserver.definition.telemetrySettings +import com.lightningkite.lightningserver.engine.conformance.EngineHttpConformanceSuite +import com.lightningkite.lightningserver.engine.local.EngineReliabilitySettings +import com.lightningkite.lightningserver.engine.local.engineCache +import com.lightningkite.lightningserver.engine.local.enginePubSub +import com.lightningkite.lightningserver.engine.local.forceWebSocketPubSub +import com.lightningkite.lightningserver.settings.set +import com.lightningkite.services.data.DataSize.Companion.bytes +import io.ktor.server.cio.CIO as ServerCIO +import kotlin.concurrent.thread + +/** + * Runs the shared cross-engine HTTP conformance suite against the Ktor (CIO) engine. + * See [EngineHttpConformanceSuite] for the behaviors asserted. + */ +class KtorHttpConformanceTest : EngineHttpConformanceSuite() { + override fun startEngine(port: Int, maxBodySize: Long): RunningEngine { + val engine = KtorEngine(conformanceDefinition()) + engine.settings.run { + generalSettings.useDefault() + secretBasis.useDefault() + loggingSettings.useDefault() + telemetrySettings.useDefault() + enginePubSub.useDefault() + engineCache.useDefault() + forceWebSocketPubSub.useDefault() + applyConformanceAppDefaults() + ktorRunConfig set KtorRuntimeSettings( + host = "127.0.0.1", + port = port, + reliability = EngineReliabilitySettings(maxBodySize = maxBodySize.bytes), + ) + } + // KtorEngine.start(factory) blocks (wait = true), so it runs on a daemon thread; interrupting it + // on close mirrors the existing KtorReliabilityTest teardown. + val serverThread = thread(start = true, isDaemon = true) { engine.start(ServerCIO) } + awaitBound(port) + return object : RunningEngine { + override val port: Int = port + override fun close() { serverThread.interrupt() } + } + } +} diff --git a/engine-local/build.gradle.kts b/engine-local/build.gradle.kts index 8839023e0..84a7d028b 100644 --- a/engine-local/build.gradle.kts +++ b/engine-local/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.dokka) id("signing") + `java-test-fixtures` alias(libs.plugins.vanniktechMavenPublish) } @@ -16,6 +17,11 @@ dependencies { api(libs.kotlin.reflect) testImplementation(libs.kotlin.test) testImplementation(libs.kotlin.test.junit) + + // The cross-engine HTTP conformance suite lives in test fixtures so every engine module's test + // source set can run the identical assertions against its own engine (see EngineHttpConformanceSuite). + testFixturesApi(libs.kotlin.test) + testFixturesApi(libs.kotlin.test.junit) } diff --git a/engine-local/src/testFixtures/kotlin/com/lightningkite/lightningserver/engine/conformance/EngineHttpConformanceSuite.kt b/engine-local/src/testFixtures/kotlin/com/lightningkite/lightningserver/engine/conformance/EngineHttpConformanceSuite.kt new file mode 100644 index 000000000..978d618c9 --- /dev/null +++ b/engine-local/src/testFixtures/kotlin/com/lightningkite/lightningserver/engine/conformance/EngineHttpConformanceSuite.kt @@ -0,0 +1,317 @@ +package com.lightningkite.lightningserver.engine.conformance + +import com.lightningkite.lightningserver.cors.CorsInterceptor +import com.lightningkite.lightningserver.cors.CorsSettings +import com.lightningkite.lightningserver.definition.ServerDefinition +import com.lightningkite.lightningserver.definition.builder.ServerBuilder +import com.lightningkite.lightningserver.http.HttpHandler +import com.lightningkite.lightningserver.http.HttpResponse +import com.lightningkite.lightningserver.http.HttpStatus +import com.lightningkite.lightningserver.http.SecurityHeadersInterceptor +import com.lightningkite.lightningserver.http.get +import com.lightningkite.lightningserver.http.post +import com.lightningkite.lightningserver.pathing.PathSpec0 +import com.lightningkite.lightningserver.plainText +import com.lightningkite.lightningserver.serialization.registerBasicMediaTypeCoders +import com.lightningkite.lightningserver.settings.ServerSettings +import kotlinx.coroutines.delay +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse.BodyHandlers +import java.time.Duration as JDuration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Engine-agnostic HTTP conformance suite shared by every real-socket engine (Ktor, JDK, Netty). + * + * The four HTTP engines each hand-roll request/response translation, so a behavior that is correct + * in one engine can silently regress in another. This suite encodes the cross-cutting contract from + * the repository's `expectations.md` (security headers, HEAD fallback, trailing-slash redirect, CORS) + * as a single set of assertions run against each engine over a real loopback socket. + * + * ## How to add an engine + * Subclass this in the engine's `test` source set and implement [startEngine]: bind the engine to the + * given port, map [maxBodySize] onto the engine's own body-cap setting, and return a [RunningEngine] + * whose [RunningEngine.close] stops it. Every `@Test` here then runs against that engine automatically. + * + * Behaviors that are NOT engine-level and are therefore covered elsewhere: + * - HSTS presence: only emitted over https; a loopback test speaks http, so this suite asserts its + * ABSENCE and the https branch is covered by SecurityHeadersInterceptor's core unit test. + * - Range / Accept-Ranges / Accept-Post / Accept-Patch / the `Allow` header: see + * [options_preflight_returns_allowed_methods] — these `expectations.md` items are not implemented + * at the engine level today (OPTIONS is handled purely as CORS preflight). + */ +public abstract class EngineHttpConformanceSuite { + + /** Body-size cap the engine must enforce; small so the 413 test stays cheap. */ + protected val maxBodySize: Long = 1024L + + /** The origin the test server's CORS config allows; used by the CORS assertions. */ + protected val allowedOrigin: String = "https://allowed.example.com" + + /** A handle to a started engine bound to a concrete port. */ + public interface RunningEngine : AutoCloseable { + public val port: Int + } + + /** + * Start THIS engine bound to [port], enforcing [maxBodySize] bytes on request bodies, serving + * [conformanceDefinition]. Must return only once the engine is accepting connections. + */ + protected abstract fun startEngine(port: Int, maxBodySize: Long): RunningEngine + + /** The single server definition every engine serves for these tests. */ + protected fun conformanceDefinition(): ServerDefinition = ConformanceServer.build() + + /** + * Provide defaults for the settings that [conformanceDefinition] adds beyond the engine's own + * required settings (currently just the CORS config). Call inside the engine's `settings.run { }` + * block alongside the standard `generalSettings.useDefault()` etc. + */ + protected fun ServerSettings.applyConformanceAppDefaults() { + ConformanceServer.cors.useDefault() + } + + /** Reserve a currently-free loopback port. */ + protected fun freePort(): Int = ServerSocket(0).use { (it.localSocketAddress as InetSocketAddress).port } + + /** Block until [port] accepts a TCP connection, so tests never race engine startup. */ + protected fun awaitBound(port: Int, timeoutMillis: Long = 15_000) { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + try { + java.net.Socket().use { it.connect(InetSocketAddress("127.0.0.1", port), 100) } + return + } catch (_: Exception) { + Thread.sleep(50) + } + } + fail("Engine never bound to port $port within ${timeoutMillis}ms") + } + + // --- Shared HTTP client (JDK built-in, so no engine test needs an extra HTTP client dependency) --- + + // Redirect.NEVER lets us observe the 307 trailing-slash redirect rather than transparently following it. + private fun client(): HttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(JDuration.ofSeconds(10)) + .build() + + private fun url(port: Int, path: String) = URI.create("http://127.0.0.1:$port$path") + + // Bound every request so a hung/broken engine fails this suite fast instead of blocking the whole + // test task. Comfortably longer than the 500ms handler timeout the /slow route exercises. + private fun request(port: Int, path: String) = + HttpRequest.newBuilder(url(port, path)).timeout(JDuration.ofSeconds(20)) + + // --- Tests --- + + @Test + public fun nosniff_present_on_ok_response() { + startEngine(freePort(), maxBodySize).use { running -> + val resp = client().send( + request(running.port, "/hello").GET().build(), + BodyHandlers.ofString(), + ) + assertEquals(200, resp.statusCode()) + assertEquals("hello", resp.body()) + assertEquals( + "nosniff", + resp.headers().firstValue("X-Content-Type-Options").orElse(null), + "X-Content-Type-Options must be present on normal responses", + ) + } + } + + @Test + public fun nosniff_present_on_error_response() { + startEngine(freePort(), maxBodySize).use { running -> + // Unmatched path -> 404 produced by the exception handler; it must still flow back out through + // the outermost SecurityHeadersInterceptor. + val resp = client().send( + request(running.port, "/no-such-route").GET().build(), + BodyHandlers.ofString(), + ) + assertEquals(404, resp.statusCode()) + assertEquals( + "nosniff", + resp.headers().firstValue("X-Content-Type-Options").orElse(null), + "X-Content-Type-Options must be present on error responses too", + ) + } + } + + @Test + public fun head_falls_back_to_get_without_body() { + startEngine(freePort(), maxBodySize).use { running -> + val resp = client().send( + request(running.port, "/hello").method("HEAD", HttpRequest.BodyPublishers.noBody()).build(), + BodyHandlers.ofString(), + ) + // Core maps a successful HEAD-fallback to 204 No Content and strips the body. + assertEquals(204, resp.statusCode(), "HEAD with no explicit handler should fall back to GET as 204") + assertTrue(resp.body().isNullOrEmpty(), "HEAD response must have no body") + assertEquals( + "nosniff", + resp.headers().firstValue("X-Content-Type-Options").orElse(null), + "Security headers must still apply to HEAD responses", + ) + } + } + + @Test + public fun trailing_slash_redirects_307() { + startEngine(freePort(), maxBodySize).use { running -> + // /hello is registered without a trailing slash; requesting /hello/ should 307 to /hello. + val resp = client().send( + request(running.port, "/hello/").GET().build(), + BodyHandlers.ofString(), + ) + assertEquals(307, resp.statusCode(), "A trailing-slash mismatch should produce a 307 redirect") + val location = resp.headers().firstValue("Location").orElse("") + assertTrue(location.endsWith("/hello"), "Redirect Location should point at /hello, was '$location'") + } + } + + @Test + public fun cors_reflects_allowed_origin() { + startEngine(freePort(), maxBodySize).use { running -> + val resp = client().send( + request(running.port, "/hello").GET() + .header("Origin", allowedOrigin).build(), + BodyHandlers.ofString(), + ) + assertEquals(200, resp.statusCode()) + assertEquals( + allowedOrigin, + resp.headers().firstValue("Access-Control-Allow-Origin").orElse(null), + "An allowed Origin must be reflected in Access-Control-Allow-Origin", + ) + } + } + + @Test + public fun options_preflight_returns_allowed_methods() { + startEngine(freePort(), maxBodySize).use { running -> + // NOTE ON expectations.md: the OPTIONS contract the framework actually implements is CORS + // preflight (via CorsInterceptor), not the static `Allow: OPTIONS, GET, HEAD, POST` / + // `Accept-Post` / `Accept-Ranges` header block listed in expectations.md. Those latter headers + // are a documented known-gap (see report). A preflight requires an Origin header; without one + // the framework has no OPTIONS handler and returns 404. + val resp = client().send( + request(running.port, "/hello") + .method("OPTIONS", HttpRequest.BodyPublishers.noBody()) + .header("Origin", allowedOrigin) + .header("Access-Control-Request-Method", "GET") + .build(), + BodyHandlers.ofString(), + ) + assertEquals(204, resp.statusCode(), "CORS preflight should be 204 No Content") + assertEquals( + allowedOrigin, + resp.headers().firstValue("Access-Control-Allow-Origin").orElse(null), + ) + val allowMethods = resp.headers().firstValue("Access-Control-Allow-Methods").orElse("") + assertTrue( + allowMethods.contains("GET"), + "Preflight Access-Control-Allow-Methods should advertise GET, was '$allowMethods'", + ) + } + } + + @Test + public fun hsts_absent_over_http() { + startEngine(freePort(), maxBodySize).use { running -> + val resp = client().send( + request(running.port, "/hello").GET().build(), + BodyHandlers.ofString(), + ) + // Per the HSTS spec the header is never emitted over plain http; the https branch is covered + // by SecurityHeadersInterceptor's core unit test, not end-to-end here. + assertNull( + resp.headers().firstValue("Strict-Transport-Security").orElse(null), + "Strict-Transport-Security must not be emitted over http", + ) + } + } + + @Test + public fun handler_timeout_returns_408() { + startEngine(freePort(), maxBodySize).use { running -> + val resp = client().send( + request(running.port, "/slow").GET().build(), + BodyHandlers.ofString(), + ) + assertEquals( + HttpStatus.RequestTimeout.code, + resp.statusCode(), + "A handler that exceeds its timeout should yield 408 (enforced centrally in ServerRuntime.handle)", + ) + } + } + + @Test + public fun oversized_body_returns_413() { + startEngine(freePort(), maxBodySize).use { running -> + val resp = client().send( + request(running.port, "/echo") + .POST(HttpRequest.BodyPublishers.ofByteArray(ByteArray((maxBodySize + 1).toInt()) { 'x'.code.toByte() })) + .build(), + BodyHandlers.ofString(), + ) + assertEquals( + HttpStatus.PayloadTooLarge.code, + resp.statusCode(), + "A request body over the engine's cap should yield 413", + ) + } + } + + /** + * The single definition every engine serves. Kept private so the concrete engine tests reach it + * only through [conformanceDefinition], and so its (effectively private) endpoint members don't trip + * explicit-API checks. + */ + private object ConformanceServer : ServerBuilder() { + val cors = setting( + "cors", + CorsSettings( + limitToDomains = listOf("https://allowed.example.com"), + limitToMethods = listOf("GET", "HEAD", "POST"), + // Non-matching origins simply get no CORS headers instead of 403, so the non-CORS tests + // (which send no Origin) are unaffected. + forbidOnMatchFail = false, + ), + ) + + init { + // Needed so the central 408/error bodies can be serialized by the default exception handler. + registerBasicMediaTypeCoders() + // Installed first (outermost) so security headers apply to every response — including CORS-processed + // and error responses — which the nosniff_* tests verify end-to-end through each engine. + install(SecurityHeadersInterceptor()) + install(CorsInterceptor(cors)) + } + + val hello = path.path("hello").get bind HttpHandler { + HttpResponse.plainText("hello") + } + val slow = path.path("slow").get bind HttpHandler(timeout = 500.milliseconds) { + delay(5.seconds) // deliberately longer than the handler timeout above + HttpResponse.plainText("done") + } + val echo = path.path("echo").post bind HttpHandler { request -> + val size = request.body?.data?.bytes()?.size ?: 0 + HttpResponse.plainText("received $size") + } + } +} diff --git a/engine-netty/build.gradle.kts b/engine-netty/build.gradle.kts index d9f55075f..a6e05144c 100644 --- a/engine-netty/build.gradle.kts +++ b/engine-netty/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.kotlin.test.junit) + testImplementation(testFixtures(project(":engine-local"))) testImplementation(libs.okhttp) testImplementation(libs.openTelemetry.sdk.testing) } diff --git a/engine-netty/src/test/kotlin/com/lightningkite/lightningserver/engine/netty/NettyHttpConformanceTest.kt b/engine-netty/src/test/kotlin/com/lightningkite/lightningserver/engine/netty/NettyHttpConformanceTest.kt new file mode 100644 index 000000000..621a07562 --- /dev/null +++ b/engine-netty/src/test/kotlin/com/lightningkite/lightningserver/engine/netty/NettyHttpConformanceTest.kt @@ -0,0 +1,58 @@ +package com.lightningkite.lightningserver.engine.netty + +import com.lightningkite.lightningserver.definition.generalSettings +import com.lightningkite.lightningserver.definition.loggingSettings +import com.lightningkite.lightningserver.definition.secretBasis +import com.lightningkite.lightningserver.definition.telemetrySettings +import com.lightningkite.lightningserver.engine.conformance.EngineHttpConformanceSuite +import com.lightningkite.lightningserver.engine.local.EngineReliabilitySettings +import com.lightningkite.lightningserver.engine.local.engineCache +import com.lightningkite.lightningserver.engine.local.enginePubSub +import com.lightningkite.lightningserver.engine.local.forceWebSocketPubSub +import com.lightningkite.lightningserver.settings.set +import com.lightningkite.services.data.DataSize.Companion.bytes +import kotlin.concurrent.thread +import kotlin.time.Duration.Companion.seconds + +/** + * Runs the shared cross-engine HTTP conformance suite against the Netty engine. + * See [EngineHttpConformanceSuite] for the behaviors asserted. + * + * Netty caps request bodies via [NettyRuntimeSettings.maxAggregatedContentLength] (its HTTP + * aggregator), not [EngineReliabilitySettings.maxBodySize] like the other engines, so the shared + * [maxBodySize] is mapped onto that field here. + */ +class NettyHttpConformanceTest : EngineHttpConformanceSuite() { + override fun startEngine(port: Int, maxBodySize: Long): RunningEngine { + val engine = NettyEngine(conformanceDefinition()) + engine.settings.run { + generalSettings.useDefault() + secretBasis.useDefault() + telemetrySettings.useDefault() + loggingSettings.useDefault() + enginePubSub.useDefault() + engineCache.useDefault() + forceWebSocketPubSub.useDefault() + applyConformanceAppDefaults() + nettyRunConfig set NettyRuntimeSettings( + host = "127.0.0.1", + port = port, + maxAggregatedContentLength = maxBodySize.bytes, + // Keep per-test teardown fast; the default 25s drain otherwise leaves worker groups + // lingering across all nine tests and drags out the run. + reliability = EngineReliabilitySettings(shutdownDrainTimeout = 1.seconds), + ) + } + // NettyEngine.start() blocks on the server channel's closeFuture().sync(), so it runs on a + // daemon thread; shutdown() completes that future and unblocks the thread. + val serverThread = thread(start = true, isDaemon = true) { engine.start() } + awaitBound(port) + return object : RunningEngine { + override val port: Int = engine.boundAddress?.port ?: port + override fun close() { + engine.shutdown() + serverThread.interrupt() + } + } + } +}