From c02459dd7c392ef54883baacff1386cb2a36e899 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 11:05:06 +0000 Subject: [PATCH] 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()); - } }