test(ext-mcp): migrate integration and security suites to flash-testing

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 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-09 11:05:06 +00:00
co-authored by Claude Opus 5
parent e0ad83eb2f
commit c02459dd7c
2 changed files with 121 additions and 171 deletions
@@ -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")
// 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();
app = FlashApp.create(port);
app.install(new McpExtension(config));
app.start();
}
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
.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<String> 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<String> 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<String> resp = post(body);
assertEquals(200, resp.statusCode());
return MAPPER.readTree(resp.body());
return MAPPER.readTree(post(body).expectStatus(200).body());
}
private HttpResponse<String> 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");
}
}
@@ -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.
*
* <p>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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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();
var mcpBuilder = McpConfig.builder("secure-server")
McpConfig.Builder mcp = McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED);
if (resourceIdentifier != null) mcpBuilder.resourceIdentifier(resourceIdentifier);
if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
app.install(new McpExtension(mcp.build()));
};
}
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<String> 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());
}
}