Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* {@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.
*
* <p>
* 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.
*
* <p>
* 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");
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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.
*
* <p>
* 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 <em>every</em> origin, including the Inspector's, with
* no warning logged. An operator reaching for {@code *} to "allow everything"
* gets the opposite.
*
* <p>
* 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<String> preflight(String origin, String method, String requestHeaders) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/mcp"))
.method("OPTIONS", HttpRequest.BodyPublishers.noBody()).header("Origin", origin)
.header("Access-Control-Request-Method", method);
if (requestHeaders != null) {
builder.header("Access-Control-Request-Headers", requestHeaders);
}
return HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString());
}

@Test
void inspectorPreflightIsAllowed() throws Exception {
HttpResponse<String> response = preflight(INSPECTOR_ORIGIN, "POST", "content-type,authorization");

assertEquals(200, response.statusCode(),
"The MCP Inspector cannot connect unless its origin passes preflight. Check that "
+ "mcp.cors.allowed-origins still contains " + INSPECTOR_ORIGIN);
assertEquals(INSPECTOR_ORIGIN, response.headers().firstValue("Access-Control-Allow-Origin").orElse(null),
"The specific origin must be echoed back; a wildcard is invalid alongside credentials");
assertEquals("true", response.headers().firstValue("Access-Control-Allow-Credentials").orElse(null),
"The Inspector sends the bearer token as a credentialed request");
}

@Test
void inspectorTransportMethodsAreAllowed() throws Exception {
String allowed = preflight(INSPECTOR_ORIGIN, "POST", null).headers().firstValue("Access-Control-Allow-Methods")
.orElse("");

// Streamable HTTP: POST sends messages, GET opens the stream, DELETE ends
// the session. Dropping any one breaks a different part of the transport.
for (String method : new String[]{"GET", "POST", "DELETE"}) {
assertTrue(allowed.contains(method),
() -> "MCP Streamable HTTP needs " + method + "; Allow-Methods was: " + allowed);
}
}

@Test
void unknownOriginIsRejected() throws Exception {
assertEquals(403, preflight("http://not-the-inspector.example", "POST", null).statusCode(),
"Origins outside the allowlist must be refused, otherwise the allowlist is decorative");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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.assertThrows;

import org.apache.solr.mcp.server.TestcontainersConfiguration;
import org.apache.solr.mcp.server.collection.CollectionService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledInNativeImage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.junit.jupiter.Testcontainers;

/**
* Probe: is {@code @PreAuthorize} actually enforced, or merely present?
*
* <p>
* {@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.
*
* <p>
* 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.");
}
}