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:
co-authored by
Claude Opus 5
parent
424ca31b7a
commit
fa5a025c93
@@ -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>
|
||||||
|
|||||||
+95
-104
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user