diff --git a/src/test/java/org/apache/solr/mcp/server/security/HttpSecurityFilterChainTest.java b/src/test/java/org/apache/solr/mcp/server/security/HttpSecurityFilterChainTest.java new file mode 100644 index 00000000..42f40d30 --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/security/HttpSecurityFilterChainTest.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.apache.solr.mcp.server.TestcontainersConfiguration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledInNativeImage; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Pins the anonymous-access boundary of the {@code http} filter chain. + * + *
+ * {@link HttpSecurityConfiguration} deliberately splits the actuator: probes + * stay open so load balancers and orchestrators can reach them, while every + * other endpoint requires authentication — otherwise an unauthenticated caller + * could read the dependency tree from {@code /actuator/sbom/application} or + * scrape metrics that map the tool surface. + * + *
+ * That decision is a one-line {@code requestMatchers} rule. Widening it to + * {@code permitAll()} would expose all of the above and break no other test, so + * this asserts both halves: health open, everything else closed. + * + *
+ * No issuer is configured here, which is the point — with OAuth2 unwired the + * chain must still deny anonymous access rather than fall open. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestcontainersConfiguration.class) +@ActiveProfiles("http") +@Tag("integration") +@Testcontainers(disabledWithoutDocker = true) +@DisabledInNativeImage +class HttpSecurityFilterChainTest { + + @LocalServerPort + private int port; + + private int statusOf(String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder().uri(URI.create("http://localhost:" + port + path)).GET().build(); + return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()).statusCode(); + } + + @Test + void healthProbeIsAnonymouslyReachable() throws Exception { + assertEquals(200, statusOf("/actuator/health"), + "/actuator/health must stay open for liveness and readiness probes"); + } + + /** + * Denial here is 403, not 401: with no issuer configured there is no + * authentication entry point to challenge with, so Spring Security rejects + * rather than prompting. Wiring an issuer turns the same request into a 401 + * carrying {@code WWW-Authenticate: Bearer}. Both are correct denials, so these + * accept either — what must never happen is a 200. + */ + private void assertDenied(String path, String why) throws Exception { + int status = statusOf(path); + assertTrue(status == 401 || status == 403, why + " — expected 401 or 403, got " + status); + } + + @Test + void sbomEndpointRequiresAuthentication() throws Exception { + assertDenied("/actuator/sbom/application", + "/actuator/sbom/application exposes the full dependency tree and must not be anonymous"); + } + + @Test + void metricsEndpointRequiresAuthentication() throws Exception { + assertDenied("/actuator/metrics", "/actuator/metrics maps the tool surface and must not be anonymous"); + } +} diff --git a/src/test/java/org/apache/solr/mcp/server/security/McpInspectorCorsTest.java b/src/test/java/org/apache/solr/mcp/server/security/McpInspectorCorsTest.java new file mode 100644 index 00000000..ffceb746 --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/security/McpInspectorCorsTest.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.apache.solr.mcp.server.TestcontainersConfiguration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledInNativeImage; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Pins the CORS contract the MCP Inspector depends on. + * + *
+ * The Inspector's UI runs at {@code http://localhost:6274} and is the default + * value of {@code mcp.cors.allowed-origins}. That default is a plain property: + * narrowing it, reordering it, or setting {@code MCP_CORS_ALLOWED_ORIGINS=*} + * silently stops the Inspector connecting, and no other test notices. + * + *
+ * The wildcard case is the trap. {@code setAllowedOrigins} is the strict API, + * so {@code *} combined with {@code allowCredentials(true)} does not open the + * server up — it rejects every origin, including the Inspector's, with + * no warning logged. An operator reaching for {@code *} to "allow everything" + * gets the opposite. + * + *
+ * This replays the exact preflight a browser sends on the Inspector's behalf
+ * and asserts the response permits the request.
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@Import(TestcontainersConfiguration.class)
+@ActiveProfiles("http")
+@Tag("integration")
+@Testcontainers(disabledWithoutDocker = true)
+@DisabledInNativeImage
+class McpInspectorCorsTest {
+
+ /** The MCP Inspector UI origin, and the shipped default allowlist entry. */
+ private static final String INSPECTOR_ORIGIN = "http://localhost:6274";
+
+ @LocalServerPort
+ private int port;
+
+ private HttpResponse
+ * {@code McpToolRegistrationTest#everyMcpEndpointIsPreAuthorized} asserts the
+ * annotation is declared on every MCP entry point. That is a static check — it
+ * cannot tell whether {@link MethodSecurityConfiguration} is wired such that
+ * the annotation has any runtime effect. If the profile gate or the
+ * {@code http.security.enabled} property condition stopped matching, every
+ * annotation would silently become a no-op and the static test would still
+ * pass.
+ *
+ *
+ * This test runs in the {@code http} profile with security left at its default
+ * (enabled) and invokes a secured method through the Spring proxy with an empty
+ * SecurityContext. Enforcement means an {@link AccessDeniedException}.
+ */
+@SpringBootTest
+@Import(TestcontainersConfiguration.class)
+@ActiveProfiles("http")
+@Tag("integration")
+@Testcontainers(disabledWithoutDocker = true)
+@DisabledInNativeImage
+class MethodSecurityEnforcementTest {
+
+ @Autowired
+ private CollectionService collectionService;
+
+ /**
+ * With an entirely empty SecurityContext, Spring Security raises
+ * {@link AuthenticationCredentialsNotFoundException} (an
+ * {@code AuthenticationException}) rather than {@code AccessDeniedException} —
+ * the latter is for an authenticated principal lacking authority. Asserting the
+ * broad {@code SecurityException}-free supertype would pass for the wrong
+ * reason, so this pins the specific type.
+ */
+ @Test
+ void unauthenticatedCallToSecuredToolIsRejected() {
+ assertThrows(AuthenticationCredentialsNotFoundException.class, () -> collectionService.listCollections(),
+ "list-collections carries @PreAuthorize(\"isAuthenticated()\") and was called with no "
+ + "authentication, so method security must reject it. Succeeding means the annotation "
+ + "is decorative: @EnableMethodSecurity is not in effect for this context.");
+ }
+}