From 4a85a2764873a33213a857c21e4f58659dd7a24f Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 10:22:23 +0000 Subject: [PATCH 01/10] feat(core): expose bound ports, service override, FlashApplication and close hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small seams, each useful on its own, that together make a Flash app testable without hand-rolled scaffolding. - ServerHandle/ServerLifecycle/FlashApp gain port()/ports(). Listeners already bind in the FlashApp constructor, so port(0) resolved to a real port that nothing could read back; every integration test worked around this by opening a ServerSocket(0), closing it and reusing the number, which races anything else on the machine. - FlashContext.override() replaces a binding instead of rejecting it. The duplicate-is-an-error rule stays everywhere else; this is the single deliberate exception, for swapping a service out in tests. A replacement is logged at INFO so misuse in production is visible. - FlashApplication + FlashApp.apply() name an application independently of the port it runs on, so the same one can be booted twice. It takes FlashApp rather than FlashRegistrar because ws() and mount() live there. Being a functional interface, a lambda and a named class are the same thing. - FlashContext.onClose() runs cleanup at stop(), children first and then in reverse registration order. stop() previously closed sockets and the executor and never touched the service graph, so a pooled DataSource was only ever released by JVM exit — invisible with one app per process, a leak per test class once a suite boots and stops many. Co-Authored-By: Claude Opus 5 --- .../java/dev/relism/flash/ServerHandle.java | 11 ++ .../dev/relism/flash/extension/FlashApp.java | 30 ++- .../flash/extension/FlashApplication.java | 41 +++++ .../relism/flash/extension/FlashContext.java | 51 ++++++ .../flash/transport/ServerLifecycle.java | 8 + .../extension/FlashAppLifecycleTest.java | 172 ++++++++++++++++++ 6 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 flash/src/main/java/dev/relism/flash/extension/FlashApplication.java create mode 100644 flash/src/test/java/dev/relism/flash/extension/FlashAppLifecycleTest.java diff --git a/flash/src/main/java/dev/relism/flash/ServerHandle.java b/flash/src/main/java/dev/relism/flash/ServerHandle.java index e03e8ce..f02a5b0 100644 --- a/flash/src/main/java/dev/relism/flash/ServerHandle.java +++ b/flash/src/main/java/dev/relism/flash/ServerHandle.java @@ -7,6 +7,7 @@ import dev.relism.flash.routing.AbstractWsRouter; import dev.relism.flash.transport.TransportFactory; import java.io.IOException; +import java.util.List; import java.util.concurrent.CompletableFuture; /** @@ -28,6 +29,16 @@ public interface ServerHandle { /** Gracefully stops the server, draining active connections. */ CompletableFuture stop(); + /** + * Port of the first bound listener. Valid as soon as the handle exists — listeners are + * bound at construction, not at {@link #start()} — so configuring with port {@code 0} + * and reading the OS-assigned port back is a supported pattern. + */ + int port(); + + /** Ports of every bound listener, in configuration order. */ + List ports(); + static ServerHandle create(FlashConfiguration config, AbstractRouter httpRouter, AbstractWsRouter wsRouter) throws IOException { diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashApp.java b/flash/src/main/java/dev/relism/flash/extension/FlashApp.java index 61a83a8..896be15 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashApp.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashApp.java @@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -138,6 +139,16 @@ public final class FlashApp extends FlashRegistrar { return this; } + /** + * Applies a {@link FlashApplication} to this app. Runs immediately, so {@code configure} + * sees a context that is still open for declarations and a listener that is already + * bound ({@link #port()}). + */ + public FlashApp apply(FlashApplication application) { + Objects.requireNonNull(application, "application").configure(this); + return this; + } + // ── Lifecycle ───────────────────────────────────────────────────────────── /** @@ -166,7 +177,24 @@ public final class FlashApp extends FlashRegistrar { server.startAndBlock(); } - public CompletableFuture stop() { return server.stop(); } + /** + * Gracefully stops the server, then runs every {@link FlashContext#onClose} callback so + * services release what they hold. Draining first means in-flight requests still see a + * live service graph. + */ + public CompletableFuture stop() { + return server.stop().whenComplete((ignored, failure) -> ctx.runCloseCallbacks()); + } + + /** + * Port of the first bound listener. Usable before {@link #start()}: listeners bind when + * the app is created, so {@code FlashApp.create(cfg with port 0).port()} yields the + * OS-assigned port, and an app can even be configured against its own address. + */ + public int port() { return server.port(); } + + /** Ports of every bound listener, in configuration order. */ + public List ports() { return server.ports(); } // ── FlashRegistrar impl ─────────────────────────────────────────────────── diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashApplication.java b/flash/src/main/java/dev/relism/flash/extension/FlashApplication.java new file mode 100644 index 0000000..4d992b9 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/extension/FlashApplication.java @@ -0,0 +1,41 @@ +package dev.relism.flash.extension; + +/** + * One application's complete contribution to a {@link FlashApp} — its routes, extensions and + * services — expressed independently of which port or configuration it runs on. + * + *

Optional. The fluent form keeps working exactly as before; this interface exists so the + * same application can be created more than once, on more than one port: + * + *

{@code
+ * public final class BlogApp implements FlashApplication {
+ *     @Override public void configure(FlashApp app) {
+ *         app.install(new JacksonExtension());
+ *         app.mount("/api", scope -> scope.scan("dev.blog.api"));
+ *         app.ws("/live", new FeedSocket());
+ *     }
+ * }
+ *
+ * // production
+ * FlashApp.create(8080).apply(new BlogApp()).startAndBlock();
+ * }
+ * + *

It takes {@link FlashApp} rather than {@link FlashRegistrar} deliberately: {@code ws} and + * {@code mount} live on {@code FlashApp}, and an application that could not register a + * WebSocket route or a mounted namespace would be a half-application. + * + *

Being a {@code @FunctionalInterface}, a lambda and a named class are the same thing here — + * {@code app -> app.get("/ping", ...)} is as valid as {@code new BlogApp()}. + * + * @see FlashApp#apply(FlashApplication) + */ +@FunctionalInterface +public interface FlashApplication { + + /** + * Registers this application onto {@code app}. Called immediately by + * {@link FlashApp#apply}, so the service graph is still open for declarations + * ({@code app.ctx().supply(...)}) and the listener is already bound ({@code app.port()}). + */ + void configure(FlashApp app); +} diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashContext.java b/flash/src/main/java/dev/relism/flash/extension/FlashContext.java index 36bed75..1bf3134 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashContext.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashContext.java @@ -1,10 +1,13 @@ package dev.relism.flash.extension; +import lombok.extern.slf4j.Slf4j; + import java.util.*; import java.util.function.Function; import java.util.function.Supplier; /** Deterministic boot-time service graph, frozen before handlers are initialised. */ +@Slf4j public final class FlashContext { private enum State { DECLARING, RESOLVING, READY } @@ -13,6 +16,7 @@ public final class FlashContext { private final List processors = new ArrayList<>(); private final List routeListeners = new ArrayList<>(); private final List readyCallbacks = new ArrayList<>(); + private final List closeCallbacks = new ArrayList<>(); private final List children = new ArrayList<>(); private final Deque> resolutionPath = new ArrayDeque<>(); private State state = State.DECLARING; @@ -34,6 +38,23 @@ public final class FlashContext { declare(type, new Binding<>(type, List.of(), ignored -> Objects.requireNonNull(instance, "instance"))); } + /** + * Replaces the binding for {@code type}, whether or not one already exists. + * + *

The declaration rule everywhere else is that a duplicate is an error + * ({@link #provide}, {@link #supply}); this is the single deliberate exception, and it + * exists for tests — {@code flash-testing} installs overrides as the last extension so a + * fake wins over whatever the application or an extension declared. Using it in + * production code is legal but almost always a mistake, so a replacement is logged. + */ + public void override(Class type, T instance) { + requireDeclaring(); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(instance, "instance"); + if (bindings.put(type, new Binding<>(type, List.of(), ignored -> instance)) != null) + log.info("Service binding overridden: {}", type.getName()); + } + /** Declares a no-dependency boot factory. */ public void supply(Class type, Supplier factory) { declare(type, new Binding<>(type, List.of(), ignored -> factory.get())); @@ -53,6 +74,21 @@ public final class FlashContext { /** Registers work materialised after all services are resolved. */ public void onReady(Runnable callback) { requireDeclaring(); readyCallbacks.add(Objects.requireNonNull(callback)); } + /** + * Registers cleanup to run when the app stops, after in-flight requests have drained. + * + *

Deliberately callable outside {@code DECLARING} so a factory can register its own + * teardown while it is being resolved: + *

{@code
+     * ctx.supply(DataSource.class, c -> {
+     *     HikariDataSource ds = new HikariDataSource(cfg);
+     *     c.onClose(ds::close);
+     *     return ds;
+     * });
+     * }
+ */ + public void onClose(Runnable callback) { closeCallbacks.add(Objects.requireNonNull(callback)); } + @SuppressWarnings("unchecked") public T require(Class type) { if (state == State.DECLARING) @@ -106,6 +142,21 @@ public final class FlashContext { for (FlashContext child : children) child.runReadyCallbacks(); } + /** + * Runs every {@link #onClose} callback once: children first, then this context's own in + * reverse registration order, so a service always closes before whatever it depends on. + * A throwing callback is logged and does not stop the rest. Clearing makes a second + * {@code stop()} a no-op. + */ + void runCloseCallbacks() { + for (FlashContext child : children) child.runCloseCallbacks(); + for (int i = closeCallbacks.size() - 1; i >= 0; i--) { + try { closeCallbacks.get(i).run(); } + catch (RuntimeException e) { log.error("Close callback failed", e); } + } + closeCallbacks.clear(); + } + /** Completes graph resolution and runs all deferred materialisation callbacks once. */ public void complete() { resolveAll(); diff --git a/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java b/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java index d0536dc..c422587 100644 --- a/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java +++ b/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java @@ -46,6 +46,14 @@ public final class ServerLifecycle implements ServerHandle { this.acceptLatch = new CountDownLatch(TransportTuning.ACCEPT_THREADS * listeners.size()); } + @Override + public int port() { return listeners.get(0).socket().getLocalPort(); } + + @Override + public List ports() { + return listeners.stream().map(bound -> bound.socket().getLocalPort()).toList(); + } + /** Whether the server has begun shutting down. Passed down to every connection as a * {@link java.util.function.BooleanSupplier} so in-flight request loops can drain * promptly instead of waiting for their next keep-alive request. */ diff --git a/flash/src/test/java/dev/relism/flash/extension/FlashAppLifecycleTest.java b/flash/src/test/java/dev/relism/flash/extension/FlashAppLifecycleTest.java new file mode 100644 index 0000000..099f406 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/extension/FlashAppLifecycleTest.java @@ -0,0 +1,172 @@ +package dev.relism.flash.extension; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The four seams {@code flash-testing} is built on: reading back an ephemeral port, + * replacing a binding, applying an application, and closing services at stop. + */ +class FlashAppLifecycleTest { + + private static FlashConfiguration ephemeral() { + return FlashConfiguration.builder().port(0).host("127.0.0.1") + .shutdownDrainTimeoutMs(250).build(); + } + + // ── port() ─────────────────────────────────────────────────────────────── + + @Test + void exposesTheOsAssignedPortBeforeStart() { + FlashApp app = FlashApp.create(ephemeral()); + try { + assertTrue(app.port() > 0, "port 0 should resolve to a real bound port"); + assertEquals(List.of(app.port()), app.ports()); + } finally { + app.stop().join(); + } + } + + @Test + void keepsTheSamePortAcrossStart() { + FlashApp app = FlashApp.create(ephemeral()).get("/ping", (req, res) -> "pong"); + try { + int beforeStart = app.port(); + app.start(); + assertEquals(beforeStart, app.port()); + } finally { + app.stop().join(); + } + } + + // ── override() ─────────────────────────────────────────────────────────── + + @Test + void overrideReplacesAnExistingBinding() { + FlashContext ctx = new FlashContext(); + ctx.provide(Greeter.class, () -> "real"); + ctx.override(Greeter.class, () -> "fake"); + + ctx.resolveAll(); + + assertEquals("fake", ctx.require(Greeter.class).greet()); + } + + @Test + void overrideAlsoDeclaresWhenNothingWasBound() { + FlashContext ctx = new FlashContext(); + ctx.override(Greeter.class, () -> "fake"); + + ctx.resolveAll(); + + assertEquals("fake", ctx.require(Greeter.class).greet()); + } + + @Test + void overrideIsRejectedOnceDeclarationsAreClosed() { + FlashContext ctx = new FlashContext(); + ctx.resolveAll(); + + assertThrows(IllegalStateException.class, () -> ctx.override(Greeter.class, () -> "fake")); + } + + @Test + void lastInstalledExtensionWinsOverAnEarlierProvider() { + FlashApp app = FlashApp.create(ephemeral()) + .install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real")) + .install((registrar, ctx) -> ctx.override(Greeter.class, () -> "fake")); + try { + app.start(); + assertEquals("fake", app.ctx().require(Greeter.class).greet()); + } finally { + app.stop().join(); + } + } + + // ── apply() ────────────────────────────────────────────────────────────── + + @Test + void applyRunsImmediatelyWithAnOpenContextAndABoundPort() { + List portSeenInsideConfigure = new ArrayList<>(); + + FlashApp app = FlashApp.create(ephemeral()).apply(configured -> { + portSeenInsideConfigure.add(configured.port()); + configured.ctx().provide(Greeter.class, () -> "from-application"); + configured.get("/hello", (req, res) -> "hi"); + }); + try { + assertEquals(List.of(app.port()), portSeenInsideConfigure); + app.start(); + assertEquals("from-application", app.ctx().require(Greeter.class).greet()); + } finally { + app.stop().join(); + } + } + + // ── onClose() ──────────────────────────────────────────────────────────── + + @Test + void stopRunsCloseCallbacksInReverseOrder() { + List closed = new ArrayList<>(); + FlashApp app = FlashApp.create(ephemeral()).apply(configured -> { + configured.ctx().onClose(() -> closed.add("first")); + configured.ctx().onClose(() -> closed.add("second")); + }); + + app.start(); + app.stop().join(); + + assertEquals(List.of("second", "first"), closed); + } + + @Test + void aFactoryCanRegisterItsOwnTeardownWhileResolving() { + AtomicInteger closes = new AtomicInteger(); + FlashApp app = FlashApp.create(ephemeral()).apply(configured -> + configured.ctx().supply(Greeter.class, services -> { + services.onClose(closes::incrementAndGet); + return () -> "resolved"; + })); + + app.start(); + assertEquals(0, closes.get(), "must not close while the app is live"); + + app.stop().join(); + assertEquals(1, closes.get()); + } + + @Test + void aThrowingCloseCallbackDoesNotStopTheRest() { + List closed = new ArrayList<>(); + FlashApp app = FlashApp.create(ephemeral()).apply(configured -> { + configured.ctx().onClose(() -> closed.add("ran")); + configured.ctx().onClose(() -> { throw new IllegalStateException("boom"); }); + }); + + app.start(); + assertDoesNotThrow(() -> app.stop().join()); + + assertEquals(List.of("ran"), closed); + } + + @Test + void closeCallbacksRunAtMostOnce() { + AtomicInteger closes = new AtomicInteger(); + FlashApp app = FlashApp.create(ephemeral()) + .apply(configured -> configured.ctx().onClose(closes::incrementAndGet)); + + app.start(); + app.stop().join(); + app.stop().join(); + + assertEquals(1, closes.get()); + } + + @FunctionalInterface + interface Greeter { String greet(); } +} From 424ca31b7a27559d8e449e131d6e47b7d8092c22 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 10:22:40 +0000 Subject: [PATCH 02/10] feat(testing): add flash-testing, a JUnit 5 harness for Flash applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boots a real app on an OS-assigned port for a test class or a single test and hands back a client pointed at it: @RegisterExtension static FlashTest app = FlashTest.of(new BlogApp()) .mock(UserService.class, new InMemoryUserService()); app.get("/api/users").expectStatus(200).expectBodyContains("alice"); A field rather than an annotation, because annotation values are compile-time constants and so could never express a second server wired from the first — FlashTest.of(new BlogApp(auth.baseUri())). Startup is lazy, so reading baseUri() boots that server on the spot and declaration order does the wiring, with no dependence on JUnit's extension ordering. A static field boots once per class, a non-static field once per test; that is stock JUnit field semantics rather than an option to configure. Runs against a real loopback port instead of dispatching in-process. An in-process dispatcher would be a third copy of the routing/handler/exception sequence that Http1Connection and Http2StreamDispatcher already duplicate, kept in sync by hand, and it would let a test pass while the status line, content-length or HPACK encoding was broken. mock() installs overrides as the last extension, after everything the application and its extensions declare, so a fake always wins. Any object is accepted, so a hand-written fake and a Mockito mock are equally welcome and this module depends on no mocking library — only flash and junit-jupiter-api. Teardown cancels the client before stopping the server: HttpClient holds keep-alive sockets open and ServerLifecycle.stop() spins until the last one closes, so the default 15s drain would otherwise be paid on every test class. shutdownNow rather than close(), which blocks until every operation completes and would hang on a leaked WebSocket. Lives at the top level, not under flash-extensions/, which holds things you install() onto an app; this carries junit-jupiter-api at compile scope and nothing installable should. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 5 +- README.md | 102 ++++++++ flash-extensions/pom.xml | 6 + flash-testing/pom.xml | 38 +++ .../relism/flash/testing/FlashRequest.java | 94 +++++++ .../relism/flash/testing/FlashResponse.java | 80 ++++++ .../dev/relism/flash/testing/FlashTest.java | 240 ++++++++++++++++++ .../relism/flash/testing/FlashWebSocket.java | 139 ++++++++++ .../testing/FlashTestPerMethodScopeTest.java | 40 +++ .../flash/testing/FlashTestSelfTest.java | 141 ++++++++++ .../flash/testing/FlashWebSocketTest.java | 74 ++++++ pom.xml | 14 +- 12 files changed, 971 insertions(+), 2 deletions(-) create mode 100644 flash-testing/pom.xml create mode 100644 flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java create mode 100644 flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java create mode 100644 flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java create mode 100644 flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java create mode 100644 flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java create mode 100644 flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java create mode 100644 flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java diff --git a/AGENTS.md b/AGENTS.md index 985077f..6ee7f45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Format: `(): ` | `chore` | Build, deps, tooling — no production code | | `ci` | Changes to GitHub Actions workflows | -Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, +Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, `ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, `ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. @@ -85,6 +85,9 @@ chore(release): 2.1.0 - Root POM: `flash-parent` — defines all dependency versions and plugin config. - `flash` module: the core framework JAR. +- `flash-testing` module: JUnit 5 harness for testing Flash applications. Deliberately not + under `flash-extensions/` — it is not something you `install()`, and it carries + `junit-jupiter-api` at compile scope. - `flash-extensions` POM: aggregator for all extension modules. - Extensions live under `flash-extensions/flash-ext-*/`. - When adding a new extension: diff --git a/README.md b/README.md index bce2420..99661cc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res | Module | Description | |---|---| | `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model | +| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses | | `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | @@ -389,6 +390,107 @@ The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a future `flash-ext-grpc` extension. +## Testing + +`flash-testing` boots a real app on an OS-assigned port for the duration of a test, and hands you +a client pointed at it. Add it with test scope: + +```xml + + dev.relism + flash-testing + ${flash.version} + test + +``` + +```java +class UserRoutesTest { + + @RegisterExtension + static FlashTest app = FlashTest.of(new BlogApp()) + .mock(UserService.class, new InMemoryUserService()); + + @Test + void listsUsers() { + app.get("/api/users") + .expectStatus(200) + .expectHeader("content-type", "application/json") + .expectBodyContains("alice"); + } +} +``` + +`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services expressed +independently of which port they run on: + +```java +public final class BlogApp implements FlashApplication { + @Override public void configure(FlashApp app) { + app.install(new JacksonExtension()); + app.mount("/api", scope -> scope.scan("dev.blog.api")); + } +} + +FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production +``` + +It is a functional interface, so a lambda works too: +`FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))`. + +### Requests + +The HTTP verb sends the request; `expect*` assertions chain and report the real response body on +failure. `get` and `delete` skip the builder when there is nothing to add. + +```java +app.get("/api/users").expectStatus(200); + +app.request() + .header("Authorization", "Bearer " + token) + .json("{\"name\":\"bob\"}") + .post("/api/users") + .expectStatus(201); + +try (FlashWebSocket socket = app.ws("/live")) { + socket.sendText("hello"); + assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2))); +} +``` + +### Replacing services + +`mock` installs replacements after everything your app and its extensions declare, so a fake always +wins. Any object will do — `flash-testing` depends on no mocking library, so a hand-written fake and +a Mockito mock are equally welcome. + +### More than one server + +`FlashTest` is an ordinary object in a field, so a test class can hold as many as it needs and wire +one from another in plain Java. Startup is lazy — reading `baseUri()` boots that server on the spot +— so declaration order does the wiring: + +```java +@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp()); +@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri())); +``` + +### Scope + +A `static` field boots once for the test class; a non-static field boots a fresh app for every test. +That is stock JUnit field semantics — the isolation switch is the keyword, not an option. + +### Configuration + +`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port +and the shutdown drain window are stamped afterwards, so a profile cannot break the harness; +`listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives +you a client for. + +```java +FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true)); +``` + ## Architecture ``` diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 65ed15a..70452eb 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -31,6 +31,12 @@ + + dev.relism + flash-testing + ${project.version} + test + dev.relism flash-ext-jackson diff --git a/flash-testing/pom.xml b/flash-testing/pom.xml new file mode 100644 index 0000000..a8c2fe6 --- /dev/null +++ b/flash-testing/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + + dev.relism + flash-parent + 2.1.0-SNAPSHOT + + + flash-testing + jar + + + + + dev.relism + flash + + + org.junit.jupiter + junit-jupiter-api + + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java new file mode 100644 index 0000000..b55b01b --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java @@ -0,0 +1,94 @@ +package dev.relism.flash.testing; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Objects; + +/** + * A request being built against a {@link FlashTest} server. The HTTP verb is terminal — it + * sends the request and hands back a {@link FlashResponse}: + * + *
{@code
+ * app.request()
+ *    .header("Authorization", "Bearer " + token)
+ *    .json("{\"name\":\"bob\"}")
+ *    .post("/api/users")
+ *    .expectStatus(201);
+ * }
+ * + * For a bodyless {@code GET} or {@code DELETE}, {@link FlashTest#get} and + * {@link FlashTest#delete} skip the builder entirely. + */ +public final class FlashRequest { + + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); + + private final FlashTest server; + private final HttpRequest.Builder request = HttpRequest.newBuilder().timeout(REQUEST_TIMEOUT); + private byte[] body; + + FlashRequest(FlashTest server) { + this.server = server; + } + + /** Adds a header. Repeatable — a name may be sent more than once. */ + public FlashRequest header(String name, String value) { + request.header(name, value); + return this; + } + + /** Sets a UTF-8 request body. */ + public FlashRequest body(String text) { + this.body = Objects.requireNonNull(text, "text").getBytes(StandardCharsets.UTF_8); + return this; + } + + /** Sets a raw request body. */ + public FlashRequest body(byte[] bytes) { + this.body = Objects.requireNonNull(bytes, "bytes").clone(); + return this; + } + + /** Sets a UTF-8 body and {@code content-type: application/json}. */ + public FlashRequest json(String json) { + return body(json).header("content-type", "application/json"); + } + + public FlashResponse get(String path) { return send("GET", path); } + public FlashResponse post(String path) { return send("POST", path); } + public FlashResponse put(String path) { return send("PUT", path); } + public FlashResponse patch(String path) { return send("PATCH", path); } + public FlashResponse delete(String path) { return send("DELETE", path); } + public FlashResponse head(String path) { return send("HEAD", path); } + public FlashResponse options(String path) { return send("OPTIONS", path); } + + /** Sends any method, including the ones Flash adds beyond RFC 9110 ({@code PURGE}, {@code QUERY}). */ + public FlashResponse send(String method, String path) { + URI target = server.baseUri().resolve(normalise(path)); + HttpRequest built = request.uri(target) + .method(method, body == null + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + try { + HttpResponse response = + server.client().send(built, HttpResponse.BodyHandlers.ofString()); + return new FlashResponse(method, target.getPath(), response); + } catch (IOException failure) { + throw new AssertionError(method + ' ' + target + " failed", failure); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(method + ' ' + target + " was interrupted", interrupted); + } + } + + /** Shared with {@link FlashTest#ws} — the ws:// URI needs the same leading slash. */ + static String normalise(String path) { + Objects.requireNonNull(path, "path"); + return path.startsWith("/") ? path : '/' + path; + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java new file mode 100644 index 0000000..454173b --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java @@ -0,0 +1,80 @@ +package dev.relism.flash.testing; + +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A response from a {@link FlashTest} server, with chainable assertions. + * + *
{@code
+ * app.get("/api/users")
+ *    .expectStatus(200)
+ *    .expectHeader("content-type", "application/json")
+ *    .expectBodyContains("alice");
+ * }
+ * + * Every failure message carries the request line, the status and the body, so a red test says + * what actually came back rather than only what did not match. Use {@link #status()}, + * {@link #body()} and {@link #headers()} for anything the assertions do not cover. + */ +public final class FlashResponse { + + private final String method; + private final String path; + private final HttpResponse response; + + FlashResponse(String method, String path, HttpResponse response) { + this.method = method; + this.path = path; + this.response = response; + } + + // ── Raw access ─────────────────────────────────────────────────────────── + + /** Response status code. */ + public int status() { return response.statusCode(); } + + /** Response body decoded as a string. */ + public String body() { return response.body(); } + + /** All response headers. */ + public HttpHeaders headers() { return response.headers(); } + + /** First value of {@code name} (case-insensitive), or {@code null} if absent. */ + public String header(String name) { return response.headers().firstValue(name).orElse(null); } + + // ── Assertions ─────────────────────────────────────────────────────────── + + /** Asserts the status code. */ + public FlashResponse expectStatus(int expected) { + assertEquals(expected, status(), this::describe); + return this; + } + + /** Asserts the first value of a header (name case-insensitive). */ + public FlashResponse expectHeader(String name, String expected) { + assertEquals(expected, header(name), () -> "header '" + name + '\'' + describe()); + return this; + } + + /** Asserts the body matches exactly. */ + public FlashResponse expectBody(String expected) { + assertEquals(expected, body(), this::describe); + return this; + } + + /** Asserts the body contains {@code fragment}. */ + public FlashResponse expectBodyContains(String fragment) { + assertTrue(body().contains(fragment), () -> "expected body to contain '" + fragment + '\'' + describe()); + return this; + } + + private String describe() { + return "\n request: " + method + ' ' + path + + "\n status: " + status() + + "\n body: " + body(); + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java new file mode 100644 index 0000000..666d821 --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java @@ -0,0 +1,240 @@ +package dev.relism.flash.testing; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashApplication; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.extension.FlashContext; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Boots a real {@link FlashApp} on an OS-assigned port for the duration of a test class or a + * single test, and gives you a client pointed at it. + * + *
{@code
+ * class UserRoutesTest {
+ *
+ *     @RegisterExtension
+ *     static FlashTest app = FlashTest.of(new BlogApp())
+ *             .mock(UserService.class, new InMemoryUserService());
+ *
+ *     @Test
+ *     void listsUsers() {
+ *         app.get("/api/users")
+ *            .expectStatus(200)
+ *            .expectBodyContains("alice");
+ *     }
+ * }
+ * }
+ * + *

More than one server

+ * Because this is an ordinary object in a field rather than an annotation, a test class can + * hold as many as it needs, and one can be wired from another with plain Java. Startup is lazy + * — reading {@code baseUri()} boots that server on the spot — so declaration order does the + * wiring, with no reliance on JUnit's extension ordering: + * + *
{@code
+ * @RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
+ * @RegisterExtension static FlashTest api  = FlashTest.of(new BlogApp(auth.baseUri()));
+ * }
+ * + *

Scope

+ * A {@code static} field boots once for the class; a non-static field boots a fresh app for + * every test. That is stock JUnit field semantics — the isolation switch is the keyword. + * + *

Replacing services

+ * {@link #mock} installs its replacements as the last extension, after everything the + * application and its extensions declare, so a fake always wins. Any object will do — a + * hand-written fake or a Mockito mock you created; this module depends on no mocking library. + * + * @see FlashRequest + * @see FlashResponse + */ +public final class FlashTest implements BeforeAllCallback, AfterAllCallback, + BeforeEachCallback, AfterEachCallback { + + private static final String LOOPBACK = "127.0.0.1"; + + /** + * ponytail: the harness pins the drain window so teardown is fast — the default 15s would + * be paid on every class. Add a profile escape hatch if a test ever needs to exercise + * draining itself. + */ + private static final int DRAIN_MS = 250; + + private final FlashApplication application; + private final Map, Object> overrides = new LinkedHashMap<>(); + private Consumer profile = builder -> { }; + + private FlashApp app; + private HttpClient client; + private URI baseUri; + private boolean classScoped; + + private FlashTest(FlashApplication application) { + this.application = Objects.requireNonNull(application, "application"); + } + + /** + * Creates a harness for {@code application}. Nothing is bound or started until first use. + * + *

{@link FlashApplication} is a functional interface, so a lambda works as well as a + * named class: {@code FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))}. + */ + public static FlashTest of(FlashApplication application) { + return new FlashTest(application); + } + + /** + * Customises the {@link FlashConfiguration} this server runs with — timeouts, HTTP/2 + * switches, buffer sizes. + * + *

The harness stamps host, port and the shutdown drain window after this runs, + * so a profile cannot break it. Setting {@code listener(...)} or {@code tls(...)} is + * rejected: the harness owns the single plaintext loopback listener it hands you a client + * for. + */ + public FlashTest profile(Consumer profile) { + requireNotStarted("profile(...)"); + this.profile = Objects.requireNonNull(profile, "profile"); + return this; + } + + /** + * Replaces the service bound to {@code type} with {@code instance} for this server. + * + *

Wins over anything the application or its extensions declare, including services + * provided by an installed {@code FlashExtension}. + * + *

ponytail: overrides are applied to the app's root context. A service declared inside + * a {@code mount(...)} scope's child context shadows the root and is therefore not + * reachable — add child-context targeting if that ever comes up. + */ + public FlashTest mock(Class type, T instance) { + requireNotStarted("mock(...)"); + overrides.put(Objects.requireNonNull(type, "type"), Objects.requireNonNull(instance, "instance")); + return this; + } + + // ── Accessors (each boots the server if it is not running) ──────────────── + + /** Base URI of the running server, e.g. {@code http://127.0.0.1:41307}. */ + public URI baseUri() { ensureStarted(); return baseUri; } + + /** OS-assigned port of the running server. */ + public int port() { ensureStarted(); return app.port(); } + + /** The running app — escape hatch for assertions the harness does not cover. */ + public FlashApp app() { ensureStarted(); return app; } + + /** The client the harness issues requests with. */ + public HttpClient client() { ensureStarted(); return client; } + + // ── Requests ───────────────────────────────────────────────────────────── + + /** Starts a request with headers or a body; the HTTP verb sends it. */ + public FlashRequest request() { ensureStarted(); return new FlashRequest(this); } + + /** Sends {@code GET path} with no headers or body. */ + public FlashResponse get(String path) { return request().get(path); } + + /** Sends {@code DELETE path} with no headers or body. */ + public FlashResponse delete(String path) { return request().delete(path); } + + /** + * Opens a WebSocket to {@code path} on this server. Close it when done — a + * try-with-resources block is the usual shape. + */ + public FlashWebSocket ws(String path) { + ensureStarted(); + return new FlashWebSocket(client, + URI.create("ws://" + LOOPBACK + ':' + app.port() + FlashRequest.normalise(path))); + } + + // ── JUnit lifecycle ────────────────────────────────────────────────────── + // A static field receives class- AND method-level callbacks, so afterEach would otherwise + // tear the server down after the first test. classScoped records which tier owns it. + + @Override public void beforeAll(ExtensionContext context) { classScoped = true; ensureStarted(); } + @Override public void afterAll(ExtensionContext context) { stop(); } + @Override public void beforeEach(ExtensionContext context) { if (!classScoped) ensureStarted(); } + @Override public void afterEach(ExtensionContext context) { if (!classScoped) stop(); } + + // ── Internals ──────────────────────────────────────────────────────────── + + private void ensureStarted() { + if (app == null) start(); + } + + private void start() { + FlashConfiguration.FlashConfigurationBuilder builder = FlashConfiguration.builder(); + profile.accept(builder); + FlashConfiguration config = builder + .port(0) + .host(LOOPBACK) + .shutdownDrainTimeoutMs(DRAIN_MS) + .build(); + rejectListenerOverrides(config); + + FlashApp starting = FlashApp.create(config).apply(application); + if (!overrides.isEmpty()) + starting.install((registrar, ctx) -> applyOverrides(ctx)); + starting.start(); + + app = starting; + baseUri = URI.create("http://" + LOOPBACK + ':' + starting.port()); + client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + } + + /** + * {@code listeners} is a lombok {@code @Singular} field, so it can only be inspected after + * the build — hence build-then-check rather than a guard on the builder. + */ + private static void rejectListenerOverrides(FlashConfiguration config) { + if (!config.getListeners().isEmpty()) + throw new IllegalStateException( + "FlashTest owns the listener — remove listener(...) from the profile"); + if (config.getTls() != null) + throw new IllegalStateException( + "FlashTest serves plaintext on loopback — remove tls(...) from the profile"); + } + + @SuppressWarnings("unchecked") + private void applyOverrides(FlashContext ctx) { + overrides.forEach((type, instance) -> ctx.override((Class) type, instance)); + } + + private void stop() { + if (app == null) return; + // Order matters: the client holds keep-alive sockets open, and ServerLifecycle.stop() + // spins until the last one closes or the drain window expires. shutdownNow rather than + // close(), which blocks until every operation completes — a test that leaked an open + // WebSocket would hang teardown forever. + client.shutdownNow(); + try { + client.awaitTermination(Duration.ofSeconds(2)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + app.stop().join(); + app = null; + client = null; + baseUri = null; + } + + private void requireNotStarted(String what) { + if (app != null) + throw new IllegalStateException(what + " must be configured before the server starts"); + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java new file mode 100644 index 0000000..3888e9e --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java @@ -0,0 +1,139 @@ +package dev.relism.flash.testing; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * A WebSocket connected to a {@link FlashTest} server, for asserting on what a Flash endpoint + * pushes back. + * + *
{@code
+ * try (FlashWebSocket socket = app.ws("/live")) {
+ *     socket.sendText("hello");
+ *     assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
+ * }
+ * }
+ * + * Backed by {@link java.net.http.WebSocket}, so the RFC 6455 handshake, masking and + * fragmentation are the JDK's, not hand-rolled. Incoming text is queued as it arrives, so a + * message that lands before {@link #awaitText} is called is not lost. + */ +public final class FlashWebSocket implements AutoCloseable { + + private final BlockingQueue received = new LinkedBlockingQueue<>(); + private final CompletableFuture closed = new CompletableFuture<>(); + private final WebSocket socket; + + FlashWebSocket(HttpClient client, URI uri) { + this.socket = client.newWebSocketBuilder() + .buildAsync(uri, new QueueingListener()) + .join(); + } + + /** Sends a whole text message. */ + public FlashWebSocket sendText(String message) { + socket.sendText(Objects.requireNonNull(message, "message"), true).join(); + return this; + } + + /** + * Waits for the next text message. + * + * @throws AssertionError if none arrives within {@code timeout} + */ + public String awaitText(Duration timeout) { + String message = poll(timeout); + if (message == null) + throw new AssertionError("No WebSocket text message within " + timeout); + return message; + } + + /** + * Waits for the server to close the connection and returns its close status code. + * + * @throws AssertionError if the server does not close within {@code timeout} + */ + public int awaitClose(Duration timeout) { + try { + return closed.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException notClosed) { + throw new AssertionError("WebSocket was not closed within " + timeout, notClosed); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted awaiting WebSocket close", interrupted); + } catch (ExecutionException failure) { + throw new AssertionError("WebSocket failed before closing", failure.getCause()); + } + } + + /** + * Sends a normal close and gives the server a moment to answer it. + * + *

Never throws: this is cleanup, usually in a try-with-resources, and a connection the + * server already dropped must not mask the failure the test was actually reporting. Use + * {@link #awaitClose} when the close itself is what you are asserting on. + */ + @Override + public void close() { + try { + if (!socket.isOutputClosed()) socket.sendClose(WebSocket.NORMAL_CLOSURE, "").join(); + } catch (RuntimeException alreadyGone) { + // nothing to close + } + closed.completeOnTimeout(WebSocket.NORMAL_CLOSURE, 1, TimeUnit.SECONDS) + .exceptionally(failure -> WebSocket.NORMAL_CLOSURE) + .join(); + } + + private String poll(Duration timeout) { + try { + return received.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted awaiting a WebSocket message", interrupted); + } + } + + /** Reassembles fragmented text and queues whole messages. */ + private final class QueueingListener implements WebSocket.Listener { + + private final StringBuilder partial = new StringBuilder(); + + @Override + public void onOpen(WebSocket webSocket) { + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + partial.append(data); + if (last) { + received.add(partial.toString()); + partial.setLength(0); + } + webSocket.request(1); + return null; + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + closed.complete(statusCode); + return null; + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + closed.completeExceptionally(error); + } + } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java new file mode 100644 index 0000000..780c28b --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java @@ -0,0 +1,40 @@ +package dev.relism.flash.testing; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A non-static field gets JUnit's method-level callbacks, so the app is rebuilt for every + * test. Counting boots proves the isolation rather than inferring it from a fresh port, which + * the OS is free to reuse. + */ +class FlashTestPerMethodScopeTest { + + private static final AtomicInteger boots = new AtomicInteger(); + + @RegisterExtension + FlashTest app = FlashTest.of(configured -> { + boots.incrementAndGet(); + configured.get("/ping", (req, res) -> "pong"); + }); + + @AfterAll + static void bootedOncePerTest() { + assertEquals(2, boots.get(), "an instance FlashTest field should boot per test"); + } + + @Test + void firstTestGetsItsOwnApp() { + app.get("/ping").expectStatus(200).expectBody("pong"); + } + + @Test + void secondTestGetsAnotherApp() { + app.get("/ping").expectStatus(200).expectBody("pong"); + } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java new file mode 100644 index 0000000..f0a6e6e --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java @@ -0,0 +1,141 @@ +package dev.relism.flash.testing; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashApplication; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The scenario the harness exists for: two servers in one class, the second configured from + * the first's address, with a service faked out on the second. + */ +class FlashTestSelfTest { + + private static final AtomicInteger downstreamBoots = new AtomicInteger(); + + @RegisterExtension + static FlashTest upstream = FlashTest.of(app -> app + .get("/health", (req, res) -> "UP") + .get("/echo", (req, res) -> "upstream:" + req.query("v"))); + + // upstream.baseUri() boots it right here, during this field's initialiser — declaration + // order does the wiring, with no @Order and no reliance on JUnit's extension ordering. + @RegisterExtension + static FlashTest downstream = FlashTest.of(new Downstream(upstream.baseUri())) + .mock(Greeter.class, () -> "faked"); + + @AfterAll + static void classScopedServerBootsExactlyOnce() { + assertEquals(1, downstreamBoots.get(), "a static FlashTest field should boot once per class"); + } + + // ── two servers, wired together ────────────────────────────────────────── + + @Test + void eachServerGetsItsOwnPort() { + assertNotEquals(upstream.port(), downstream.port()); + assertTrue(upstream.port() > 0); + } + + @Test + void downstreamReachesUpstreamThroughItsInjectedBaseUri() { + downstream.get("/call-upstream") + .expectStatus(200) + .expectBody("UP"); + } + + // ── mocking ────────────────────────────────────────────────────────────── + + @Test + void mockWinsOverAServiceProvidedByAnInstalledExtension() { + downstream.get("/greeting") + .expectStatus(200) + .expectBody("faked"); + } + + @Test + void mockAfterStartIsRejected() { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> downstream.mock(Greeter.class, () -> "too late")); + assertTrue(error.getMessage().contains("before the server starts")); + } + + // ── request / response surface ─────────────────────────────────────────── + + @Test + void sendsHeadersQueriesAndBodies() { + upstream.get("/echo?v=7").expectBody("upstream:7"); + + downstream.request() + .header("X-Trace", "abc") + .json("{\"name\":\"bob\"}") + .post("/submit") + .expectStatus(201) + .expectHeader("X-Trace", "abc") + .expectBodyContains("bob"); + } + + @Test + void failedAssertionsReportTheActualResponse() { + AssertionError error = assertThrows(AssertionError.class, + () -> upstream.get("/health").expectStatus(404)); + + String message = error.getMessage(); + assertTrue(message.contains("GET /health"), message); + assertTrue(message.contains("200"), message); + assertTrue(message.contains("UP"), message); + } + + @Test + void unmatchedRoutesStillComeBackAsResponses() { + upstream.get("/nope").expectStatus(404); + } + + // ── fixtures ───────────────────────────────────────────────────────────── + + /** Counts its own boots so the class-scoped lifecycle can be asserted. */ + private record Downstream(URI upstream) implements FlashApplication { + + @Override + public void configure(FlashApp app) { + downstreamBoots.incrementAndGet(); + + // Provided by an extension, so the .mock(...) above has something real to beat. + app.install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real")); + + app.get("/greeting", (req, res) -> app.ctx().require(Greeter.class).greet()); + + app.get("/call-upstream", (req, res) -> { + try (HttpClient http = HttpClient.newHttpClient()) { + return http.send(HttpRequest.newBuilder(upstream.resolve("/health")).build(), + HttpResponse.BodyHandlers.ofString()).body(); + } catch (IOException failure) { + throw new IllegalStateException(failure); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + }); + + app.post("/submit", (req, res) -> res.status(201) + .header("X-Trace", req.header("X-Trace")) + .body(req.body().bytes())); + } + } + + @FunctionalInterface + interface Greeter { String greet(); } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java new file mode 100644 index 0000000..0a0287e --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java @@ -0,0 +1,74 @@ +package dev.relism.flash.testing; + +import dev.relism.flash.websocket.WebSocketFrame; +import dev.relism.flash.websocket.WebSocketHandler; +import dev.relism.flash.websocket.WebSocketSession; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class FlashWebSocketTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(2); + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.ws("/echo", new WebSocketHandler() { + @Override public void onOpen(WebSocketSession session) { } + @Override public void onMessage(WebSocketSession session, WebSocketFrame frame) { + if (frame.opcode() != WebSocketFrame.OP_TEXT) return; + try { + byte[] echo = ("echo:" + new String(frame.copyPayload(), frame.payloadOffset(), + frame.payloadLength(), StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8); + session.sendText(echo, 0, echo.length); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + }); + configured.ws("/silent", new WebSocketHandler() { + @Override public void onOpen(WebSocketSession session) { } + @Override public void onMessage(WebSocketSession session, WebSocketFrame frame) { } + }); + }); + + @Test + void roundTripsTextThroughARealHandshake() { + try (FlashWebSocket socket = app.ws("/echo")) { + socket.sendText("hello"); + assertEquals("echo:hello", socket.awaitText(TIMEOUT)); + } + } + + @Test + void queuesEveryMessageInOrder() { + try (FlashWebSocket socket = app.ws("/echo")) { + socket.sendText("one").sendText("two"); + assertEquals("echo:one", socket.awaitText(TIMEOUT)); + assertEquals("echo:two", socket.awaitText(TIMEOUT)); + } + } + + /** The likeliest user mistake: teardown must cancel it rather than block on it. */ + @Test + void anUnclosedSocketDoesNotHangTeardown() { + FlashWebSocket leaked = app.ws("/echo"); + leaked.sendText("no try-with-resources here"); + assertEquals("echo:no try-with-resources here", leaked.awaitText(TIMEOUT)); + } + + @Test + void awaitTextFailsLoudlyWhenNothingArrives() { + try (FlashWebSocket socket = app.ws("/silent")) { + socket.sendText("ignored"); + AssertionError error = assertThrows(AssertionError.class, + () -> socket.awaitText(Duration.ofMillis(300))); + assertEquals("No WebSocket text message within PT0.3S", error.getMessage()); + } + } +} diff --git a/pom.xml b/pom.xml index cdbc07d..aaa7c7e 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ flash + flash-testing flash-extensions @@ -36,6 +37,7 @@ 3.2.8 2.18.0 1.37 + 5.11.0 3.6.0 @@ -60,6 +62,11 @@ flash ${project.version} + + dev.relism + flash-testing + ${project.version} + dev.relism flash-ext-jackson @@ -139,9 +146,14 @@ org.junit.jupiter junit-jupiter - 5.11.0 + ${junit.version} test + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + From fa5a025c935d60e8172c281df31aa75d5052c73d Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 10:22:40 +0000 Subject: [PATCH 03/10] test(ext-mcp): migrate McpAuthPolicyTest to flash-testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First migration, chosen because it is the case that drove the harness design: a Flash app plus a FakeOidcProvider, with tokens audience-bound to the app's own port, so the port has to be readable after boot. Drops the racy free-port dance, the hand-rolled HttpClient plumbing and the per-test teardown; failures now report the response body. 120 to 106 code lines, and what remains is tool-policy assertions rather than fixture code. The two boot-rejection tests keep building their app directly — a harness whose job is to boot an app is the wrong tool for asserting that booting fails — but port(0) removes freePort() from those too. Co-Authored-By: Claude Opus 5 --- flash-extensions/flash-ext-mcp/pom.xml | 5 + .../flash/ext/mcp/McpAuthPolicyTest.java | 199 +++++++++--------- 2 files changed, 100 insertions(+), 104 deletions(-) diff --git a/flash-extensions/flash-ext-mcp/pom.xml b/flash-extensions/flash-ext-mcp/pom.xml index e5effe7..db7f5ac 100644 --- a/flash-extensions/flash-ext-mcp/pom.xml +++ b/flash-extensions/flash-ext-mcp/pom.xml @@ -38,6 +38,11 @@ org.junit.jupiter junit-jupiter + + dev.relism + flash-testing + test + diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java index fb31b9c..af42bdb 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java @@ -3,16 +3,14 @@ package dev.relism.flash.ext.mcp; import dev.relism.flash.ext.oidc.OidcConfig; import dev.relism.flash.ext.oidc.OidcExtension; import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.testing.FlashResponse; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; -import java.net.ServerSocket; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -26,125 +24,118 @@ class McpAuthPolicyTest { private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured"; private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly"; - private FlashApp app; - private FakeOidcProvider provider; + private static final FakeOidcProvider provider = newProvider(); - @AfterEach - void tearDown() { - if (app != null) app.stop(); - if (provider != null) provider.close(); - } - - @Test - void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception { - int port = bootSecuredApp(SECURED_TOOLS); - String resourceId = "http://127.0.0.1:" + port + "/mcp"; - - String noRole = provider.signToken("user-1", resourceId, null); - HttpResponse denied = callTool(port, "admin_only", noRole); - assertEquals(200, denied.statusCode()); - assertTrue(denied.body().contains("\"isError\":true"), denied.body()); - assertTrue(denied.body().contains("missing required role"), denied.body()); - - String withRole = provider.signToken("user-1", resourceId, null, "admin"); - HttpResponse allowed = callTool(port, "admin_only", withRole); - assertEquals(200, allowed.statusCode()); - assertTrue(allowed.body().contains("\"isError\":false"), allowed.body()); - assertTrue(allowed.body().contains("ok"), allowed.body()); - } - - @Test - void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception { - int port = bootSecuredApp(SECURED_TOOLS); - String resourceId = "http://127.0.0.1:" + port + "/mcp"; - - String noScope = provider.signToken("user-1", resourceId, "read"); - HttpResponse denied = callTool(port, "write_only", noScope); - assertEquals(200, denied.statusCode()); - assertTrue(denied.body().contains("\"isError\":true"), denied.body()); - assertTrue(denied.body().contains("missing required scope"), denied.body()); - - String withScope = provider.signToken("user-1", resourceId, "read write"); - HttpResponse allowed = callTool(port, "write_only", withScope); - assertEquals(200, allowed.statusCode()); - assertTrue(allowed.body().contains("\"isError\":false"), allowed.body()); - assertTrue(allowed.body().contains("written"), allowed.body()); - } - - @Test - void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception { - int port = bootSecuredApp(SECURED_TOOLS); - String resourceId = "http://127.0.0.1:" + port + "/mcp"; - - String plain = provider.signToken("user-1", resourceId, null); - HttpResponse resp = callTool(port, "open", plain); - assertEquals(200, resp.statusCode()); - assertTrue(resp.body().contains("\"isError\":false"), resp.body()); - assertTrue(resp.body().contains("open"), resp.body()); - } - - @Test - void toolAnnotated_butSecurityNone_failsAtBoot() throws Exception { - provider = new FakeOidcProvider(); - int port = freePort(); - app = FlashApp.create(port); + @RegisterExtension + static FlashTest secured = FlashTest.of(app -> { app.install(new OidcExtension(OidcConfig.builder( provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); app.install(new McpExtension(McpConfig.builder("secure-server") .toolsPackage(SECURED_TOOLS) - .security(McpSecurity.NONE) + .security(McpSecurity.REQUIRED) .build())); + }); - IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start()); - assertTrue(e.getMessage().contains("no active OAuth2 protection"), e.getMessage()); + /** Tokens are audience-bound to this server, so the port has to be read back after boot. */ + private static String resourceId() { + return "http://127.0.0.1:" + secured.port() + "/mcp"; + } + + @AfterAll + static void closeProvider() { + provider.close(); + } + + // ── Tool policy ────────────────────────────────────────────────────────── + + @Test + void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception { + callTool("admin_only", provider.signToken("user-1", resourceId(), null)) + .expectStatus(200) + .expectBodyContains("\"isError\":true") + .expectBodyContains("missing required role"); + + callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin")) + .expectStatus(200) + .expectBodyContains("\"isError\":false") + .expectBodyContains("ok"); } @Test - void bareAuthenticated_hasNoEffect_failsAtBoot() throws Exception { - provider = new FakeOidcProvider(); - int port = freePort(); - app = FlashApp.create(port); - app.install(new OidcExtension(OidcConfig.builder( - provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); - app.install(new McpExtension(McpConfig.builder("secure-server") - .toolsPackage(AUTHENTICATED_ONLY_TOOLS) - .security(McpSecurity.REQUIRED) - .build())); + void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception { + callTool("write_only", provider.signToken("user-1", resourceId(), "read")) + .expectStatus(200) + .expectBodyContains("\"isError\":true") + .expectBodyContains("missing required scope"); - IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start()); - assertTrue(e.getMessage().contains("no effect"), e.getMessage()); + callTool("write_only", provider.signToken("user-1", resourceId(), "read write")) + .expectStatus(200) + .expectBodyContains("\"isError\":false") + .expectBodyContains("written"); } - // ── Helpers ────────────────────────────────────────────────────────────── + @Test + void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception { + callTool("open", provider.signToken("user-1", resourceId(), null)) + .expectStatus(200) + .expectBodyContains("\"isError\":false") + .expectBodyContains("open"); + } - private int bootSecuredApp(String toolsPackage) throws Exception { - provider = new FakeOidcProvider(); - int port = freePort(); + private static FlashResponse callTool(String toolName, String token) { + return secured.request() + .header("Accept", "application/json") + .header("Authorization", "Bearer " + token) + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + + toolName + "\"}}") + .post("/mcp"); + } - app = FlashApp.create(port); + // ── Boot-time rejection ────────────────────────────────────────────────── + // These assert that start() throws, so they build the app directly rather than through + // FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that + // booting fails. Port 0 still removes the old free-port dance. + + private FlashApp bootFailure; + + @AfterEach + void releaseBootFailureListener() { + if (bootFailure != null) bootFailure.stop().join(); + } + + @Test + void toolAnnotated_butSecurityNone_failsAtBoot() { + bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE); + + IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start); + assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage()); + } + + @Test + void bareAuthenticated_hasNoEffect_failsAtBoot() { + bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED); + + IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start); + assertTrue(error.getMessage().contains("no effect"), error.getMessage()); + } + + private static FlashApp mcpApp(String toolsPackage, McpSecurity security) { + FlashApp app = FlashApp.create(FlashConfiguration.builder() + .port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build()); app.install(new OidcExtension(OidcConfig.builder( provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); app.install(new McpExtension(McpConfig.builder("secure-server") .toolsPackage(toolsPackage) - .security(McpSecurity.REQUIRED) + .security(security) .build())); - app.start(); - return port; + return app; } - private static HttpResponse callTool(int port, String toolName, String token) throws Exception { - String body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\"}}"; - HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("Authorization", "Bearer " + token) - .POST(HttpRequest.BodyPublishers.ofString(body)); - return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString()); - } - - private static int freePort() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - return s.getLocalPort(); + private static FakeOidcProvider newProvider() { + try { + return new FakeOidcProvider(); + } catch (Exception failure) { + throw new IllegalStateException("Could not start the fake OIDC provider", failure); } } } From 1c207cf94c8bbe750e1f708721abc256ec32987c Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 11:05:06 +0000 Subject: [PATCH 04/10] feat(testing): boot lazily instead of eagerly in beforeEach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beforeEach called ensureStarted(), so every FlashTest field in a class booted for every test whether or not that test touched it — a class holding four servers paid for four boots per test. Booting is already lazy on first access, so the hook was only ever forcing work forward. Neither hook starts anything now. beforeAll still records that a static field owns the class-scoped lifecycle, which is what keeps afterEach from tearing a class-scoped server down after the first test. This also lets an application read @TempDir inside configure(): JUnit populates those during instance post-processing, before the first test body but after extension beforeEach callbacks would have fired. Co-Authored-By: Claude Opus 5 --- .../src/main/java/dev/relism/flash/testing/FlashTest.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java index 666d821..18605e6 100644 --- a/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java @@ -165,10 +165,14 @@ public final class FlashTest implements BeforeAllCallback, AfterAllCallback, // ── JUnit lifecycle ────────────────────────────────────────────────────── // A static field receives class- AND method-level callbacks, so afterEach would otherwise // tear the server down after the first test. classScoped records which tier owns it. + // + // Neither hook starts anything: booting stays lazy, so a class holding several servers + // only pays for the ones a test actually touches, and an application whose configure() + // reads @TempDir sees it populated rather than null. - @Override public void beforeAll(ExtensionContext context) { classScoped = true; ensureStarted(); } + @Override public void beforeAll(ExtensionContext context) { classScoped = true; } @Override public void afterAll(ExtensionContext context) { stop(); } - @Override public void beforeEach(ExtensionContext context) { if (!classScoped) ensureStarted(); } + @Override public void beforeEach(ExtensionContext context) { /* lazy */ } @Override public void afterEach(ExtensionContext context) { if (!classScoped) stop(); } // ── Internals ──────────────────────────────────────────────────────────── From e0ad83eb2f47fe75c2c1c95c23888a40e09a612a Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 11:05:06 +0000 Subject: [PATCH 05/10] test(core): read bound ports back from the app instead of guessing free ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every integration test picked a port by opening ServerSocket(0), closing it and reusing the number, which races anything else on the machine between the close and the rebind. FlashApp.port() reports the port the listener actually bound, so the guess is gone: 18 freePort() helpers deleted, 40 call sites now pass port(0) and read the result back. Two ServerSocket(0) uses remain and are correct. ConnectionRunnerTest accepts on its socket rather than using it to pick a number. H2LoadMeasurementTest hands its port to an external nghttpd process, which has no equivalent of port() to read back; that one is now commented to say so. HttpServerTlsTest's two-listener case reads both back through ports(). HttpServerTest and HttpServerConcurrencyTest also move from @BeforeEach to @BeforeAll — every test in them is read-only against the same routes, so 11 and 3 boots respectively become 1. HttpServerConcurrencyTest's lazy-compile test keeps building its own app, since a freshly compiled router is the thing it tests. HttpServerTest now runs 11 tests in 0.06s. The http2 interop suites (curl, nghttp, grpcurl, h2spec), the load measurement and the soak test are skipped without their external binaries or system property, so those edits are compile-verified here and exercised in CI. Co-Authored-By: Claude Opus 5 --- .../flash/HttpServerConcurrencyTest.java | 38 ++++++-------- .../java/dev/relism/flash/HttpServerTest.java | 27 +++++----- .../relism/flash/HttpServerTimeoutTest.java | 27 ++++------ .../dev/relism/flash/HttpServerTlsTest.java | 51 +++++++++---------- .../relism/flash/HttpServerWebSocketTest.java | 8 +-- .../extension/FlashAppWebSocketTest.java | 8 +-- .../relism/flash/http2/CurlInteropTest.java | 13 ++--- .../relism/flash/http2/GrpcInteropTest.java | 9 +--- .../flash/http2/H2LoadMeasurementTest.java | 8 ++- .../flash/http2/H2SpecComplianceTest.java | 13 ++--- .../flash/http2/H2cPriorKnowledgeTest.java | 13 ++--- .../relism/flash/http2/Http2AbuseTest.java | 9 +--- .../flash/http2/Http2ConcurrencyTest.java | 9 +--- .../relism/flash/http2/Http2ConnectTest.java | 9 +--- .../http2/Http2ConnectionIntegrationTest.java | 45 ++++++++-------- .../http2/Http2MisdirectedRequestTest.java | 9 +--- .../http2/Http2RegressionCorpusTest.java | 9 +--- .../dev/relism/flash/http2/Http2SoakTest.java | 9 +--- .../relism/flash/http2/Http2TrailersTest.java | 13 ++--- .../relism/flash/http2/NghttpInteropTest.java | 9 +--- .../flash/http2/WebSocketOverH2Test.java | 9 +--- .../flash/http2/WebSocketParityTest.java | 9 +--- .../ServerLifecycleGracefulShutdownTest.java | 13 ++--- 23 files changed, 132 insertions(+), 235 deletions(-) diff --git a/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java b/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java index ea4368e..942a1d7 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java @@ -4,11 +4,10 @@ import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashConfiguration; import dev.relism.flash.http.ContentType; import dev.relism.flash.models.Response; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import java.net.ServerSocket; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -25,20 +24,17 @@ import static org.junit.jupiter.api.Assertions.*; class HttpServerConcurrencyTest { - private FlashApp app; - private int port; - private HttpClient httpClient; - - @BeforeEach - void setUp() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - port = s.getLocalPort(); - } + private static FlashApp app; + private static int port; + private static HttpClient httpClient; + @BeforeAll + static void setUp() { app = FlashApp.create(FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .build()); + port = app.port(); app.get("/ping", (req, res) -> "pong"); app.post("/echo", (req, res) -> { @@ -53,9 +49,9 @@ class HttpServerConcurrencyTest { .build(); } - @AfterEach - void tearDown() { - if (app != null) app.stop(); + @AfterAll + static void tearDown() { + if (app != null) app.stop().join(); } // --- helpers --- @@ -173,15 +169,13 @@ class HttpServerConcurrencyTest { */ @Test void concurrent_lazyCompile_noRaceCondition() throws Exception { - int freshPort; - try (ServerSocket s = new ServerSocket(0)) { - freshPort = s.getLocalPort(); - } - + // Deliberately a brand-new app: this test is about compiling routes lazily on first + // use, so it must not share the class-scoped server. FlashApp freshApp = FlashApp.create(FlashConfiguration.builder() - .port(freshPort) + .port(0) .host("127.0.0.1") .build()); + int freshPort = freshApp.port(); for (int i = 0; i < 10; i++) { final int idx = i; diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTest.java index ff4705f..3f7ab21 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerTest.java @@ -2,15 +2,14 @@ package dev.relism.flash; import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashConfiguration; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; -import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; @@ -18,19 +17,17 @@ import static org.junit.jupiter.api.Assertions.*; class HttpServerTest { - private FlashApp app; - private int port; - - @BeforeEach - void setUp() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - port = s.getLocalPort(); - } + // Every test here is read-only against the same routes, so one boot for the class. + private static FlashApp app; + private static int port; + @BeforeAll + static void setUp() { app = FlashApp.create(FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .build()); + port = app.port(); app.get("/api/ping", (req, res) -> "pong"); @@ -61,9 +58,9 @@ class HttpServerTest { app.start(); } - @AfterEach - void tearDown() { - if (app != null) app.stop(); + @AfterAll + static void tearDown() { + if (app != null) app.stop().join(); } // --- helpers --- diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java index a354a90..471ef5a 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.io.OutputStream; -import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.nio.file.Path; @@ -30,21 +29,15 @@ class HttpServerTimeoutTest { if (app != null) app.stop(); } - private int freePort() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - return s.getLocalPort(); - } - } - @Test void slowlorisHeaderDribble_disconnectedWithinHeaderReadTimeout() throws Exception { int headerTimeoutMs = 300; - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .headerReadTimeoutMs(headerTimeoutMs) .idleKeepAliveTimeoutMs(60_000) .build()); + int port = app.port(); app.get("/", (req, res) -> "ok"); app.start(); @@ -71,12 +64,12 @@ class HttpServerTimeoutTest { @Test void idleKeepAliveConnection_disconnectedWithinIdleTimeout() throws Exception { int idleTimeoutMs = 300; - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .headerReadTimeoutMs(10_000) .idleKeepAliveTimeoutMs(idleTimeoutMs) .build()); + int port = app.port(); app.get("/", (req, res) -> "ok"); app.start(); @@ -96,13 +89,13 @@ class HttpServerTimeoutTest { @Test void slowBodyDribble_disconnectedWithinBodyReadTimeout() throws Exception { int bodyTimeoutMs = 300; - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .headerReadTimeoutMs(10_000) .idleKeepAliveTimeoutMs(10_000) .bodyReadTimeoutMs(bodyTimeoutMs) .build()); + int port = app.port(); app.post("/echo", (req, res) -> req.body().bytes()); app.start(); @@ -130,12 +123,12 @@ class HttpServerTimeoutTest { int headerTimeoutMs = 300; Path ks = TestKeystores.build(dir, "timeout.p12", "changeit", TestKeystores.Entry.of("only", "timeout.test")); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.keystore(ks, "changeit")) .headerReadTimeoutMs(headerTimeoutMs) .build()); + int port = app.port(); app.get("/", (req, res) -> "ok"); app.start(); @@ -157,13 +150,13 @@ class HttpServerTimeoutTest { @Test void wellBehavedRequest_wellWithinTimeouts_unaffected() throws Exception { - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .headerReadTimeoutMs(300) .idleKeepAliveTimeoutMs(300) .bodyReadTimeoutMs(300) .build()); + int port = app.port(); app.get("/ping", (req, res) -> "pong"); app.start(); diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java index d1c55d3..635ba36 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java @@ -60,9 +60,6 @@ class HttpServerTlsTest { if (app != null) app.stop(); } - private static int freePort() throws IOException { - try (ServerSocket s = new ServerSocket(0)) { return s.getLocalPort(); } - } private static String httpGet(SSLSocket socket, String path) throws IOException { socket.setSoTimeout(SOCKET_TIMEOUT_MS); @@ -79,11 +76,11 @@ class HttpServerTlsTest { @Test void httpsRequest_servedOverModernTls(@TempDir java.nio.file.Path dir) throws Exception { var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.keystore(ks, "changeit")) .build()); + int port = app.port(); app.get("/ping", (req, res) -> "pong"); app.start(); @@ -103,11 +100,11 @@ class HttpServerTlsTest { var ks = TestKeystores.build(dir, "sni.p12", "changeit", TestKeystores.Entry.of("a", "a.test"), TestKeystores.Entry.of("b", "b.test")); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.keystore(ks, "changeit")) .build()); + int port = app.port(); app.get("/ping", (req, res) -> "pong"); app.start(); @@ -143,11 +140,11 @@ class HttpServerTlsTest { @Test void mTls_requireRejectsClientWithNoCertificate(@TempDir java.nio.file.Path dir) throws Exception { var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.keystore(ks, "changeit").clientAuth(ClientAuth.REQUIRE)) .build()); + int port = app.port(); app.get("/ping", (req, res) -> "pong"); app.start(); @@ -180,11 +177,11 @@ class HttpServerTlsTest { SSLContext serverCtx = SSLContext.getInstance("TLS"); serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.REQUIRE)) .build()); + int port = app.port(); app.get("/ping", (req, res) -> "pong"); app.start(); @@ -219,12 +216,12 @@ class HttpServerTlsTest { @Test void multipleListeners_plainAndTlsServeTheSameApp(@TempDir java.nio.file.Path dir) throws Exception { var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); - int plainPort = freePort(); - int tlsPort = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .listener(new FlashConfiguration.Listener(plainPort, "127.0.0.1", null)) - .listener(new FlashConfiguration.Listener(tlsPort, "127.0.0.1", TlsConfig.keystore(ks, "changeit"))) + .listener(new FlashConfiguration.Listener(0, "127.0.0.1", null)) + .listener(new FlashConfiguration.Listener(0, "127.0.0.1", TlsConfig.keystore(ks, "changeit"))) .build()); + int plainPort = app.ports().get(0); + int tlsPort = app.ports().get(1); app.get("/ping", (req, res) -> "pong"); app.start(); @@ -287,11 +284,11 @@ class HttpServerTlsTest { SSLContext ctx = SSLContext.getInstance("TLS"); ctx.init(managers, null, null); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.ofContext(ctx)) .build()); + int port = app.port(); app.get("/ping", (req, res) -> "pong"); app.start(); @@ -364,11 +361,11 @@ class HttpServerTlsTest { SSLContext[] boxedCtx = new SSLContext[1]; RecordingKeyManager recorder = buildRecordingContext(ks, boxedCtx); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.ofContext(boxedCtx[0]).applicationProtocols("acme-tls/1", "http/1.1")) .build()); + int port = app.port(); app.get("/ping", (req, res) -> "pong"); app.start(); @@ -422,11 +419,11 @@ class HttpServerTlsTest { @Test void request_isSecureAndSessionAvailableOverTls(@TempDir java.nio.file.Path dir) throws Exception { var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.keystore(ks, "changeit")) .build()); + int port = app.port(); app.get("/secure-info", (req, res) -> { SSLSession session = req.sslSession(); return req.isSecure() + ":" + (session != null) + ":" + (session != null ? session.getCipherSuite() : ""); @@ -456,13 +453,13 @@ class HttpServerTlsTest { SSLContext serverCtx = SSLContext.getInstance("TLS"); serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") // OPTIONAL, not REQUIRE: proves getPeerCertificates() works without also // re-testing the REQUIRE-rejection path already covered elsewhere in this file. .tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.OPTIONAL)) .build()); + int port = app.port(); app.get("/secure-info", (req, res) -> { try { X509Certificate peer = (X509Certificate) req.sslSession().getPeerCertificates()[0]; @@ -488,8 +485,8 @@ class HttpServerTlsTest { @Test void request_isNotSecureAndSessionIsNullOnPlainListener() throws Exception { - int port = freePort(); - app = FlashApp.create(FlashConfiguration.builder().port(port).host("127.0.0.1").build()); + app = FlashApp.create(FlashConfiguration.builder().port(0).host("127.0.0.1").build()); + int port = app.port(); app.get("/secure-info", (req, res) -> req.isSecure() + ":" + (req.sslSession() == null)); app.start(); @@ -524,11 +521,11 @@ class HttpServerTlsTest { @Test void wss_sessionIsSecureAndExposesSslSession(@TempDir java.nio.file.Path dir) throws Exception { var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .tls(TlsConfig.keystore(ks, "changeit")) .build()); + int port = app.port(); AtomicReference observedSecure = new AtomicReference<>(); AtomicReference observedSession = new AtomicReference<>(); diff --git a/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java b/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java index d5eddd4..9d5e8d2 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java @@ -13,7 +13,6 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; -import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Base64; @@ -29,14 +28,11 @@ class HttpServerWebSocketTest { @BeforeEach void setUp() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - port = s.getLocalPort(); - } - app = FlashApp.create(FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .build()); + port = app.port(); app.ws("/chat", new WebSocketHandler() { @Override diff --git a/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java b/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java index ffa059a..9589e9b 100644 --- a/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java +++ b/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.net.ServerSocket; import static org.junit.jupiter.api.Assertions.*; @@ -20,14 +19,11 @@ class FlashAppWebSocketTest { @BeforeEach void setUp() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - port = s.getLocalPort(); - } - app = FlashApp.create(FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .build()); + port = app.port(); } @AfterEach diff --git a/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java index d269a74..ece753a 100644 --- a/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java @@ -32,7 +32,6 @@ class CurlInteropTest { @Test void tlsGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception { - int port = freePort(); Path keystore = TestKeystores.build( directory, @@ -43,23 +42,24 @@ class CurlInteropTest { FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .tls(TlsConfig.keystore(keystore, "changeit")) .http2Enabled(true) .build()); + int port = app.port(); exercise(directory, "https://localhost:" + port, "--http2", "--insecure"); } @Test void cleartextGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .build()); + int port = app.port(); exercise(directory, "http://127.0.0.1:" + port, "--http2-prior-knowledge"); } @@ -112,9 +112,4 @@ class CurlInteropTest { return result; } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java index 1d06280..71692ec 100644 --- a/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java @@ -28,14 +28,14 @@ class GrpcInteropTest { @Test void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .build()); + int port = app.port(); app.post("/flash.test.Echo/Unary", (request, response) -> response.type("application/grpc") .body(request.body().bytes()) @@ -156,11 +156,6 @@ class GrpcInteropTest { return count; } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } private record Result(int exitCode, String output) {} } diff --git a/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java b/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java index f0dee81..7b73b3e 100644 --- a/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java @@ -42,16 +42,16 @@ class H2LoadMeasurementTest { @Test void measureFlashAndNghttpdMatrix(@TempDir Path directory) throws Exception { - int flashPort = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(flashPort) + .port(0) .http2CleartextEnabled(true) .h2MaxStreamsCreatedPerInterval(Integer.MAX_VALUE) .h2MaxStreamsPerConnection(0) .build()); + int flashPort = app.port(); app.get("/index.html", (request, response) -> "flash-load"); app.start(); @@ -133,6 +133,10 @@ class H2LoadMeasurementTest { if (path != null) builder.environment().put("LD_LIBRARY_PATH", path); } + /** + * Still needed here: this port is handed to an external nghttpd process, which has no + * equivalent of {@code FlashApp.port()} to read an OS-assigned port back from. + */ private static int freePort() throws Exception { try (ServerSocket socket = new ServerSocket(0)) { return socket.getLocalPort(); diff --git a/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java b/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java index bbd29cb..40a7a9d 100644 --- a/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java @@ -36,14 +36,14 @@ class H2SpecComplianceTest { @Test void cleartextSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .build()); + int port = app.port(); registerProbeRoutes(); app.start(); @@ -52,7 +52,6 @@ class H2SpecComplianceTest { @Test void tlsSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception { - int port = freePort(); Path keystore = TestKeystores.build( directory, @@ -63,10 +62,11 @@ class H2SpecComplianceTest { FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .tls(TlsConfig.keystore(keystore, "changeit")) .http2Enabled(true) .build()); + int port = app.port(); registerProbeRoutes(); app.start(); @@ -145,11 +145,6 @@ class H2SpecComplianceTest { assertEquals(0, skipped.getLength(), output); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } private record ProcessResult(int exitCode, String output) {} } diff --git a/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java b/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java index fdd6186..a043eb5 100644 --- a/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java @@ -28,14 +28,14 @@ class H2cPriorKnowledgeTest { @Test void priorKnowledgeRequiresItsIndependentOptIn() throws Exception { - int disabledPort = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(disabledPort) + .port(0) .http2Enabled(true) .build()); + int disabledPort = app.port(); app.get("/", (request, response) -> "wrong protocol"); app.start(); @@ -47,14 +47,14 @@ class H2cPriorKnowledgeTest { } app.stop().join(); - int enabledPort = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(enabledPort) + .port(0) .http2CleartextEnabled(true) .build()); + int enabledPort = app.port(); app.get("/", (request, response) -> "h2c"); app.start(); @@ -125,9 +125,4 @@ class H2cPriorKnowledgeTest { payload); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java index c44a589..1c6bf96 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java @@ -183,15 +183,15 @@ class Http2AbuseTest { @Test void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception { - int port = freePort(); FlashApp app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .h2StreamIdleTimeoutMs(20) .build()); + int port = app.port(); app.post("/idle", (request, response) -> request.body().bytes()); app.start(); ByteWriter headers = new ByteWriter(64); @@ -295,11 +295,6 @@ class Http2AbuseTest { throw new AssertionError("missing " + expected); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } private record Run(List frames) { int lastGoAwayError() { diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java index 9793bf2..84c7096 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java @@ -29,16 +29,16 @@ class Http2ConcurrencyTest { @Test void thousandMultiplexedStreamsCompleteOnOneConnection() throws Exception { - int port = freePort(); AtomicInteger handled = new AtomicInteger(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .h2MaxStreamsCreatedPerInterval(2_000) .build()); + int port = app.port(); app.get("/work", (request, response) -> Integer.toString(handled.incrementAndGet())); app.start(); @@ -110,9 +110,4 @@ class Http2ConcurrencyTest { payload); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java index 733a9fb..0cfa99f 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java @@ -28,14 +28,14 @@ class Http2ConnectTest { @Test void connectTunnelCanExchangeDataBeforeEitherSideCloses() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .build()); + int port = app.port(); app.connect("tunnel", (request, response) -> response.type(ContentType.NONE).streaming(output -> { byte[] bytes = new byte[16]; @@ -100,9 +100,4 @@ class Http2ConnectTest { return text.getBytes(StandardCharsets.US_ASCII); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java index febbb76..13acd2b 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java @@ -48,7 +48,6 @@ class Http2ConnectionIntegrationTest { @Test void javaHttpClientUsesHttp2AgainstAnExistingParameterizedRoute(@TempDir Path directory) throws Exception { - int port = freePort(); Path keystore = TestKeystores.build( directory, @@ -58,11 +57,12 @@ class Http2ConnectionIntegrationTest { app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .tls(TlsConfig.keystore(keystore, "changeit")) .http2Enabled(true) .build()); + int port = app.port(); app.get( "/users/{id}", (request, response) -> request.param("id") + ":" + request.header("host")); app.start(); @@ -87,7 +87,6 @@ class Http2ConnectionIntegrationTest { @Test void javaHttpClientUploadsAndDownloadsFlowControlledBodies(@TempDir Path directory) throws Exception { - int port = freePort(); Path keystore = TestKeystores.build( directory, @@ -102,11 +101,12 @@ class Http2ConnectionIntegrationTest { app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .tls(TlsConfig.keystore(keystore, "changeit")) .http2Enabled(true) .build()); + int port = app.port(); app.post("/echo", (request, response) -> request.body().bytes()); app.get("/fixed", (request, response) -> response.body(download)); app.get( @@ -143,7 +143,6 @@ class Http2ConnectionIntegrationTest { @Test void pushStreamingAppliesBackpressureAcrossMultipleWindows(@TempDir Path directory) throws Exception { - int port = freePort(); int length = 2 * 1024 * 1024 + 31; Path keystore = TestKeystores.build( @@ -154,11 +153,12 @@ class Http2ConnectionIntegrationTest { app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .tls(TlsConfig.keystore(keystore, "changeit")) .http2Enabled(true) .build()); + int port = app.port(); app.get( "/push", (request, response) -> @@ -197,7 +197,6 @@ class Http2ConnectionIntegrationTest { @Test void hundredMegabyteUploadAndDownloadRemainStreaming(@TempDir Path directory) throws Exception { - int port = freePort(); long length = 100L * 1024 * 1024; Path keystore = TestKeystores.build( @@ -208,11 +207,12 @@ class Http2ConnectionIntegrationTest { app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .tls(TlsConfig.keystore(keystore, "changeit")) .http2Enabled(true) .build()); + int port = app.port(); app.post( "/upload", (request, response) -> { @@ -250,14 +250,14 @@ class Http2ConnectionIntegrationTest { @Test void bodylessGetRunsExistingRouteAndReturnsHeadersAndData() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .http2CleartextEnabled(true) .build()); + int port = app.port(); app.get("/api/ping", (request, response) -> "pong"); app.start(); @@ -314,15 +314,15 @@ class Http2ConnectionIntegrationTest { @Test void resetQueuedStreamCannotReleaseOrReuseItBeforeDispatchObservesCancellation() throws Exception { - int port = freePort(); AtomicInteger calls = new AtomicInteger(); app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .http2CleartextEnabled(true) .build()); + int port = app.port(); app.get( "/queued", (request, response) -> { @@ -384,15 +384,15 @@ class Http2ConnectionIntegrationTest { @Test void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception { - int port = freePort(); AtomicBoolean handlerEntered = new AtomicBoolean(); app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .http2CleartextEnabled(true) .build()); + int port = app.port(); app.get( "/", (request, response) -> { @@ -442,15 +442,15 @@ class Http2ConnectionIntegrationTest { @Test void serverStopInitiatesTwoStageGoAwayOnIdleConnection() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .http2CleartextEnabled(true) .shutdownDrainTimeoutMs(5_000) .build()); + int port = app.port(); app.start(); try (Socket socket = new Socket("127.0.0.1", port)) { @@ -485,14 +485,14 @@ class Http2ConnectionIntegrationTest { @Test void protocolStateDoesNotLeakAcrossConsecutiveConnections() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .http2CleartextEnabled(true) .build()); + int port = app.port(); app.start(); try (Socket first = new Socket("127.0.0.1", port)) { @@ -537,7 +537,6 @@ class Http2ConnectionIntegrationTest { @Test void tlsListenerOffersAndNegotiatesH2WhenHttp2IsEnabled(@TempDir Path directory) throws Exception { - int port = freePort(); Path keystore = TestKeystores.build( directory, @@ -547,11 +546,12 @@ class Http2ConnectionIntegrationTest { app = FlashApp.create( FlashConfiguration.builder() - .port(port) + .port(0) .host("127.0.0.1") .tls(TlsConfig.keystore(keystore, "changeit")) .http2Enabled(true) .build()); + int port = app.port(); app.start(); try (SSLSocket socket = @@ -630,11 +630,6 @@ class Http2ConnectionIntegrationTest { payload); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } private static String ascii(dev.relism.fpr.core.ByteView view) { byte[] bytes = new byte[view.length()]; diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java index e8f04c2..0b8b9c3 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java @@ -31,7 +31,6 @@ class Http2MisdirectedRequestTest { @Test void authorityOutsideSelectedCertificateReceives421(@TempDir Path directory) throws Exception { - int port = freePort(); Path keystore = TestKeystores.build( directory, @@ -42,10 +41,11 @@ class Http2MisdirectedRequestTest { FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .tls(TlsConfig.keystore(keystore, "changeit")) .http2Enabled(true) .build()); + int port = app.port(); app.get("/", (request, response) -> "must not run"); app.start(); @@ -114,9 +114,4 @@ class Http2MisdirectedRequestTest { } } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java index 9cfcce0..0445ecd 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java @@ -47,14 +47,14 @@ class Http2RegressionCorpusTest { @Test void headersOnHalfClosedRemoteStreamUseStreamErrorBeforeDispatch() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .build()); + int port = app.port(); app.get("/", (request, response) -> "ok"); app.start(); @@ -101,9 +101,4 @@ class Http2RegressionCorpusTest { payload); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java index cb8f827..8714f65 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java @@ -35,18 +35,18 @@ class Http2SoakTest { @Test void sustainedMixedTrafficRetainsBoundedHeapAndCompletesRequests() throws Exception { long seconds = Long.getLong("flash.http2.soak.seconds", 600L); - int port = freePort(); byte[] streamBody = new byte[8 * 1024]; Arrays.fill(streamBody, (byte) 's'); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .h2MaxStreamsCreatedPerInterval(100_000) .h2MaxStreamsPerConnection(0) .build()); + int port = app.port(); app.get("/get", (request, response) -> "get"); app.post("/post", (request, response) -> request.body().bytes()); app.get("/stream", (request, response) -> response.chunked(new ByteArrayInputStream(streamBody))); @@ -182,9 +182,4 @@ class Http2SoakTest { payload); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java index 880c2ae..dcf3c1e 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java @@ -27,14 +27,14 @@ class Http2TrailersTest { @Test void requestTrailersReachHandlerAfterBodyEof() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .build()); + int port = app.port(); app.post("/trailers", (request, response) -> { assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII)); return request.trailers().first("grpc-status"); @@ -99,14 +99,14 @@ class Http2TrailersTest { } private int startBlockingRoute() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .build()); + int port = app.port(); app.post("/trailers", (request, response) -> request.body().bytes()); app.start(); return port; @@ -152,9 +152,4 @@ class Http2TrailersTest { payload); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java index 46fc5d7..0b542f2 100644 --- a/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java @@ -38,9 +38,8 @@ class NghttpInteropTest { } private void exercise(Path directory, boolean tls) throws Exception { - int port = freePort(); FlashConfiguration.FlashConfigurationBuilder builder = - FlashConfiguration.builder().host("127.0.0.1").port(port); + FlashConfiguration.builder().host("127.0.0.1").port(0); if (tls) { Path keystore = TestKeystores.build( @@ -54,6 +53,7 @@ class NghttpInteropTest { } byte[] large = new byte[2 * 1024 * 1024 + 29]; app = FlashApp.create(builder.build()); + int port = app.port(); app.get("/get", (request, response) -> "nghttp-get"); app.post("/post", (request, response) -> "uploaded-" + request.body().bytes().length); app.get("/large", (request, response) -> response.body(large)); @@ -93,9 +93,4 @@ class NghttpInteropTest { assertTrue(trace.contains("recv DATA frame"), trace); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java b/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java index d269f51..8f1df4c 100644 --- a/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java +++ b/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java @@ -23,15 +23,15 @@ class WebSocketOverH2Test { @Test void opensEchoesFragmentsAndCarriesAMessageLargerThanTheFlowWindow() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .wsFrameBufferSize(2 * 1024 * 1024) .build()); + int port = app.port(); app.ws( "/chat", new WebSocketHandler() { @@ -67,9 +67,4 @@ class WebSocketOverH2Test { } } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java b/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java index fb120ae..c73d838 100644 --- a/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java @@ -28,14 +28,14 @@ class WebSocketParityTest { @Test void oneRouteAndHandlerEchoTheSameMessageOverHttp1AndHttp2() throws Exception { - int port = freePort(); app = FlashApp.create( FlashConfiguration.builder() .host("127.0.0.1") - .port(port) + .port(0) .http2CleartextEnabled(true) .build()); + int port = app.port(); app.ws( "/parity", new WebSocketHandler() { @@ -135,9 +135,4 @@ class WebSocketParityTest { return input.readNBytes(length); } - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java b/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java index e148835..b114284 100644 --- a/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java +++ b/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java @@ -28,22 +28,17 @@ class ServerLifecycleGracefulShutdownTest { if (app != null) app.stop(); } - private static int freePort() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - return s.getLocalPort(); - } - } @Test void inFlightRequest_completesWithConnectionClose_duringShutdown() throws Exception { - int port = freePort(); CountDownLatch handlerStarted = new CountDownLatch(1); CountDownLatch releaseHandler = new CountDownLatch(1); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .shutdownDrainTimeoutMs(5_000) .build()); + int port = app.port(); app.get("/slow", (req, res) -> { handlerStarted.countDown(); assertTrue(releaseHandler.await(5, TimeUnit.SECONDS)); @@ -80,11 +75,11 @@ class ServerLifecycleGracefulShutdownTest { @Test void stop_closesListener_soNewConnectionsAreRefused() throws Exception { - int port = freePort(); app = FlashApp.create(FlashConfiguration.builder() - .port(port).host("127.0.0.1") + .port(0).host("127.0.0.1") .shutdownDrainTimeoutMs(500) .build()); + int port = app.port(); app.get("/ping", (req, res) -> "pong"); app.start(); From c02459dd7c392ef54883baacff1386cb2a36e899 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 11:05:06 +0000 Subject: [PATCH 06/10] test(ext-mcp): migrate integration and security suites to flash-testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpExtensionIntegrationTest: 10 stateless JSON-RPC calls against one server config, so one class-scoped server replaces a boot per test. 117 to 85 code lines. McpExtensionSecurityTest: the four server configurations it exercises — AUTO without oidc, REQUIRED with a derived resource identifier, REQUIRED with an explicit one, and REQUIRED with advertised scopes — become four named servers sharing one FakeOidcProvider, replacing eight boots and a freePort() helper. 151 to 128 code lines. The boot-rejection test still builds its app directly: a harness whose job is to boot an app is the wrong tool for asserting that booting fails. Co-Authored-By: Claude Opus 5 --- .../ext/mcp/McpExtensionIntegrationTest.java | 71 ++---- .../ext/mcp/McpExtensionSecurityTest.java | 221 ++++++++---------- 2 files changed, 121 insertions(+), 171 deletions(-) diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java index 051af49..f0e760f 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java @@ -2,16 +2,10 @@ package dev.relism.flash.ext.mcp; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import dev.relism.flash.extension.FlashApp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import dev.relism.flash.testing.FlashResponse; +import dev.relism.flash.testing.FlashTest; import org.junit.jupiter.api.Test; - -import java.net.ServerSocket; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import org.junit.jupiter.api.extension.RegisterExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -22,34 +16,15 @@ class McpExtensionIntegrationTest { private static final ObjectMapper MAPPER = new ObjectMapper(); - private FlashApp app; - private String mcpUrl; - private HttpClient client; - - @BeforeEach - void setUp() throws Exception { - int port; - try (ServerSocket s = new ServerSocket(0)) { - port = s.getLocalPort(); - } - mcpUrl = "http://127.0.0.1:" + port + "/mcp"; - client = HttpClient.newHttpClient(); - - McpConfig config = McpConfig.builder("test-server") - .version("9.9.9") - .toolsPackage("dev.relism.flash.ext.mcp.fixtures") - .security(McpSecurity.NONE) - .build(); - - app = FlashApp.create(port); - app.install(new McpExtension(config)); - app.start(); - } - - @AfterEach - void tearDown() { - if (app != null) app.stop(); - } + // Every test here is a stateless JSON-RPC call against the same server, so one boot for + // the class rather than one per test. + @RegisterExtension + static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension( + McpConfig.builder("test-server") + .version("9.9.9") + .toolsPackage("dev.relism.flash.ext.mcp.fixtures") + .security(McpSecurity.NONE) + .build()))); @Test void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception { @@ -104,17 +79,13 @@ class McpExtensionIntegrationTest { } @Test - void notification_returns202WithEmptyBody() throws Exception { - String body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"; - HttpResponse resp = post(body); - assertEquals(202, resp.statusCode()); + void notification_returns202WithEmptyBody() { + post("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}").expectStatus(202); } @Test void malformedJson_returns400ParseError() throws Exception { - HttpResponse resp = post("not json"); - assertEquals(400, resp.statusCode()); - JsonNode json = MAPPER.readTree(resp.body()); + JsonNode json = MAPPER.readTree(post("not json").expectStatus(400).body()); assertEquals(-32700, json.get("error").get("code").asInt()); } @@ -128,16 +99,10 @@ class McpExtensionIntegrationTest { private JsonNode call(int id, String method, String paramsJson) throws Exception { String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}"; - HttpResponse resp = post(body); - assertEquals(200, resp.statusCode()); - return MAPPER.readTree(resp.body()); + return MAPPER.readTree(post(body).expectStatus(200).body()); } - private HttpResponse post(String body) throws Exception { - HttpRequest req = HttpRequest.newBuilder(URI.create(mcpUrl)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); - return client.send(req, HttpResponse.BodyHandlers.ofString()); + private FlashResponse post(String body) { + return mcp.request().json(body).post("/mcp"); } } diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java index ee040ad..111b5ea 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java @@ -3,16 +3,15 @@ package dev.relism.flash.ext.mcp; import dev.relism.flash.ext.oidc.OidcConfig; import dev.relism.flash.ext.oidc.OidcExtension; import dev.relism.flash.extension.FlashApp; -import org.junit.jupiter.api.AfterEach; +import dev.relism.flash.extension.FlashApplication; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.testing.FlashRequest; +import dev.relism.flash.testing.FlashResponse; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; -import java.net.ServerSocket; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -20,175 +19,161 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc} * installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256 * tokens — plus the fail-fast/degrade behavior when oidc is absent. + * + *

Four server configurations differ only in how MCP security is declared, so each gets its + * own {@link FlashTest} and they share one provider. */ class McpExtensionSecurityTest { private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures"; + private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp"; - private FlashApp app; - private FakeOidcProvider provider; + private static final FakeOidcProvider provider = newProvider(); - @AfterEach - void tearDown() { - if (app != null) app.stop(); - if (provider != null) provider.close(); + /** MCP asked for AUTO security with no oidc installed — should degrade to public. */ + @RegisterExtension + static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension( + McpConfig.builder("auto-server") + .toolsPackage(TOOLS_PACKAGE) + .security(McpSecurity.AUTO) + .build()))); + + /** REQUIRED with oidc, resource identifier derived from the request. */ + @RegisterExtension + static FlashTest secured = FlashTest.of(securedApp(null, null)); + + /** REQUIRED with oidc and an explicitly declared resource identifier. */ + @RegisterExtension + static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null)); + + /** REQUIRED with oidc and advertised scopes. */ + @RegisterExtension + static FlashTest securedWithScopes = + FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"})); + + @AfterAll + static void closeProvider() { + provider.close(); } + // ── No oidc installed ──────────────────────────────────────────────────── + @Test - void required_withoutOidc_throwsAtBoot() throws Exception { - int port = freePort(); - app = FlashApp.create(port); + void required_withoutOidc_throwsAtBoot() { + // Asserting that boot fails, so this one builds its app directly rather than through + // the harness; port(0) still removes the old free-port dance. + FlashApp app = FlashApp.create(FlashConfiguration.builder() + .port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build()); app.install(new McpExtension(McpConfig.builder("secure-server") .toolsPackage(TOOLS_PACKAGE) .security(McpSecurity.REQUIRED) .build())); - - assertThrows(IllegalStateException.class, () -> app.start()); + try { + assertThrows(IllegalStateException.class, app::start); + } finally { + app.stop().join(); + } } @Test - void auto_withoutOidc_degradesToPublic() throws Exception { - int port = freePort(); - app = FlashApp.create(port); - app.install(new McpExtension(McpConfig.builder("auto-server") - .toolsPackage(TOOLS_PACKAGE) - .security(McpSecurity.AUTO) - .build())); - app.start(); - - HttpResponse resp = post(port, initializeBody(), null); - assertEquals(200, resp.statusCode()); + void auto_withoutOidc_degradesToPublic() { + post(degraded, initializeBody(), null).expectStatus(200); } - @Test - void required_withOidc_rejectsMissingToken() throws Exception { - int port = bootSecuredApp(null); + // ── REQUIRED with oidc ─────────────────────────────────────────────────── - HttpResponse resp = post(port, initializeBody(), null); - assertEquals(401, resp.statusCode()); + @Test + void required_withOidc_rejectsMissingToken() { + post(secured, initializeBody(), null).expectStatus(401); } @Test void required_withOidc_rejectsWrongAudience() throws Exception { - int port = bootSecuredApp("https://mcp.example.com/mcp"); String token = provider.signToken("user-1", "https://someone-else.example.com/resource"); - HttpResponse resp = post(port, initializeBody(), token); - assertEquals(403, resp.statusCode()); + post(securedWithResourceId, initializeBody(), token).expectStatus(403); } @Test void required_withOidc_acceptsValidAudience() throws Exception { - String resourceId = "https://mcp.example.com/mcp"; - int port = bootSecuredApp(resourceId); - String token = provider.signToken("user-1", resourceId); + String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID); - HttpResponse resp = post(port, initializeBody(), token); - assertEquals(200, resp.statusCode()); - assertTrue(resp.body().contains("\"protocolVersion\"")); + post(securedWithResourceId, initializeBody(), token) + .expectStatus(200) + .expectBodyContains("\"protocolVersion\""); } @Test void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception { - int port = bootSecuredApp(null); - String derivedResourceId = "http://127.0.0.1:" + port + "/mcp"; + String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp"; - String matching = provider.signToken("user-1", derivedResourceId); - assertEquals(200, post(port, initializeBody(), matching).statusCode()); - - String mismatched = provider.signToken("user-1", "https://someone-else.example.com/resource"); - assertEquals(403, post(port, initializeBody(), mismatched).statusCode()); + post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId)) + .expectStatus(200); + post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource")) + .expectStatus(403); } @Test - void required_withOidc_missingToken_challengeIncludesResourceMetadata() throws Exception { - int port = bootSecuredApp(null); + void required_withOidc_missingToken_challengeIncludesResourceMetadata() { + FlashResponse response = post(secured, initializeBody(), null).expectStatus(401); - HttpResponse resp = post(port, initializeBody(), null); - assertEquals(401, resp.statusCode()); - String challenge = resp.headers().firstValue("WWW-Authenticate").orElse(""); - assertTrue(challenge.contains( - "resource_metadata=\"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp\""), + String challenge = response.header("WWW-Authenticate"); + assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:" + + secured.port() + "/.well-known/oauth-protected-resource/mcp\""), "WWW-Authenticate: " + challenge); } + // ── Protected resource metadata ────────────────────────────────────────── + @Test - void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() throws Exception { - int port = bootSecuredApp(null); + void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() { + FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp") + .expectStatus(200) + .expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"") + .expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"); - HttpResponse resp = HttpClient.newHttpClient().send( - HttpRequest.newBuilder(URI.create( - "http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(), - HttpResponse.BodyHandlers.ofString()); - - assertEquals(200, resp.statusCode()); - assertTrue(resp.body().contains("\"resource\":\"http://127.0.0.1:" + port + "/mcp\""), resp.body()); - assertTrue(resp.body().contains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"), resp.body()); - assertTrue(!resp.body().contains("scopes_supported"), "scopes_supported must be omitted when unset: " + resp.body()); + assertTrue(!response.body().contains("scopes_supported"), + "scopes_supported must be omitted when unset: " + response.body()); } @Test - void scopesSupported_published_inProtectedResourceMetadata() throws Exception { - provider = new FakeOidcProvider(); - int port = freePort(); - - app = FlashApp.create(port); - app.install(new OidcExtension(OidcConfig.builder( - provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); - app.install(new McpExtension(McpConfig.builder("secure-server") - .toolsPackage(TOOLS_PACKAGE) - .security(McpSecurity.REQUIRED) - .scopesSupported("openid", "profile", "email") - .build())); - app.start(); - - HttpResponse resp = HttpClient.newHttpClient().send( - HttpRequest.newBuilder(URI.create( - "http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(), - HttpResponse.BodyHandlers.ofString()); - - assertEquals(200, resp.statusCode()); - assertTrue(resp.body().contains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"), resp.body()); + void scopesSupported_published_inProtectedResourceMetadata() { + securedWithScopes.get("/.well-known/oauth-protected-resource/mcp") + .expectStatus(200) + .expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"); } // ── Helpers ────────────────────────────────────────────────────────────── - private int bootSecuredApp(String resourceIdentifier) throws Exception { - provider = new FakeOidcProvider(); - int port = freePort(); + private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) { + return app -> { + app.install(new OidcExtension(OidcConfig.builder( + provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); - OidcConfig oidcConfig = OidcConfig.builder( - provider.issuer(), "mcp-client", "secret", "/auth/callback") - .build(); + McpConfig.Builder mcp = McpConfig.builder("secure-server") + .toolsPackage(TOOLS_PACKAGE) + .security(McpSecurity.REQUIRED); + if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier); + if (scopesSupported != null) mcp.scopesSupported(scopesSupported); + app.install(new McpExtension(mcp.build())); + }; + } - var mcpBuilder = McpConfig.builder("secure-server") - .toolsPackage(TOOLS_PACKAGE) - .security(McpSecurity.REQUIRED); - if (resourceIdentifier != null) mcpBuilder.resourceIdentifier(resourceIdentifier); - - app = FlashApp.create(port); - app.install(new OidcExtension(oidcConfig)); - app.install(new McpExtension(mcpBuilder.build())); - app.start(); - return port; + private static FlashResponse post(FlashTest server, String body, String bearerToken) { + FlashRequest request = server.request().header("Accept", "application/json").json(body); + if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken); + return request.post("/mcp"); } private static String initializeBody() { return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"; } - private static int freePort() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - return s.getLocalPort(); + private static FakeOidcProvider newProvider() { + try { + return new FakeOidcProvider(); + } catch (Exception failure) { + throw new IllegalStateException("Could not start the fake OIDC provider", failure); } } - - private static HttpResponse post(int port, String body, String bearerToken) throws Exception { - HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(body)); - if (bearerToken != null) req.header("Authorization", "Bearer " + bearerToken); - return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString()); - } } From 7785712efe402bd8894a43cffabd626cce419adc Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 11:05:06 +0000 Subject: [PATCH 07/10] test(ext-web-bundler): migrate integration test to flash-testing Two frontend layouts, each laid out on disk inside its own application's configure(). The harness runs that lazily at first access, so @TempDir is populated by then and the server for whichever test is not running never boots. Replaces three blocks of HttpRequest.newBuilder(URI.create(...)) per test with single-line assertions. 98 to 60 code lines. Co-Authored-By: Claude Opus 5 --- .../flash-ext-web-bundler/pom.xml | 5 + .../WebBundlerExtensionIntegrationTest.java | 147 +++++++----------- 2 files changed, 58 insertions(+), 94 deletions(-) diff --git a/flash-extensions/flash-ext-web-bundler/pom.xml b/flash-extensions/flash-ext-web-bundler/pom.xml index 6fb24c2..45ffc5a 100644 --- a/flash-extensions/flash-ext-web-bundler/pom.xml +++ b/flash-extensions/flash-ext-web-bundler/pom.xml @@ -33,5 +33,10 @@ org.junit.jupiter junit-jupiter + + dev.relism + flash-testing + test + diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java index df7c443..6060fa4 100644 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java +++ b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java @@ -1,122 +1,81 @@ package dev.relism.flash.ext.webbundler; -import dev.relism.flash.extension.FlashApp; -import org.junit.jupiter.api.AfterEach; +import dev.relism.flash.testing.FlashTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.io.TempDir; -import java.net.ServerSocket; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - +/** + * Two frontend layouts served by a real server. Each is laid out on disk inside the + * application's own configure(), which the harness runs lazily at first access — by then + * {@link TempDir} is populated, and a server whose test never runs is never booted. + */ class WebBundlerExtensionIntegrationTest { + @TempDir - Path tempDir; + static Path tempDir; - private FlashApp app; + @RegisterExtension + static FlashTest managed = FlashTest.of(app -> { + Path webRoot = write(tempDir.resolve("web").resolve("dist"), + "index.html", "spa", + "app.js", "console.log('ok');").getParent(); - @AfterEach - void tearDown() { - if (app != null) app.stop(); - } - - @Test - void prodMode_servesAssetsAndFallback_withoutBreakingBackendRoutes() throws Exception { - Path webRoot = tempDir.resolve("web"); - Path dist = webRoot.resolve("dist"); - Files.createDirectories(dist); - Files.writeString(dist.resolve("index.html"), "spa"); - Files.writeString(dist.resolve("app.js"), "console.log('ok');"); - - int port; - try (ServerSocket s = new ServerSocket(0)) { - port = s.getLocalPort(); - } - - WebBundlerConfig config = WebBundlerConfig.builder() + app.install(new WebBundlerExtension(WebBundlerConfig.builder() .runtimeMode(RuntimeMode.PROD) .operationMode(OperationMode.MANAGED) .webRoot(webRoot) .assetsFromFilesystem(Path.of("dist")) .basePath("/app") - .build(); - - app = FlashApp.create(port); - app.install(new WebBundlerExtension(config)); + .build())); app.get("/api/ping", (req, res) -> "pong"); - app.start(); + }); - HttpClient client = HttpClient.newHttpClient(); - HttpResponse backend = client.send( - HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/ping")).GET().build(), - HttpResponse.BodyHandlers.ofString() - ); - assertEquals(200, backend.statusCode()); - assertEquals("pong", backend.body()); + // PROD is deterministic in a test JVM (Flash.DEV depends on env/system-property detection + // that can't be forced per-test); STATIC's actual guarantee — that it never orchestrates, + // in DEV or PROD — is enforced structurally by the same requiresOrchestration() gate in + // both WebBundlerExtension.provide() and .routes(), not by this test. + @RegisterExtension + static FlashTest staticFrontend = FlashTest.of(app -> { + Path webRoot = write(tempDir.resolve("public"), + "index.html", "static", + "style.css", "body{color:red}"); - HttpResponse asset = client.send( - HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/app/app.js")).GET().build(), - HttpResponse.BodyHandlers.ofString() - ); - assertEquals(200, asset.statusCode()); - assertTrue(asset.body().contains("console.log")); - - HttpResponse fallback = client.send( - HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/app/some/client/route")).GET().build(), - HttpResponse.BodyHandlers.ofString() - ); - assertEquals(200, fallback.statusCode()); - assertTrue(fallback.body().contains("spa")); - } - - @Test - void staticFrontend_servesAssetsWithoutOrchestration() throws Exception { - Path webRoot = tempDir.resolve("public"); - Files.createDirectories(webRoot); - Files.writeString(webRoot.resolve("index.html"), "static"); - Files.writeString(webRoot.resolve("style.css"), "body{color:red}"); - - int port; - try (ServerSocket s = new ServerSocket(0)) { - port = s.getLocalPort(); - } - - // PROD is deterministic in a test JVM (Flash.DEV depends on env/system-property detection - // that can't be forced per-test); STATIC's actual guarantee — that it never orchestrates, - // in DEV or PROD — is enforced structurally by the same requiresOrchestration() gate in - // both WebBundlerExtension.provide() and .routes(), not by this test. - WebBundlerConfig config = WebBundlerConfig.builder() + app.install(new WebBundlerExtension(WebBundlerConfig.builder() .runtimeMode(RuntimeMode.PROD) .frontendType(FrontendType.STATIC) .webRoot(webRoot) - .build(); - - app = FlashApp.create(port); - app.install(new WebBundlerExtension(config)); + .build())); app.get("/api/ping", (req, res) -> "pong"); - app.start(); + }); - HttpClient client = HttpClient.newHttpClient(); - HttpResponse backend = client.send( - HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/ping")).GET().build(), - HttpResponse.BodyHandlers.ofString() - ); - assertEquals(200, backend.statusCode()); - assertEquals("pong", backend.body()); - - HttpResponse asset = client.send( - HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/style.css")).GET().build(), - HttpResponse.BodyHandlers.ofString() - ); - assertEquals(200, asset.statusCode()); - assertTrue(asset.body().contains("color:red")); + @Test + void prodMode_servesAssetsAndFallback_withoutBreakingBackendRoutes() { + managed.get("/api/ping").expectStatus(200).expectBody("pong"); + managed.get("/app/app.js").expectStatus(200).expectBodyContains("console.log"); + managed.get("/app/some/client/route").expectStatus(200).expectBodyContains("spa"); } + @Test + void staticFrontend_servesAssetsWithoutOrchestration() { + staticFrontend.get("/api/ping").expectStatus(200).expectBody("pong"); + staticFrontend.get("/style.css").expectStatus(200).expectBodyContains("color:red"); + } + + /** Creates {@code directory} and writes the given name/content pairs into it. */ + private static Path write(Path directory, String... nameThenContent) { + try { + Files.createDirectories(directory); + for (int i = 0; i < nameThenContent.length; i += 2) + Files.writeString(directory.resolve(nameThenContent[i]), nameThenContent[i + 1]); + return directory; + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + } } From 58bae41f7a095d72fbd155995862bfc3ee4fb0ed Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 12:07:55 +0000 Subject: [PATCH 08/10] docs(testing): document flash-testing and the limits it deliberately keeps README covers the application handle, requests and assertions, service replacement, multi-server wiring, scope, WebSockets, configuration and the teardown ordering. limits.md records the seven things the harness cannot do and what to use for each: TLS, HTTP/2, WebSocket over HTTP/2, malformed requests, response framing, the flash core module's dependency cycle, and scoped services. Each is a consequence of a real constraint rather than an unfinished feature, so writing them down stops the next person rediscovering them one at a time. Co-Authored-By: Claude Opus 5 --- README.md | 4 + flash-testing/docs/README.md | 195 +++++++++++++++++++++++++++++++++++ flash-testing/docs/limits.md | 79 ++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 flash-testing/docs/README.md create mode 100644 flash-testing/docs/limits.md diff --git a/README.md b/README.md index 99661cc..84cf184 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ See extension-specific READMEs for full details: - [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) +- [`flash-testing`](flash-testing/docs/README.md) ## Error handlers @@ -482,6 +483,9 @@ That is stock JUnit field semantics — the isolation switch is the keyword, not ### Configuration +Full reference: [`flash-testing/docs`](flash-testing/docs/README.md), including the +[limits](flash-testing/docs/limits.md) the harness deliberately does not cross. + `profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port and the shutdown drain window are stamped afterwards, so a profile cannot break the harness; `listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives diff --git a/flash-testing/docs/README.md b/flash-testing/docs/README.md new file mode 100644 index 0000000..47c3d51 --- /dev/null +++ b/flash-testing/docs/README.md @@ -0,0 +1,195 @@ +# flash-testing + +JUnit 5 harness for testing Flash applications. Boots a real app on an OS-assigned port, +swaps services for fakes, and asserts on responses. No mocking library, no assertion library — +`flash` and `junit-jupiter-api`, nothing else. + +## What it provides + +| Component | Description | +|---|---| +| `FlashTest` | JUnit 5 extension — owns one app's lifecycle and hands you a client | +| `FlashRequest` | Header/body builder; the HTTP verb is terminal and sends | +| `FlashResponse` | Chainable assertions that report the real response on failure | +| `FlashWebSocket` | WebSocket client over `java.net.http`, with a queue and timeouts | + +## Dependency + +```xml + + dev.relism + flash-testing + ${flash.version} + test + +``` + +## Quick start + +```java +class UserRoutesTest { + + @RegisterExtension + static FlashTest app = FlashTest.of(new BlogApp()) + .mock(UserService.class, new InMemoryUserService()); + + @Test + void listsUsers() { + app.get("/api/users") + .expectStatus(200) + .expectHeader("content-type", "application/json") + .expectBodyContains("alice"); + } +} +``` + +## The application under test + +`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services declared +independently of the port they run on: + +```java +public final class BlogApp implements FlashApplication { + @Override public void configure(FlashApp app) { + app.install(new JacksonExtension()); + app.mount("/api", scope -> scope.scan("dev.blog.api")); + app.ws("/live", new FeedSocket()); + } +} + +FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production +``` + +It takes `FlashApp` rather than `FlashRegistrar` deliberately: `ws()` and `mount()` live there, +and an application that could not register a WebSocket route or a mounted namespace would be a +half-application. + +It is a `@FunctionalInterface`, so a lambda and a named class are the same thing: + +```java +FlashTest.of(app -> app.get("/ping", (req, res) -> "pong")) +``` + +## Requests + +`get` and `delete` skip the builder when there is nothing to add. Everything else goes through +`request()`, where the HTTP verb sends: + +```java +app.get("/api/users").expectStatus(200); + +app.request() + .header("Authorization", "Bearer " + token) + .json("{\"name\":\"bob\"}") + .post("/api/users") + .expectStatus(201); +``` + +`json(...)` sets the body and `content-type: application/json`. `send(method, path)` reaches any +verb, including the ones Flash adds beyond RFC 9110 (`PURGE`, `QUERY`). + +Assertions chain, and every failure message carries the request line, status and body: + +``` +expected: <200> but was: <404> + request: GET /api/users + status: 404 + body: {"status":404,"error":"Not Found"} +``` + +`status()`, `body()`, `headers()` and `header(name)` are there for anything the assertions do +not cover. + +## Replacing services + +`mock` installs its replacements as the **last** extension, after everything the application and +its own extensions declare, so a fake always wins: + +```java +FlashTest.of(new BlogApp()) + .mock(UserService.class, new InMemoryUserService()) + .mock(Clock.class, Clock.fixed(instant, ZoneOffset.UTC)); +``` + +Any object is accepted — a hand-written fake, or a Mockito mock you created yourself. This module +depends on no mocking library. + +Prefer a constructor parameter when the application already takes one: +`new AdminApi(upstreamUri)` needs no override at all. `mock` is for what you do not control — +services declared inside `configure`, or provided by an installed extension. + +Calling `mock` after the server has started throws. + +## More than one server + +`FlashTest` is an ordinary object in a field, so a class can hold as many as it needs and wire one +from another in plain Java. Booting is lazy — reading `baseUri()` starts that server on the spot — +so declaration order does the wiring, with no `@Order` and no reliance on JUnit's extension +ordering: + +```java +@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp()); +@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri())); +``` + +Because binding happens when the app is created rather than when it starts, an application can +even be configured against its own address: + +```java +FlashTest.of(app -> app.install(new McpExtension( + McpConfig.builder("srv").resourceIdentifier("http://127.0.0.1:" + app.port() + "/mcp").build()))); +``` + +A server nothing touches is never booted. + +## Scope + +A `static` field boots once for the test class; a non-static field boots a fresh app for every +test. That is stock JUnit field semantics — the isolation switch is the keyword, not an option: + +```java +@RegisterExtension static FlashTest shared = FlashTest.of(new BlogApp()); // one boot per class +@RegisterExtension FlashTest fresh = FlashTest.of(new BlogApp()); // one boot per test +``` + +A class-scoped server shares state across its tests, including anything a `mock` fake accumulates. +Reset it in `@BeforeEach`, or use an instance field. + +## WebSockets + +```java +try (FlashWebSocket socket = app.ws("/live")) { + socket.sendText("hello"); + assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2))); +} +``` + +Incoming text is queued as it arrives, so a message that lands before `awaitText` is called is not +lost. `awaitClose(timeout)` returns the server's close status code. `close()` never throws — a +connection the server already dropped must not mask the failure the test was reporting. + +## Configuration + +`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes: + +```java +FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true)); +``` + +Host, port and the shutdown drain window are stamped **after** the profile runs, so it cannot +break the harness. `listener(...)` and `tls(...)` are rejected — see [limits](limits.md). + +## Teardown + +The client is cancelled before the server stops. `HttpClient` holds keep-alive sockets open and +`ServerLifecycle.stop()` spins until the last one closes or the drain window expires, so the +default 15s drain would otherwise be paid on every test class. The harness pins it to 250ms and +uses `shutdownNow()` rather than `close()`, which blocks until every operation completes and would +hang on a leaked WebSocket. + +Service cleanup registered with `FlashContext.onClose` runs after the drain, so a pooled +`DataSource` is released between test classes rather than at JVM exit. + +## See also + +- [limits.md](limits.md) — what the harness deliberately cannot do, and what to use instead diff --git a/flash-testing/docs/limits.md b/flash-testing/docs/limits.md new file mode 100644 index 0000000..01cfdd5 --- /dev/null +++ b/flash-testing/docs/limits.md @@ -0,0 +1,79 @@ +# Limits + +What `flash-testing` deliberately cannot do, why, and what to use instead. Each of these is a +consequence of a real constraint, not an unfinished feature. + +## TLS + +`profile(cfg -> cfg.tls(...))` is rejected. The harness serves plaintext on loopback and hands you +a client bound to `http://127.0.0.1:`; a TLS listener would leave that base URI pointing at +the wrong scheme, and the client would need the test certificate in a trust store. + +**Instead:** build the app directly with `FlashApp.create(...)` and a raw `SSLSocket`, as +`HttpServerTlsTest` does. `port(0)` plus `FlashApp.port()` still removes the free-port dance. + +Lifting this is the only limit here worth reconsidering, and only if application-level TLS testing +is actually wanted — transport-level TLS is already covered by the core suite. + +## HTTP/2 + +`java.net.http` reaches cleartext h2 through an `Upgrade:` handshake. Flash implements HTTP/2 +cleartext by **prior knowledge only** — a deliberate choice recorded in the root README — so the +harness client cannot negotiate h2 against a plaintext Flash listener. Over TLS it would work +through ALPN, but TLS is unavailable per the section above. + +**Instead:** the `flash/src/test/.../http2/` suites drive h2 frames over raw sockets. That is the +right tool for protocol behaviour anyway. + +## WebSocket over HTTP/2 + +`java.net.http.WebSocket` does not negotiate RFC 8441 extended CONNECT, so `FlashWebSocket` always +speaks the HTTP/1.1 upgrade. Flash supports both, but this client can only exercise one. + +**Instead:** `WebSocketOverH2Test` and `WebSocketParityTest` frame extended CONNECT by hand. + +## Malformed requests + +Every request goes through `java.net.http`, which structurally cannot emit an invalid request +line, a bad header block, a smuggled `Content-Length`, or a chunked *request* body on demand. That +is a feature for application testing and a blocker for parser testing. + +**Instead:** `RequestParserSecurityTest`, `RequestParserFuzzTest` and the raw-socket half of +`HttpServerTest` write bytes directly. A harness that could send malformed requests would just be +a socket. + +## Response framing + +`java.net.http` transparently decodes chunked responses and hides connection reuse, so +`Transfer-Encoding: chunked` and `Connection: keep-alive` are not observable through +`FlashResponse`. + +**Instead:** `HttpServerTest` keeps raw sockets for exactly those assertions, taking its port from +a `FlashTest` field so the class still boots once. Mixing the two styles in one class is the +intended pattern, not a workaround. + +## The `flash` core module + +`flash-testing` depends on `flash`, so `flash`'s own tests cannot depend on `flash-testing` — +Maven rejects module cycles regardless of scope. + +**Instead:** core tests use `FlashApp.create(...)` with `port(0)` and read `port()` back. Every +core suite already does this. Unblocking it would need a third module depending on both, which is +not worth it for the two suites that would benefit. + +## Scoped services + +`mock` writes to the app's root `FlashContext`. `FlashContext.require` checks its own bindings +before its parent's, so a service declared inside a `mount(...)` scope's child context shadows the +root and is **not** reachable from `mock`. + +**Instead:** declare the service on the app rather than inside the scope, or assert against the +real one. Child-context targeting would be a small addition if a scoped service ever needs faking. + +## Shutdown draining + +The harness pins `shutdownDrainTimeoutMs` to 250ms after any profile runs, so a test cannot +exercise graceful-drain behaviour through it. + +**Instead:** `ServerLifecycleGracefulShutdownTest` builds its app directly. A profile escape hatch +would be easy to add if this ever comes up twice. From fe8c6ed162613269bcc7fc6fa6ef27f2a29f8a0b Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 12:20:33 +0000 Subject: [PATCH 09/10] fix(core): honour HttpException status in the default exception handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpException carries the status the caller meant, and its own javadoc says extensions map it to a structured response — but nothing did. Every one reached the catch-all and came back as 500, including the 400s that RequestHelper raises for a malformed query param and that flash-ext-jackson raises for an unparseable body. A handler doing the documented thing produced the wrong status. The default handler now renders HttpException at its own status, in both dev and prod modes, with the message JSON-escaped. Not pre-encoded like JSON_404 and JSON_500: the message is per-exception, and a path that already unwound a stack does not need the allocation shaved. Co-Authored-By: Claude Opus 5 --- .../relism/flash/routing/AbstractRouter.java | 40 +++++++++++++++++++ .../flash/routing/AbstractRouterTest.java | 23 +++++++++++ 2 files changed, 63 insertions(+) diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java index 4473359..dc4e5ae 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java @@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp; import dev.relism.flash.models.*; import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; import dev.relism.flash.Flash; +import dev.relism.flash.exceptions.HttpException; import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.template.ErrorPages; @@ -56,17 +57,56 @@ public abstract class AbstractRouter { protected ExceptionHandler exceptionHandler = Flash.DEV ? (ex, req, res) -> { + if (ex instanceof HttpException http) return renderHttpException(http, res); res.status(500); res.type(ContentType.TEXT_HTML); return ErrorPages.renderException(req, ex); } : (ex, req, res) -> { + if (ex instanceof HttpException http) return renderHttpException(http, res); log.error("Unhandled exception in {} {}", req.method(), req.path(), ex); res.status(500); res.type(ContentType.JSON); return JSON_500; }; + /** + * {@link HttpException} carries the status the caller meant; without this it reached the + * catch-all above and every one of them came back as 500 — including the 400s + * {@code RequestHelper} and {@code flash-ext-jackson} raise for malformed input. + * + *

Deliberately not pre-encoded like {@link #JSON_404}: the message is per-exception, and + * an error path that already unwound a stack does not need the allocation shaved. + */ + private static byte[] renderHttpException(HttpException failure, Response res) { + res.status(failure.status()); + res.type(ContentType.JSON); + String message = failure.getMessage(); + StringBuilder out = new StringBuilder(48 + (message == null ? 0 : message.length())); + out.append("{\"error\":\""); + escapeJson(message == null ? "" : message, out); + out.append("\",\"status\":").append(failure.status()).append('}'); + return out.toString().getBytes(StandardCharsets.UTF_8); + } + + /** Minimal RFC 8259 string escaping — enough for an exception message. */ + private static void escapeJson(String text, StringBuilder out) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20) out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } + } + } + } + public SimpleHandler getNotFoundHandler() { return notFoundHandler; } public ExceptionHandler getExceptionHandler() { return exceptionHandler; } diff --git a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java index ede5000..9871486 100644 --- a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java +++ b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java @@ -1,5 +1,7 @@ package dev.relism.flash.routing; +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestHandler; @@ -7,6 +9,8 @@ import dev.relism.flash.models.Response; import dev.relism.flash.models.SimpleHandler; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; + import static org.junit.jupiter.api.Assertions.*; class AbstractRouterTest { @@ -77,4 +81,23 @@ class AbstractRouterTest { router.onException((ex, req, res) -> "Caught"); assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null)); } + + + /** + * HttpException carries the status the caller meant. Before this was honoured every one of + * them came back as 500, including the 400s RequestHelper and flash-ext-jackson raise. + */ + @Test + void defaultExceptionHandlerHonoursHttpExceptionStatus() throws Exception { + DummyRouter router = new DummyRouter(); + Response res = new Response(200, ContentType.TEXT_PLAIN); + + Object body = router.getExceptionHandler() + .handle(HttpException.badRequest("bad \"input\""), null, res); + + assertEquals(400, res.getStatusCode()); + String rendered = new String((byte[]) body, StandardCharsets.UTF_8); + assertTrue(rendered.contains("\\\"input\\\""), "message must be JSON-escaped: " + rendered); + assertTrue(rendered.contains("\"status\":400"), rendered); + } } From f68e66129612d19ca1cedc94c0a6978cce256302 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 12:20:33 +0000 Subject: [PATCH 10/10] feat(ext-validation): add request validation with compiled constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard jakarta.validation annotations, compiled once per type into a flat check table. No configuration: constraints come from the annotations already on your types, and ValidationException extends HttpException with status 422 so the default handler renders it without this extension registering anything. record CreateUser(@NotBlank @Size(max = 80) String name, @Email String email, @Min(18) int age) {} CreateUser dto = validation.body(req, CreateUser.class); Annotations only — Hibernate Validator's engine is deliberately absent. It resolves constraints reflectively per call and pulls ~2 MB plus EL, which is the per-request cost this module exists to avoid. jakarta.validation-api is ~90 KB of annotations. The passing path allocates nothing. Constraints resolve at first use into an opcode plus operands cached in a ClassValue, so there is no map lookup and no lock. Fields are read through MethodHandles adapted to an exact signature — (Object)Object for references, (Object)long for primitive integrals — so invokeExact neither boxes nor builds the argument array Field.get and Method.invoke allocate. Checks are a flat array walked by a tableswitch rather than a class hierarchy behind a virtual call. @Size reads a length the object already knows and @Email scans with indexOf, because Pattern.matcher allocates a matcher and two int arrays per call. Messages are pre-rendered at compile time. The violation list and the exception exist only once something fails. @Pattern is the marked exception: its regex compiles once but matcher() allocates per call. Constraints are read from declared fields, so records and plain classes take one code path — a constraint on a record component propagates to its backing field. Jakarta null semantics are exact: only @NotNull rejects null. flash-ext-openapi now mirrors the same annotations into the generated schema — minLength, maxLength, minItems, minimum, maximum, pattern, format: email and required — via an optional jakarta.validation dependency detected at boot. A type declares its rules once and both the validator and the published contract read them. An explicit @Schema still wins; the bridge only fills keys nobody set, and without the annotations on the classpath the bridge class is never loaded. flash-ext-jackson is optional too: validate(value) works without it, only body(req, type) needs a codec. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- flash-extensions/flash-ext-openapi/pom.xml | 9 + .../flash/ext/openapi/ConstraintHints.java | 84 +++++++ .../flash/ext/openapi/OpenApiBuilder.java | 11 +- .../flash-ext-validation/docs/README.md | 143 ++++++++++++ flash-extensions/flash-ext-validation/pom.xml | 51 +++++ .../relism/flash/ext/validation/Check.java | 76 ++++++ .../flash/ext/validation/Validation.java | 69 ++++++ .../ext/validation/ValidationException.java | 39 ++++ .../ext/validation/ValidationExtension.java | 37 +++ .../flash/ext/validation/Validator.java | 216 ++++++++++++++++++ .../ValidationOpenApiInteropTest.java | 68 ++++++ .../ext/validation/ValidationRoutesTest.java | 69 ++++++ .../flash/ext/validation/ValidatorTest.java | 117 ++++++++++ flash-extensions/pom.xml | 6 + pom.xml | 11 + 16 files changed, 1006 insertions(+), 2 deletions(-) create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java create mode 100644 flash-extensions/flash-ext-validation/docs/README.md create mode 100644 flash-extensions/flash-ext-validation/pom.xml create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java create mode 100644 flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java create mode 100644 flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java create mode 100644 flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java diff --git a/AGENTS.md b/AGENTS.md index 6ee7f45..11aa19e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Format: `(): ` Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, `ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, -`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. +`ext-mcp`, `ext-validation`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. Examples: ``` diff --git a/flash-extensions/flash-ext-openapi/pom.xml b/flash-extensions/flash-ext-openapi/pom.xml index a3ba1fe..2aae4e7 100644 --- a/flash-extensions/flash-ext-openapi/pom.xml +++ b/flash-extensions/flash-ext-openapi/pom.xml @@ -29,6 +29,15 @@ org.projectlombok lombok + + + jakarta.validation + jakarta.validation-api + true + org.junit.jupiter junit-jupiter diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java new file mode 100644 index 0000000..a92a8aa --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java @@ -0,0 +1,84 @@ +package dev.relism.flash.ext.openapi; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +/** + * Mirrors {@code jakarta.validation} constraints into the generated schema, so a type carries its + * rules once and both the validator and the published contract read them. + * + *

Loaded reflectively by {@link OpenApiBuilder} and used only when the annotations are on the + * classpath — this class is never touched otherwise, so {@code flash-ext-openapi} keeps working + * with no validation dependency at all. Nothing to install and nothing to configure: if the + * annotations are there, the schema gains {@code minLength}, {@code maximum}, {@code format} and + * {@code required} on its own. + */ +final class ConstraintHints { + + private ConstraintHints() {} + + /** True when jakarta.validation is resolvable, so the caller may use this class. */ + static boolean available() { + try { + Class.forName("jakarta.validation.constraints.NotNull", false, ConstraintHints.class.getClassLoader()); + return true; + } catch (Throwable absent) { + return false; + } + } + + /** + * Merges {@code field}'s constraints into {@code property}, and reports whether the field is + * required. Never overwrites a key an explicit {@code @Schema} already set. + */ + static boolean apply(Field field, Map property) { + boolean isString = "string".equals(property.get("type")); + + Size size = field.getAnnotation(Size.class); + if (size != null) { + if (isString) { + if (size.min() > 0) property.putIfAbsent("minLength", size.min()); + if (size.max() != Integer.MAX_VALUE) property.putIfAbsent("maxLength", size.max()); + } else if ("array".equals(property.get("type"))) { + if (size.min() > 0) property.putIfAbsent("minItems", size.min()); + if (size.max() != Integer.MAX_VALUE) property.putIfAbsent("maxItems", size.max()); + } + } + + Min min = field.getAnnotation(Min.class); + if (min != null) property.putIfAbsent("minimum", min.value()); + + Max max = field.getAnnotation(Max.class); + if (max != null) property.putIfAbsent("maximum", max.value()); + + if (field.isAnnotationPresent(Email.class)) property.putIfAbsent("format", "email"); + + Pattern pattern = field.getAnnotation(Pattern.class); + if (pattern != null) property.putIfAbsent("pattern", pattern.regexp()); + + if (field.isAnnotationPresent(NotBlank.class) && isString) property.putIfAbsent("minLength", 1); + if (field.isAnnotationPresent(NotEmpty.class)) { + if (isString) property.putIfAbsent("minLength", 1); + else if ("array".equals(property.get("type"))) property.putIfAbsent("minItems", 1); + } + + return field.isAnnotationPresent(NotNull.class) + || field.isAnnotationPresent(NotBlank.class) + || field.isAnnotationPresent(NotEmpty.class); + } + + /** Constraint annotations this bridge understands, for documentation and tests. */ + static List supported() { + return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern"); + } +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index 07a52b1..a420587 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -365,6 +365,9 @@ public final class OpenApiBuilder { return null; } + /** Resolved once: jakarta.validation is an optional dependency of this module. */ + private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available(); + private static final class SchemaRegistry { private static final Set> SIMPLE = Set.of( String.class, CharSequence.class, @@ -476,8 +479,14 @@ public final class OpenApiBuilder { if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true); } + // Constraints declared for flash-ext-validation also describe the contract, so + // mirror them here rather than making callers restate every rule as @Schema. + boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property); + properties.put(name, property); - if ((ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) required.add(name); + if (constrainedRequired + || (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) + required.add(name); } if (!properties.isEmpty()) out.put("properties", properties); diff --git a/flash-extensions/flash-ext-validation/docs/README.md b/flash-extensions/flash-ext-validation/docs/README.md new file mode 100644 index 0000000..09f5076 --- /dev/null +++ b/flash-extensions/flash-ext-validation/docs/README.md @@ -0,0 +1,143 @@ +# flash-ext-validation + +Request validation for Flash. Standard `jakarta.validation` annotations, compiled once per type +into a flat check table, with zero allocation on the passing path. + +## What it provides + +| Component | Description | +|---|---| +| `Validation` | The service — `body(req, type)` parses and verifies, `validate(value)` verifies | +| `Validator` | One type's compiled constraints; reusable and thread-safe | +| `ValidationException` | 422 carrying every violation, not just the first | + +## Dependency + +```xml + + dev.relism + flash-ext-validation + ${flash.version} + +``` + +## Quick start + +```java +FlashApp.create(8080) + .install(new JacksonExtension()) + .install(new ValidationExtension()) + .scan("dev.example.api"); +``` + +```java +public record CreateUser( + @NotBlank @Size(max = 80) String name, + @Email String email, + @Min(18) int age) {} +``` + +```java +@POST("/api/users") +public final class CreateUserHandler extends RequestHandler { + + private Validation validation; + private UserService users; + + @Override protected void onInit() { + validation = require(Validation.class); + users = require(UserService.class); + } + + @Override public Object handle(Request req, Response res) throws Exception { + CreateUser dto = validation.body(req, CreateUser.class); + return res.status(201).body(users.create(dto)); + } +} +``` + +There is nothing to configure. Constraints come from the annotations already on your types, and +failures reach the client as `422` on their own — see [Error responses](#error-responses). + +## Supported constraints + +`@NotNull` · `@NotBlank` · `@NotEmpty` · `@Size` · `@Min` · `@Max` · `@Email` · `@Pattern` + +Jakarta null semantics are honoured exactly: **only `@NotNull` rejects null**. Every other +constraint passes a null value, so `@Email String email` means "if present, must look like an +email" — combine with `@NotNull` when it is mandatory. + +`@Size` applies to `CharSequence`, `Collection`, `Map` and object arrays. `@Min`/`@Max` apply to +primitive integrals and to `Number` subtypes. + +An unsupported annotation is ignored rather than rejected, so adding one is never a boot failure. + +## Records and classes + +Constraints are read from **declared fields**. A constraint on a record component propagates to +its backing field, so records and plain classes take the same path with no extra configuration: + +```java +record CreateUser(@NotBlank String name) {} // works +class CreateUser { @NotBlank private String name; } // works +``` + +## Error responses + +`ValidationException` extends Flash's `HttpException` with status 422, so the default exception +handler renders it. Nothing is registered, and your own `onException` still wins if you set one. + +```json +{"error":"name must not be blank; age must be at least 18","status":422} +``` + +Malformed JSON is a different failure and comes back as `400` from the codec, before any +constraint runs. + +## OpenAPI + +Install `flash-ext-openapi` alongside and the generated schema mirrors the same annotations — +`minLength`, `maxLength`, `minItems`, `minimum`, `maximum`, `pattern`, `format: email`, and +`required`. Declared once, enforced and published. + +Nothing registers this. `flash-ext-openapi` carries `jakarta.validation-api` as an optional +dependency and detects it at boot; without it the bridge class is never loaded. + +An explicit `@Schema` always wins — the bridge only fills keys nobody set. + +## Without Jackson + +`flash-ext-jackson` is optional. Without it `validate(value)` still works on values you construct +or parse yourself; only `body(req, type)` needs a codec and says so if one is missing. + +## Performance + +The passing path is the one that runs on every request, so it allocates nothing: + +- **Compiled once per type.** Constraints resolve to an opcode plus operands at first use, cached + in a `ClassValue` — stored beside the class by the JVM, so no map lookup, no lock, and the entry + is collected with the class rather than pinning it. +- **No reflection per request.** Fields are read through `MethodHandle`s adapted to an exact + signature: `(Object)Object` for references, `(Object)long` for primitive integrals. `invokeExact` + neither boxes nor builds the argument array that `Field.get` and `Method.invoke` allocate. +- **No megamorphic dispatch.** Checks are a flat array walked by a `tableswitch` on an opcode, not + a class hierarchy behind a virtual call. +- **No copies.** `@Size` reads a length the object already knows; `@Email` scans with `indexOf` + rather than a regex, because `Pattern.matcher` allocates a matcher and two int arrays per call. +- **Messages pre-rendered at compile time**, so even a failure formats nothing. + +The list, the violations and the exception exist only once something fails. + +`@Pattern` is the deliberate exception: its regex is compiled once, but `matcher()` allocates per +call. It is marked in the source. Prefer `@Size`/`@Email` on hot routes, or validate the shape +structurally. + +## Pre-warming + +Compilation happens on a type's first request. To pay it at boot instead: + +```java +ctx.onReady(() -> ctx.require(Validation.class).forType(CreateUser.class)); +``` + +Worth it only for a route that must not pay first-call cost. Everything else warms itself. diff --git a/flash-extensions/flash-ext-validation/pom.xml b/flash-extensions/flash-ext-validation/pom.xml new file mode 100644 index 0000000..c7fa868 --- /dev/null +++ b/flash-extensions/flash-ext-validation/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-validation + + + + dev.relism + flash + + + + jakarta.validation + jakarta.validation-api + + + + dev.relism + flash-ext-jackson + true + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + + dev.relism + flash-ext-openapi + test + + + diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java new file mode 100644 index 0000000..eda228f --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java @@ -0,0 +1,76 @@ +package dev.relism.flash.ext.validation; + +import java.lang.invoke.MethodHandle; +import java.util.regex.Pattern; + +/** + * One constraint, compiled. Flattened into an opcode plus its operands rather than a class per + * constraint type: the check loop becomes a {@code tableswitch} over a monomorphic array instead + * of a megamorphic virtual call, and a passing check touches no allocation at all. + * + *

Field access goes through a {@link MethodHandle} adapted at compile time to an exact + * signature — {@code (Object)Object} for reference fields, {@code (Object)long} for primitive + * integrals — so {@code invokeExact} neither boxes nor allocates an argument array the way + * {@code Field.get} and {@code Method.invoke} do. + */ +final class Check { + + static final int NOT_NULL = 0; + static final int NOT_BLANK = 1; + static final int NOT_EMPTY = 2; + static final int SIZE = 3; + static final int RANGE_PRIMITIVE = 4; + static final int RANGE_BOXED = 5; + static final int EMAIL = 6; + static final int PATTERN = 7; + + final int op; + final String field; + /** Pre-rendered at compile time, so even the failure path formats nothing. */ + final String message; + + /** {@code (Object)Object} — set for every op except {@link #RANGE_PRIMITIVE}. */ + final MethodHandle ref; + /** {@code (Object)long} — set only for {@link #RANGE_PRIMITIVE}. */ + final MethodHandle num; + + final int min; + final int max; + final long lo; + final long hi; + final Pattern pattern; + + private Check(int op, String field, String message, MethodHandle ref, MethodHandle num, + int min, int max, long lo, long hi, Pattern pattern) { + this.op = op; + this.field = field; + this.message = message; + this.ref = ref; + this.num = num; + this.min = min; + this.max = max; + this.lo = lo; + this.hi = hi; + this.pattern = pattern; + } + + static Check reference(int op, String field, String message, MethodHandle ref) { + return new Check(op, field, message, ref, null, 0, 0, 0, 0, null); + } + + static Check size(String field, String message, MethodHandle ref, int min, int max) { + return new Check(SIZE, field, message, ref, null, min, max, 0, 0, null); + } + + static Check rangePrimitive(String field, String message, MethodHandle num, long lo, long hi) { + return new Check(RANGE_PRIMITIVE, field, message, null, num, 0, 0, lo, hi, null); + } + + static Check rangeBoxed(String field, String message, MethodHandle ref, long lo, long hi) { + return new Check(RANGE_BOXED, field, message, ref, null, 0, 0, lo, hi, null); + } + + static Check pattern(String field, String message, MethodHandle ref, Pattern pattern) { + return new Check(PATTERN, field, message, ref, null, 0, 0, 0, 0, pattern); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java new file mode 100644 index 0000000..df692c2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java @@ -0,0 +1,69 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.Json; +import dev.relism.flash.models.Request; + +/** + * The validation service. Resolve it with {@code require(Validation.class)}. + * + *

{@code
+ * CreateUser dto = validation.body(req, CreateUser.class);   // parse + verify
+ * }
+ * + *

Constraints are compiled the first time a type is seen and cached in a {@link ClassValue}, + * which the JVM stores beside the class itself — no map lookup, no lock, and the entry is + * collected with the class rather than pinning it. Every later request walks the compiled table. + */ +public final class Validation { + + private final ClassValue validators = new ClassValue<>() { + @Override protected Validator computeValue(Class type) { + return Validator.compile(type); + } + }; + + /** Null when flash-ext-jackson is absent; only {@link #body} needs it. */ + private Json json; + + Validation() {} + + /** Called once at boot by {@link ValidationExtension}, after the service graph resolves. */ + void bindCodec(Json json) { + this.json = json; + } + + /** + * Deserializes the request body into {@code type} and verifies its constraints. + * + * @throws dev.relism.flash.exceptions.HttpException 400 if the body is not valid JSON + * @throws ValidationException 422 if it parses but violates a constraint + */ + public T body(Request request, Class type) throws Exception { + if (json == null) + throw new IllegalStateException( + "Validation.body(...) needs a JSON codec — install JacksonExtension, " + + "or parse yourself and call validate(...)"); + T value = json.body(request, type); + validators.get(type).verify(value); + return value; + } + + /** + * Verifies an already-constructed value. + * + * @return {@code value}, so it can be used inline + * @throws ValidationException 422 on the first type's worth of failures + */ + public T validate(T value) { + validators.get(value.getClass()).verify(value); + return value; + } + + /** + * The compiled constraints of {@code type}. Useful to pre-warm a hot DTO at boot, or to + * check whether a type declares constraints at all. + */ + public Validator forType(Class type) { + return validators.get(type); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java new file mode 100644 index 0000000..dac202e --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java @@ -0,0 +1,39 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.exceptions.HttpException; + +import java.util.List; + +/** + * Raised when a value fails its constraints. Extends {@link HttpException} with status 422, so + * Flash's default exception handler renders it without this extension registering anything. + * + *

Allocated only on failure — a passing validation constructs nothing. + */ +public final class ValidationException extends HttpException { + + private final transient List violations; + + ValidationException(List violations) { + super(422, describe(violations)); + this.violations = List.copyOf(violations); + } + + /** The individual failures, in field declaration order. */ + public List violations() { + return violations; + } + + private static String describe(List violations) { + StringBuilder out = new StringBuilder(32 * violations.size()); + for (int i = 0; i < violations.size(); i++) { + if (i > 0) out.append("; "); + Violation v = violations.get(i); + out.append(v.field()).append(' ').append(v.message()); + } + return out.toString(); + } + + /** One failed constraint. */ + public record Violation(String field, String message) {} +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java new file mode 100644 index 0000000..029594b --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java @@ -0,0 +1,37 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.Json; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; + +/** + * Installs request validation. + * + *

{@code
+ * FlashApp.create(8080)
+ *     .install(new JacksonExtension())
+ *     .install(new ValidationExtension())
+ *     .scan("dev.example.api");
+ * }
+ * + *

No configuration. There is nothing to tune: constraints come from the annotations already on + * your types, failures come back as 422 through Flash's default exception handler because + * {@link ValidationException} carries its own status, and the JSON codec is picked up if + * {@code flash-ext-jackson} is installed. + * + *

Install order does not matter — Flash resolves the whole service graph before any handler + * initialises. + */ +public final class ValidationExtension implements FlashExtension { + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.supply(Validation.class, Validation::new); + + // Resolved here rather than declared as a dependency: jackson is optional, and a declared + // dependency would make it mandatory. By the time ready callbacks run the graph is + // complete, so find() sees whatever was actually installed. + ctx.onReady(() -> ctx.require(Validation.class).bindCodec(ctx.find(Json.class).orElse(null))); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java new file mode 100644 index 0000000..153a639 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java @@ -0,0 +1,216 @@ +package dev.relism.flash.ext.validation; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * The compiled constraints of one type. Built once per class and reused for every request. + * + *

{@link #verify} allocates nothing when a value passes: the loop walks an array (no iterator), + * reads fields through exact-signature {@link MethodHandle}s (no boxing, no argument array), and + * compares against operands resolved at compile time. The violation list and the exception are + * constructed only once something actually fails. + */ +public final class Validator { + + private static final Check[] NONE = new Check[0]; + + private final Check[] checks; + + private Validator(Check[] checks) { + this.checks = checks; + } + + /** True when the type declares no constraints at all — {@link #verify} is then a no-op. */ + public boolean isEmpty() { + return checks.length == 0; + } + + /** + * Verifies every constraint on {@code target}. + * + * @throws ValidationException with all failures, never just the first + */ + public void verify(Object target) { + List failures = null; + for (Check check : checks) { + if (passes(check, target)) continue; + if (failures == null) failures = new ArrayList<>(4); + failures.add(new ValidationException.Violation(check.field, check.message)); + } + if (failures != null) throw new ValidationException(failures); + } + + private static boolean passes(Check check, Object target) { + try { + if (check.op == Check.RANGE_PRIMITIVE) { + long value = (long) check.num.invokeExact(target); + return value >= check.lo && value <= check.hi; + } + Object value = (Object) check.ref.invokeExact(target); + // Jakarta semantics: only @NotNull rejects null; every other constraint passes it. + return switch (check.op) { + case Check.NOT_NULL -> value != null; + case Check.NOT_BLANK -> value instanceof String text && !text.isBlank(); + case Check.NOT_EMPTY -> value != null && sizeOf(value) > 0; + case Check.SIZE -> value == null || withinSize(check, value); + case Check.RANGE_BOXED -> value == null || withinRange(check, (Number) value); + case Check.EMAIL -> value == null || (value instanceof String text && isEmail(text)); + case Check.PATTERN -> value == null + || (value instanceof String text && check.pattern.matcher(text).matches()); + default -> true; + }; + } catch (Throwable failure) { + throw new IllegalStateException("Could not read " + check.field + " for validation", failure); + } + } + + private static boolean withinSize(Check check, Object value) { + int size = sizeOf(value); + return size >= check.min && size <= check.max; + } + + private static boolean withinRange(Check check, Number value) { + long asLong = value.longValue(); + return asLong >= check.lo && asLong <= check.hi; + } + + /** No copies: every branch reads a length the object already knows. */ + private static int sizeOf(Object value) { + if (value instanceof CharSequence text) return text.length(); + if (value instanceof Collection items) return items.size(); + if (value instanceof Map entries) return entries.size(); + if (value instanceof Object[] array) return array.length; + return 1; + } + + /** + * Structural check rather than a regex: {@code Pattern.matcher} allocates a matcher, an int + * array and a group array on every call, which is exactly the per-request cost this module + * exists to avoid. {@code indexOf} allocates nothing. + * + *

Accepts what a mail server would plausibly route and rejects the shapes people actually + * typo. Deliverability is the confirmation mail's job, not a validator's. + */ + private static boolean isEmail(String value) { + int at = value.indexOf('@'); + if (at <= 0 || at == value.length() - 1) return false; + if (value.indexOf('@', at + 1) >= 0) return false; + int dot = value.indexOf('.', at + 2); + return dot > 0 && dot < value.length() - 1 && value.indexOf(' ') < 0; + } + + // ── Compilation ────────────────────────────────────────────────────────── + + /** + * Compiles {@code type}'s constraints once. + * + *

Reads declared fields rather than record accessors: a constraint on a record component + * propagates to the backing field, so records and plain classes need one code path, not two. + */ + static Validator compile(Class type) { + MethodHandles.Lookup lookup; + try { + lookup = MethodHandles.privateLookupIn(type, MethodHandles.lookup()); + } catch (IllegalAccessException denied) { + throw new IllegalStateException( + "Cannot read " + type.getName() + " for validation — open its module or package", denied); + } + + List checks = new ArrayList<>(); + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + MethodHandle getter; + try { + getter = lookup.unreflectGetter(field); + } catch (IllegalAccessException denied) { + continue; + } + compileField(field, getter, checks); + } + return new Validator(checks.isEmpty() ? NONE : checks.toArray(new Check[0])); + } + + private static void compileField(Field field, MethodHandle getter, List checks) { + String name = field.getName(); + Class type = field.getType(); + MethodHandle ref = type.isPrimitive() ? null : asReference(getter); + + if (field.isAnnotationPresent(NotNull.class) && ref != null) + checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref)); + + if (field.isAnnotationPresent(NotBlank.class) && ref != null) + checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref)); + + if (field.isAnnotationPresent(NotEmpty.class) && ref != null) + checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref)); + + Size size = field.getAnnotation(Size.class); + if (size != null && ref != null) + checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max())); + + Min min = field.getAnnotation(Min.class); + Max max = field.getAnnotation(Max.class); + if (min != null || max != null) { + long lo = min != null ? min.value() : Long.MIN_VALUE; + long hi = max != null ? max.value() : Long.MAX_VALUE; + String message = rangeMessage(min, max); + if (isIntegralPrimitive(type)) { + checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi)); + } else if (Number.class.isAssignableFrom(type) && ref != null) { + checks.add(Check.rangeBoxed(name, message, ref, lo, hi)); + } + } + + if (field.isAnnotationPresent(Email.class) && ref != null) + checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref)); + + Pattern pattern = field.getAnnotation(Pattern.class); + if (pattern != null && ref != null) { + // ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is + // compiled once here; swap for a structural check if a hot route ever needs it. + checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref, + java.util.regex.Pattern.compile(pattern.regexp()))); + } + } + + private static boolean isIntegralPrimitive(Class type) { + return type == int.class || type == long.class || type == short.class || type == byte.class; + } + + private static MethodHandle asReference(MethodHandle getter) { + return getter.asType(MethodType.methodType(Object.class, Object.class)); + } + + private static MethodHandle asLong(MethodHandle getter) { + return getter.asType(MethodType.methodType(long.class, Object.class)); + } + + private static String sizeMessage(Size size) { + if (size.min() == 0) return "size must be at most " + size.max(); + if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min(); + return "size must be between " + size.min() + " and " + size.max(); + } + + private static String rangeMessage(Min min, Max max) { + if (min == null) return "must be at most " + max.value(); + if (max == null) return "must be at least " + min.value(); + return "must be between " + min.value() + " and " + max.value(); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java new file mode 100644 index 0000000..11868e6 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java @@ -0,0 +1,68 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.JacksonExtension; +import dev.relism.flash.ext.openapi.APIResponse; +import dev.relism.flash.ext.openapi.ApiOperation; +import dev.relism.flash.ext.openapi.Content; +import dev.relism.flash.ext.openapi.OpenApiExtension; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.Response; +import dev.relism.flash.routing.GET; +import dev.relism.flash.testing.FlashTest; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Constraints are declared once and read twice: the validator enforces them, the published schema + * describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on + * its own when they are on the classpath. + */ +class ValidationOpenApiInteropTest { + + record Account( + @NotBlank @Size(max = 40) String name, + @Email String email, + @Min(18) @Max(120) int age) {} + + @GET("/accounts") + @ApiOperation(summary = "List accounts") + @APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class)) + public static class ListAccounts extends RequestHandler { + @Override public Object handle(Request request, Response response) { + return new Account("alice", "a@b.com", 30); + } + } + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.install(new JacksonExtension()); + configured.install(new ValidationExtension()); + configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0")); + configured.scan("dev.relism.flash.ext.validation"); + }); + + @Test + void constraintsAppearInTheGeneratedSchema() { + app.get("/openapi.json") + .expectStatus(200) + .expectBodyContains("\"maxLength\":40") + .expectBodyContains("\"format\":\"email\"") + .expectBodyContains("\"minimum\":18") + .expectBodyContains("\"maximum\":120"); + } + + @Test + void notBlankMarksThePropertyRequiredAndNonEmpty() { + app.get("/openapi.json") + .expectStatus(200) + .expectBodyContains("\"minLength\":1") + .expectBodyContains("\"required\":[\"name\"]"); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java new file mode 100644 index 0000000..f32b5e2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java @@ -0,0 +1,69 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.JacksonExtension; +import dev.relism.flash.testing.FlashTest; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */ +class ValidationRoutesTest { + + record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {} + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.install(new JacksonExtension()); + configured.install(new ValidationExtension()); + + configured.ctx().onReady(() -> { + Validation validation = configured.ctx().require(Validation.class); + configured.post("/users", (req, res) -> + res.status(201).body("created:" + validation.body(req, CreateUser.class).name())); + }); + }); + + @Test + void validBodyReachesTheHandler() { + app.request().json("{\"name\":\"alice\",\"email\":\"a@b.com\",\"age\":30}").post("/users") + .expectStatus(201) + .expectBody("created:alice"); + } + + @Test + void constraintViolationBecomes422WithEveryFailureListed() { + app.request().json("{\"name\":\"\",\"email\":\"nope\",\"age\":5}").post("/users") + .expectStatus(422) + .expectHeader("Content-Type", "application/json") + .expectBodyContains("name must not be blank") + .expectBodyContains("email must be a well-formed email address") + .expectBodyContains("age must be at least 18"); + } + + @Test + void malformedJsonBecomes400NotAValidationFailure() { + app.request().json("not json").post("/users") + .expectStatus(400) + .expectBodyContains("Invalid request body"); + } + + /** Regression guard: HttpException used to reach the catch-all and come back as 500. */ + @Test + void statusCarriedByTheExceptionSurvivesToTheWire() { + assertEquals(422, app.request().json("{\"name\":\"x\",\"email\":\"a@b.com\",\"age\":1}") + .post("/users").status()); + } + + @Test + void errorBodyIsValidJsonEvenWhenTheMessageContainsQuotes() { + app.request().json("{\"name\":\"waaaaaaaaaay-too-long\",\"email\":\"a@b.com\",\"age\":30}").post("/users") + .expectStatus(422) + .expectBodyContains("\"status\":422") + .expectBodyContains("size must be at most 8"); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java new file mode 100644 index 0000000..bd8e1d2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java @@ -0,0 +1,117 @@ +package dev.relism.flash.ext.validation; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ValidatorTest { + + record CreateUser( + @NotBlank @Size(max = 8) String name, + @Email String email, + @Min(18) @Max(120) int age, + @NotNull String role) {} + + record Boxed(@Min(1) Integer count) {} + + record Sized(@NotEmpty List tags, @Size(min = 2, max = 4) String code) {} + + record Patterned(@Pattern(regexp = "[a-z]+") String slug) {} + + record Plain(String anything) {} + + private static ValidationException failureOf(Object value) { + return assertThrows(ValidationException.class, () -> Validator.compile(value.getClass()).verify(value)); + } + + @Test + void aValidValuePasses() { + assertDoesNotThrow(() -> + Validator.compile(CreateUser.class).verify(new CreateUser("alice", "a@b.com", 30, "admin"))); + } + + @Test + void reportsEveryViolationNotJustTheFirst() { + ValidationException failure = failureOf(new CreateUser(" ", "nope", 5, null)); + + assertEquals(List.of("name", "email", "age", "role"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void violationsCarryFieldAndMessage() { + ValidationException failure = failureOf(new CreateUser("alice", "a@b.com", 5, "admin")); + + assertEquals(1, failure.violations().size()); + assertEquals("age", failure.violations().get(0).field()); + assertEquals("must be between 18 and 120", failure.violations().get(0).message()); + assertEquals(422, failure.status()); + assertEquals("age must be between 18 and 120", failure.getMessage()); + } + + @Test + void sizeCountsCharactersWithoutCopying() { + assertEquals("name", failureOf(new CreateUser("far-too-long", "a@b.com", 30, "x")) + .violations().get(0).field()); + } + + @Test + void onlyNotNullRejectsNull() { + // @Email, @Size and @Min all accept null per Jakarta semantics; @NotNull is the one that does not. + ValidationException failure = failureOf(new CreateUser("alice", null, 30, null)); + + assertEquals(List.of("role"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void boxedNumbersUseTheReferencePathAndTolerateNull() { + assertDoesNotThrow(() -> Validator.compile(Boxed.class).verify(new Boxed(null))); + assertEquals("count", failureOf(new Boxed(0)).violations().get(0).field()); + } + + @Test + void sizeAppliesToCollectionsAndStrings() { + assertDoesNotThrow(() -> Validator.compile(Sized.class).verify(new Sized(List.of("a"), "abc"))); + + ValidationException failure = failureOf(new Sized(List.of(), "x")); + assertEquals(List.of("tags", "code"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void patternIsAnchoredLikeJakarta() { + assertDoesNotThrow(() -> Validator.compile(Patterned.class).verify(new Patterned("abc"))); + assertEquals("slug", failureOf(new Patterned("Abc1")).violations().get(0).field()); + } + + @Test + void emailAcceptsPlausibleAddressesAndRejectsTypos() { + assertDoesNotThrow(() -> + Validator.compile(CreateUser.class).verify(new CreateUser("a", "first.last@sub.example.co", 20, "x"))); + + for (String bad : List.of("no-at", "@leading.com", "trailing@", "two@@at.com", "no dots@x", "a@b")) { + assertThrows(ValidationException.class, + () -> Validator.compile(CreateUser.class).verify(new CreateUser("a", bad, 20, "x")), + bad); + } + } + + @Test + void aTypeWithNoConstraintsCompilesToANoOp() { + Validator validator = Validator.compile(Plain.class); + + assertTrue(validator.isEmpty()); + assertDoesNotThrow(() -> validator.verify(new Plain(null))); + } +} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 70452eb..3749b9b 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -24,6 +24,7 @@ flash-ext-limiter flash-ext-web-bundler flash-ext-mcp + flash-ext-validation flash-ext-data-core flash-ext-data-jdbc flash-ext-data-hibernate @@ -31,6 +32,11 @@ + + dev.relism + flash-ext-validation + ${project.version} + dev.relism flash-testing diff --git a/pom.xml b/pom.xml index aaa7c7e..c8bd745 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 2.18.0 1.37 5.11.0 + 3.1.1 3.6.0 @@ -67,6 +68,16 @@ flash-testing ${project.version} + + dev.relism + flash-ext-validation + ${project.version} + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation.version} + dev.relism flash-ext-jackson