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:
co-authored by
Claude Opus 5
parent
e0ad83eb2f
commit
c02459dd7c
+18
-53
@@ -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<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");
|
||||
}
|
||||
}
|
||||
|
||||
+103
-118
@@ -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();
|
||||
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<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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user