diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java index 4f849f86fb..9f324f6298 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java @@ -264,11 +264,13 @@ public abstract class AbstractRequestMatcherRegistry { } private static String computeErrorMessage(Collection registrations) { - String template = "This method cannot decide whether these patterns are Spring MVC patterns or not. " - + "If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); " - + "otherwise, please use requestMatchers(AntPathRequestMatcher).\n\n" - + "This is because there is more than one mappable servlet in your servlet context: %s.\n\n" - + "For each MvcRequestMatcher, call MvcRequestMatcher#setServletPath to indicate the servlet path."; + String template = """ + This method cannot decide whether these patterns are Spring MVC patterns or not. \ + This is because there is more than one mappable servlet in your servlet context: %s. + + To address this, please create one PathPatternRequestMatcher.Builder#servletPath for each servlet that has \ + authorized endpoints and use them to construct request matchers manually. + """; Map> mappings = new LinkedHashMap<>(); for (ServletRegistration registration : registrations) { mappings.put(registration.getClassName(), registration.getMappings()); diff --git a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc index 4eaf5f3d5e..41fffe79ba 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc @@ -577,15 +577,11 @@ http { ====== [[match-by-mvc]] -=== Using an MvcRequestMatcher +=== Matching by Servlet Path Generally speaking, you can use `requestMatchers(String)` as demonstrated above. -However, if you map Spring MVC to a different servlet path, then you need to account for that in your security configuration. - -For example, if Spring MVC is mapped to `/spring-mvc` instead of `/` (the default), then you may have an endpoint like `/spring-mvc/my/controller` that you want to authorize. - -You need to use `MvcRequestMatcher` to split the servlet path and the controller path in your configuration like so: +However, if you have authorization rules from multiple servlets, you need to specify those: .Match by MvcRequestMatcher [tabs] @@ -594,16 +590,15 @@ Java:: + [source,java,role="primary"] ---- -@Bean -MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) { - return new MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc"); -} +import static org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher.withDefaults; @Bean -SecurityFilterChain appEndpoints(HttpSecurity http, MvcRequestMatcher.Builder mvc) { +SecurityFilterChain appEndpoints(HttpSecurity http) { + PathPatternRequestMatcher.Builder mvc = withDefaults().servletPath("/spring-mvc"); http .authorizeHttpRequests((authorize) -> authorize - .requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller") + .requestMatchers(mvc.matcher("/admin/**")).hasAuthority("admin") + .requestMatchers(mvc.matcher("/my/controller/**")).hasAuthority("controller") .anyRequest().authenticated() ); @@ -616,17 +611,15 @@ Kotlin:: [source,kotlin,role="secondary"] ---- @Bean -fun mvc(introspector: HandlerMappingIntrospector): MvcRequestMatcher.Builder = - MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc"); - -@Bean -fun appEndpoints(http: HttpSecurity, mvc: MvcRequestMatcher.Builder): SecurityFilterChain = +fun appEndpoints(http: HttpSecurity): SecurityFilterChain { http { authorizeHttpRequests { - authorize(mvc.pattern("/my/controller/**"), hasAuthority("controller")) + authorize("/spring-mvc", "/admin/**", hasAuthority("admin")) + authorize("/spring-mvc", "/my/controller/**", hasAuthority("controller")) authorize(anyRequest, authenticated) } } +} ---- Xml:: @@ -634,16 +627,19 @@ Xml:: [source,xml,role="secondary"] ---- + ---- ====== -This need can arise in at least two different ways: +This is because Spring Security requires all URIs to be absolute (minus the context path). -* If you use the `spring.mvc.servlet.path` Boot property to change the default path (`/`) to something else -* If you register more than one Spring MVC `DispatcherServlet` (thus requiring that one of them not be the default path) +[TIP] +===== +There are several other components that create request matchers for you like {spring-boot-api-url}org/springframework/boot/autoconfigure/security/servlet/PathRequest.html[`PathRequest#toStaticResources#atCommonLocations`] +===== [[match-by-custom]] === Using a Custom Matcher diff --git a/web/src/main/java/org/springframework/security/web/FilterChainProxy.java b/web/src/main/java/org/springframework/security/web/FilterChainProxy.java index 7795f2c8ba..f9ad696436 100644 --- a/web/src/main/java/org/springframework/security/web/FilterChainProxy.java +++ b/web/src/main/java/org/springframework/security/web/FilterChainProxy.java @@ -46,6 +46,7 @@ import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; import org.springframework.web.filter.DelegatingFilterProxy; import org.springframework.web.filter.GenericFilterBean; +import org.springframework.web.filter.ServletRequestPathFilter; /** * Delegates {@code Filter} requests to a list of Spring-managed filter beans. As of @@ -162,6 +163,8 @@ public class FilterChainProxy extends GenericFilterBean { private FilterChainDecorator filterChainDecorator = new VirtualFilterChainDecorator(); + private Filter springWebFilter = new ServletRequestPathFilter(); + public FilterChainProxy() { } @@ -210,27 +213,29 @@ public class FilterChainProxy extends GenericFilterBean { throws IOException, ServletException { FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request); HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response); - List filters = getFilters(firewallRequest); - if (filters == null || filters.isEmpty()) { - if (logger.isTraceEnabled()) { - logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest))); + this.springWebFilter.doFilter(firewallRequest, firewallResponse, (r, s) -> { + List filters = getFilters(firewallRequest); + if (filters == null || filters.isEmpty()) { + if (logger.isTraceEnabled()) { + logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest))); + } + firewallRequest.reset(); + this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse); + return; } - firewallRequest.reset(); - this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse); - return; - } - if (logger.isDebugEnabled()) { - logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest))); - } - FilterChain reset = (req, res) -> { if (logger.isDebugEnabled()) { - logger.debug(LogMessage.of(() -> "Secured " + requestLine(firewallRequest))); + logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest))); } - // Deactivate path stripping as we exit the security filter chain - firewallRequest.reset(); - chain.doFilter(req, res); - }; - this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse); + FilterChain reset = (req, res) -> { + if (logger.isDebugEnabled()) { + logger.debug(LogMessage.of(() -> "Secured " + requestLine(firewallRequest))); + } + // Deactivate path stripping as we exit the security filter chain + firewallRequest.reset(); + chain.doFilter(req, res); + }; + this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse); + }); } /** @@ -447,4 +452,23 @@ public class FilterChainProxy extends GenericFilterBean { } + private static final class FirewallFilter implements Filter { + + private final HttpFirewall firewall; + + private FirewallFilter(HttpFirewall firewall) { + this.firewall = firewall; + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + filterChain.doFilter(this.firewall.getFirewalledRequest(request), + this.firewall.getFirewalledResponse(response)); + } + + } + } diff --git a/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java new file mode 100644 index 0000000000..92d727f6dd --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java @@ -0,0 +1,370 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.security.web.servlet.util.matcher; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; +import org.springframework.lang.Nullable; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.util.ServletRequestPathUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * A {@link RequestMatcher} that uses {@link PathPattern}s to match against each + * {@link HttpServletRequest}. The provided path should be relative to the servlet (that + * is, it should exclude any context or servlet path). + * + *

+ * To also match the servlet, please see {@link PathPatternRequestMatcher#servletPath} + * + *

+ * Note that the {@link org.springframework.web.servlet.HandlerMapping} that contains the + * related URI patterns must be using {@link PathPatternParser#defaultInstance}. If that + * is not the case, use {@link PathPatternParser} to parse your path and provide a + * {@link PathPattern} in the constructor. + *

+ * + * @author Josh Cummings + * @since 6.5 + */ +public final class PathPatternRequestMatcher implements RequestMatcher { + + private final PathPattern pattern; + + private RequestMatcher servletPath = AnyRequestMatcher.INSTANCE; + + private RequestMatcher method = AnyRequestMatcher.INSTANCE; + + /** + * Creates a {@link PathPatternRequestMatcher} that uses the provided {@code pattern}. + *

+ * The {@code pattern} should be relative to the servlet path + *

+ * @param pattern the pattern used to match + */ + private PathPatternRequestMatcher(PathPattern pattern) { + this.pattern = pattern; + } + + /** + * Use {@link PathPatternParser#defaultInstance} to parse path patterns. + * @return a {@link Builder} that treats URIs as relative to the context path, if any + */ + public static Builder withDefaults() { + return new Builder(); + } + + /** + * Use this {@link PathPatternParser} to parse path patterns. + * @param parser the {@link PathPatternParser} to use + * @return a {@link Builder} that treats URIs as relative to the given + * {@code servletPath} + */ + public static Builder withPathPatternParser(PathPatternParser parser) { + Assert.notNull(parser, "pathPatternParser cannot be null"); + return new Builder(parser); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean matches(HttpServletRequest request) { + return matcher(request).isMatch(); + } + + /** + * {@inheritDoc} + */ + @Override + public MatchResult matcher(HttpServletRequest request) { + if (!this.servletPath.matches(request)) { + return MatchResult.notMatch(); + } + if (!this.method.matches(request)) { + return MatchResult.notMatch(); + } + PathContainer path = getRequestPath(request).pathWithinApplication(); + PathPattern.PathMatchInfo info = this.pattern.matchAndExtract(path); + return (info != null) ? MatchResult.match(info.getUriVariables()) : MatchResult.notMatch(); + } + + void setMethod(RequestMatcher method) { + this.method = method; + } + + void setServletPath(RequestMatcher servletPath) { + this.servletPath = servletPath; + } + + private RequestPath getRequestPath(HttpServletRequest request) { + return ServletRequestPathUtils.getParsedRequestPath(request); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object o) { + if (!(o instanceof PathPatternRequestMatcher that)) { + return false; + } + return Objects.equals(this.pattern, that.pattern); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hash(this.pattern); + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + StringBuilder request = new StringBuilder(); + if (this.method instanceof HttpMethodRequestMatcher m) { + request.append(m.method.name()).append(' '); + } + if (this.servletPath instanceof ServletPathRequestMatcher s) { + request.append(s.path); + } + return "PathPattern [" + request + this.pattern + "]"; + } + + /** + * A builder for specifying various elements of a request for the purpose of creating + * a {@link PathPatternRequestMatcher}. + * + *

+ * For example, if Spring MVC is deployed to `/mvc` and another servlet to `/other`, + * then you can use this builder to do: + *

+ * + * + * http + * .authorizeHttpRequests((authorize) -> authorize + * .requestMatchers(servletPath("/mvc").matcher("/user/**")).hasAuthority("user") + * .requestMatchers(servletPath("/other").matcher("/admin/**")).hasAuthority("admin") + * ) + * ... + * + */ + public static final class Builder { + + private final PathPatternParser parser; + + private final RequestMatcher servletPath; + + Builder() { + this(PathPatternParser.defaultInstance); + } + + Builder(PathPatternParser parser) { + this(parser, AnyRequestMatcher.INSTANCE); + } + + Builder(PathPatternParser parser, RequestMatcher servletPath) { + this.parser = parser; + this.servletPath = servletPath; + } + + /** + * Match requests starting with this {@code servletPath}. + * @param servletPath the servlet path prefix + * @return the {@link Builder} for more configuration + */ + public Builder servletPath(String servletPath) { + return new Builder(this.parser, new ServletPathRequestMatcher(servletPath)); + } + + /** + * Match requests having this path pattern. + * + *

+ * When the HTTP {@code method} is null, then the matcher does not consider the + * HTTP method + * + *

+ * Path patterns always start with a slash and may contain placeholders. They can + * also be followed by {@code /**} to signify all URIs under a given path. + * + *

+ * These must be specified relative to any servlet path prefix (meaning you should + * exclude the context path and any servlet path prefix in stating your pattern). + * + *

+ * The following are valid patterns and their meaning + *

    + *
  • {@code /path} - match exactly and only `/path`
  • + *
  • {@code /path/**} - match `/path` and any of its descendents
  • + *
  • {@code /path/{value}/**} - match `/path/subdirectory` and any of its + * descendents, capturing the value of the subdirectory in + * {@link RequestAuthorizationContext#getVariables()}
  • + *
+ * + *

+ * A more comprehensive list can be found at {@link PathPattern}. + * @param path the path pattern to match + * @return the {@link Builder} for more configuration + */ + public PathPatternRequestMatcher matcher(String path) { + return matcher(null, path); + } + + /** + * Match requests having this {@link HttpMethod} and path pattern. + * + *

+ * When the HTTP {@code method} is null, then the matcher does not consider the + * HTTP method + * + *

+ * Path patterns always start with a slash and may contain placeholders. They can + * also be followed by {@code /**} to signify all URIs under a given path. + * + *

+ * These must be specified relative to any servlet path prefix (meaning you should + * exclude the context path and any servlet path prefix in stating your pattern). + * + *

+ * The following are valid patterns and their meaning + *

    + *
  • {@code /path} - match exactly and only `/path`
  • + *
  • {@code /path/**} - match `/path` and any of its descendents
  • + *
  • {@code /path/{value}/**} - match `/path/subdirectory` and any of its + * descendents, capturing the value of the subdirectory in + * {@link RequestAuthorizationContext#getVariables()}
  • + *
+ * + *

+ * A more comprehensive list can be found at {@link PathPattern}. + * @param method the {@link HttpMethod} to match, may be null + * @param path the path pattern to match + * @return the {@link Builder} for more configuration + */ + public PathPatternRequestMatcher matcher(@Nullable HttpMethod method, String path) { + Assert.notNull(path, "pattern cannot be null"); + Assert.isTrue(path.startsWith("/"), "pattern must start with a /"); + PathPattern pathPattern = this.parser.parse(path); + PathPatternRequestMatcher requestMatcher = new PathPatternRequestMatcher(pathPattern); + if (method != null) { + requestMatcher.setMethod(new HttpMethodRequestMatcher(method)); + } + if (this.servletPath != AnyRequestMatcher.INSTANCE) { + requestMatcher.setServletPath(this.servletPath); + } + return requestMatcher; + } + + } + + private static final class HttpMethodRequestMatcher implements RequestMatcher { + + private final HttpMethod method; + + HttpMethodRequestMatcher(HttpMethod method) { + this.method = method; + } + + @Override + public boolean matches(HttpServletRequest request) { + return this.method.name().equals(request.getMethod()); + } + + @Override + public String toString() { + return "HttpMethod [" + this.method + "]"; + } + + } + + private static final class ServletPathRequestMatcher implements RequestMatcher { + + private final String path; + + private final AtomicReference servletExists = new AtomicReference<>(); + + ServletPathRequestMatcher(String servletPath) { + Assert.notNull(servletPath, "servletPath cannot be null"); + Assert.isTrue(servletPath.startsWith("/"), "servletPath must start with '/'"); + Assert.isTrue(!servletPath.endsWith("/"), "servletPath must not end with a slash"); + Assert.isTrue(!servletPath.contains("*"), "servletPath must not contain a star"); + this.path = servletPath; + } + + @Override + public boolean matches(HttpServletRequest request) { + Assert.isTrue(servletExists(request), () -> this.path + "/* does not exist in your servlet registration " + + registrationMappings(request)); + return Objects.equals(this.path, ServletRequestPathUtils.getServletPathPrefix(request)); + } + + private boolean servletExists(HttpServletRequest request) { + return this.servletExists.updateAndGet((value) -> { + if (value != null) { + return value; + } + if (request.getAttribute("org.springframework.test.web.servlet.MockMvc.MVC_RESULT_ATTRIBUTE") != null) { + return true; + } + for (ServletRegistration registration : request.getServletContext() + .getServletRegistrations() + .values()) { + if (registration.getMappings().contains(this.path + "/*")) { + return true; + } + } + return false; + }); + } + + private Map> registrationMappings(HttpServletRequest request) { + Map> map = new LinkedHashMap<>(); + ServletContext servletContext = request.getServletContext(); + for (ServletRegistration registration : servletContext.getServletRegistrations().values()) { + map.put(registration.getName(), registration.getMappings()); + } + return map; + } + + @Override + public String toString() { + return "ServletPath [" + this.path + "]"; + } + + } + +} diff --git a/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java b/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java index 778efc545f..2e8f7a552a 100644 --- a/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java +++ b/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java @@ -48,6 +48,7 @@ import org.springframework.security.web.firewall.FirewalledRequest; import org.springframework.security.web.firewall.HttpFirewall; import org.springframework.security.web.firewall.RequestRejectedException; import org.springframework.security.web.firewall.RequestRejectedHandler; +import org.springframework.security.web.servlet.TestMockHttpServletMappings; import org.springframework.security.web.util.matcher.RequestMatcher; import static org.assertj.core.api.Assertions.assertThat; @@ -166,6 +167,7 @@ public class FilterChainProxyTests { FirewalledRequest fwr = mock(FirewalledRequest.class); given(fwr.getRequestURI()).willReturn("/"); given(fwr.getContextPath()).willReturn(""); + given(fwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping()); this.fcp.setFirewall(fw); given(fw.getFirewalledRequest(this.request)).willReturn(fwr); given(this.matcher.matches(any(HttpServletRequest.class))).willReturn(false); @@ -183,9 +185,11 @@ public class FilterChainProxyTests { FirewalledRequest firstFwr = mock(FirewalledRequest.class, "firstFwr"); given(firstFwr.getRequestURI()).willReturn("/"); given(firstFwr.getContextPath()).willReturn(""); + given(firstFwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping()); FirewalledRequest fwr = mock(FirewalledRequest.class, "fwr"); given(fwr.getRequestURI()).willReturn("/"); given(fwr.getContextPath()).willReturn(""); + given(fwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping()); given(fw.getFirewalledRequest(this.request)).willReturn(firstFwr); given(fw.getFirewalledRequest(firstFwr)).willReturn(fwr); given(fwr.getRequest()).willReturn(firstFwr); diff --git a/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java new file mode 100644 index 0000000000..e19adf05a2 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.security.web.servlet.util.matcher; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.servlet.MockServletContext; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.util.ServletRequestPathUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * Tests for {@link PathPatternRequestMatcher} + */ +public class PathPatternRequestMatcherTests { + + @Test + void matcherWhenPatternMatchesRequestThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/uri"); + assertThat(matcher.matches(request("/uri"))).isTrue(); + } + + @Test + void matcherWhenPatternContainsPlaceholdersThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/uri/{username}"); + assertThat(matcher.matcher(request("/uri/bob")).getVariables()).containsEntry("username", "bob"); + } + + @Test + void matcherWhenOnlyPathInfoMatchesThenMatches() { + RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/uri"); + assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isTrue(); + } + + @Test + void matcherWhenUriContainsServletPathThenNoMatch() { + RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/mvc/uri"); + assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isFalse(); + } + + @Test + void matcherWhenSameMethodThenMatchResult() { + RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("/uri"))).isTrue(); + } + + @Test + void matcherWhenDifferentPathThenNoMatch() { + RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("GET", "/urj", ""))).isFalse(); + } + + @Test + void matcherWhenDifferentMethodThenNoMatch() { + RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.GET, "/uri"); + assertThat(matcher.matches(request("POST", "/mvc/uri", "/mvc"))).isFalse(); + } + + @Test + void matcherWhenNoMethodThenMatches() { + RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/uri"); + assertThat(matcher.matches(request("POST", "/uri", ""))).isTrue(); + assertThat(matcher.matches(request("GET", "/uri", ""))).isTrue(); + } + + @Test + void matcherWhenServletPathThenMatchesOnlyServletPath() { + PathPatternRequestMatcher.Builder servlet = PathPatternRequestMatcher.withDefaults() + .servletPath("/servlet/path"); + RequestMatcher matcher = servlet.matcher(HttpMethod.GET, "/endpoint"); + ServletContext servletContext = servletContext("/servlet/path"); + MockHttpServletRequest mock = get("/servlet/path/endpoint").servletPath("/servlet/path") + .buildRequest(servletContext); + ServletRequestPathUtils.parseAndCache(mock); + assertThat(matcher.matches(mock)).isTrue(); + mock = get("/endpoint").servletPath("/endpoint").buildRequest(servletContext); + ServletRequestPathUtils.parseAndCache(mock); + assertThat(matcher.matches(mock)).isFalse(); + } + + @Test + void matcherWhenRequestPathThenIgnoresServletPath() { + PathPatternRequestMatcher.Builder request = PathPatternRequestMatcher.withDefaults(); + RequestMatcher matcher = request.matcher(HttpMethod.GET, "/endpoint"); + MockHttpServletRequest mock = get("/servlet/path/endpoint").servletPath("/servlet/path").buildRequest(null); + ServletRequestPathUtils.parseAndCache(mock); + assertThat(matcher.matches(mock)).isTrue(); + mock = get("/endpoint").servletPath("/endpoint").buildRequest(null); + ServletRequestPathUtils.parseAndCache(mock); + assertThat(matcher.matches(mock)).isTrue(); + } + + @Test + void matcherWhenServletPathThenRequiresServletPathToExist() { + PathPatternRequestMatcher.Builder servlet = PathPatternRequestMatcher.withDefaults() + .servletPath("/servlet/path"); + RequestMatcher matcher = servlet.matcher(HttpMethod.GET, "/endpoint"); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy( + () -> matcher.matches(get("/servlet/path/endpoint").servletPath("/servlet/path").buildRequest(null))); + } + + @Test + void servletPathWhenEndsWithSlashOrStarThenIllegalArgument() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> PathPatternRequestMatcher.withDefaults().servletPath("/path/**")); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> PathPatternRequestMatcher.withDefaults().servletPath("/path/*")); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> PathPatternRequestMatcher.withDefaults().servletPath("/path/")); + } + + MockHttpServletRequest request(String uri) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", uri); + ServletRequestPathUtils.parseAndCache(request); + return request; + } + + MockHttpServletRequest request(String method, String uri, String servletPath) { + MockHttpServletRequest request = new MockHttpServletRequest(method, uri); + request.setServletPath(servletPath); + ServletRequestPathUtils.parseAndCache(request); + return request; + } + + MockServletContext servletContext(String... servletPath) { + MockServletContext servletContext = new MockServletContext(); + ServletRegistration.Dynamic registration = servletContext.addServlet("servlet", Servlet.class); + for (String s : servletPath) { + registration.addMapping(s + "/*"); + } + return servletContext; + } + +}