test(ext-mcp): migrate McpAuthPolicyTest to flash-testing

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 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-09 10:22:40 +00:00
co-authored by Claude Opus 5
parent 424ca31b7a
commit fa5a025c93
2 changed files with 100 additions and 104 deletions
+5
View File
@@ -38,6 +38,11 @@
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
</dependency> </dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -3,16 +3,14 @@ package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig; import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension; import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp; 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.AfterEach;
import org.junit.jupiter.api.Test; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; 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 SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly"; private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
private FlashApp app; private static final FakeOidcProvider provider = newProvider();
private FakeOidcProvider provider;
@AfterEach @RegisterExtension
void tearDown() { static FlashTest secured = FlashTest.of(app -> {
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<String> 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<String> 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<String> 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<String> 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<String> 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);
app.install(new OidcExtension(OidcConfig.builder( app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server") app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(SECURED_TOOLS) .toolsPackage(SECURED_TOOLS)
.security(McpSecurity.NONE) .security(McpSecurity.REQUIRED)
.build())); .build()));
});
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start()); /** Tokens are audience-bound to this server, so the port has to be read back after boot. */
assertTrue(e.getMessage().contains("no active OAuth2 protection"), e.getMessage()); 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 @Test
void bareAuthenticated_hasNoEffect_failsAtBoot() throws Exception { void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
provider = new FakeOidcProvider(); callTool("write_only", provider.signToken("user-1", resourceId(), "read"))
int port = freePort(); .expectStatus(200)
app = FlashApp.create(port); .expectBodyContains("\"isError\":true")
app.install(new OidcExtension(OidcConfig.builder( .expectBodyContains("missing required scope");
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(AUTHENTICATED_ONLY_TOOLS)
.security(McpSecurity.REQUIRED)
.build()));
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start()); callTool("write_only", provider.signToken("user-1", resourceId(), "read write"))
assertTrue(e.getMessage().contains("no effect"), e.getMessage()); .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 { private static FlashResponse callTool(String toolName, String token) {
provider = new FakeOidcProvider(); return secured.request()
int port = freePort(); .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( app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server") app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(toolsPackage) .toolsPackage(toolsPackage)
.security(McpSecurity.REQUIRED) .security(security)
.build())); .build()));
app.start(); return app;
return port;
} }
private static HttpResponse<String> callTool(int port, String toolName, String token) throws Exception { private static FakeOidcProvider newProvider() {
String body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\"}}"; try {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp")) return new FakeOidcProvider();
.header("Content-Type", "application/json") } catch (Exception failure) {
.header("Accept", "application/json") throw new IllegalStateException("Could not start the fake OIDC provider", failure);
.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();
} }
} }
} }