With this blog post, we’ll explore how to test secured Spring Web MVC endpoints with Spring Boot and MockMvc. Spring Web MVC allows us to map HTTP endpoints to Java methods to e.g., develop an HTTP API for our customers. As most of these endpoints return sensitive information, we usually secure them with Spring Security to avoid anonymous access.
We’ll look at some recipes for how to invoke our controller endpoints from the perspective of a logged-in user. Furthermore, we’ll learn how to test our authentication and authorization rules by invoking our endpoints with various user configurations.
After working through this article, you’ll…
- No longer procrastinate on writing tests for protected endpoints
- Appreciate Spring Security’s excellent testing support
- Learn that testing protected endpoints is almost as convenient as testing an unsecured endpoint
- Understand how to “inject” and change the SecurityContext for testing purposes and bypass a login
- Have recipes at hand to test your Spring Security authentication and authorization rules
Sample Spring Boot Application Walkthrough
Throughout this article, we’re going to test a sample Java 17 Spring Boot application. To mix things up, we’re going to protect two controller endpoints with two different authentication mechanisms.
The first Spring Web MVC endpoint (/admin) returns a server-side rendered Thymeleaf view and is secured by a form login. Whenever an authenticated user visits the page, they’ll be redirected to a login form if they’re not logged in.
Our second endpoint (/api/customers) represents a REST API to fetch data about customers. For this use case, our Spring Boot application acts as an OAuth2 resources server. Incoming client requests need a valid JSON Web Token (JWT) as part of the HTTP Authorization header to get past our security filters. This is a common authentication mechanism for today’s microservices. Our clients may be actual users that obtain their token with the OAuth2 authorization code flow or another microservice that uses the client credentials flow to get a token.
Independent on how our clients get their JWT, Spring Security will validate the signature of the token and only allow the user to access the endpoint if the signature is valid and the token hasn’t expired yet.
For this controller and security setup, we include the following Spring Boot Starter dependencies to our pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
We then configure this authentication protection with the following Spring Security configuration:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain adminPageSecurity(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.antMatcher("/admin")
.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.formLogin();
return httpSecurity.build();
}
@Bean
@Order(2)
public SecurityFilterChain restApiSecurity(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.antMatcher("/api/**")
.authorizeHttpRequests()
.anyRequest()
.authenticated()
.and()
.oauth2ResourceServer()
.jwt();
return httpSecurity.build();
}
}
Following a component-based Spring security configuration, we provide two SecurityFilterChain beans with different orders. Each filter chain has the responsibility to configure the authentication rules for a specific path (see antMatcher()).
With this configuration, our Thymeleaf view is protected with a form login, and accessing our REST API requires a valid JWT as part of the HTTP Authorization header.
Testing Controller Endpoints with MockMvc and @WebMvcTest
When testing Spring Web MVC controller endpoints, we can use the Spring Boot test slice annotation @WebMvcTest to test our controllers in isolation. With @WebMvcTest, Spring Boot will configure a minimal ApplicationContext for us. This context contains only beans that are relevant for testing a Spring Web MVC controller.
This includes the actual controller bean, filter, converter, @ControllerAdvice, and other Web MVC-related beans. However, Spring won’t initialize our service, repository, or component beans.
When writing tests with @WebMvcTest, we usually use @MockBean to mock any collaborator for our controller that is not relevant for testing our endpoint. This may be a service class or any other component that our controller interacts with.
Furthermore, Spring Boot will autoconfigure MockMvc for us when using @WebMvcTest. MockMvc is a mocked servlet environment that we can use to test our HTTP controller endpoints without the need to launch our embedded servlet container.
While MockMvc is a mocked environment, it still comes with HTTP semantics so that we can test the serialization, HTTP status codes, and return types of our endpoints.
It’s superior to a plain unit test for our controller endpoint as MockMvc also comes with a Spring Security integration for us to verify authentication and authorization. When writing a unit test with JUnit and Mockito, we would be able to directly invoke the controller mapping methods and verify their behavior. However, we would miss out on a lot of HTTP semantics and even though we had a lot of branch coverage, are not able to ensure our endpoints are properly protected, and our authorization rules apply.
For our Spring Security rules to be applied, we have to import the security configuration with @Import to our test class if we’re using a component-based security configuration. When previously extending the WebSecurityConfigurerAdapter class with a custom bean, Spring Boot picked up the configuration for us. However, the WebSecurityConfigurerAdapter is deprecated as of Spring Security 5.7.0-M2 in favor of a component-based config.
Testing Recipe One: Form Login
For the first testing recipe, we want to test our server-side rendered Thymeleaf view that is protected by a form login.
A naive test with MockMvc that tries to directly invoke the endpoint without any authentication will fail:
@Import(SecurityConfig.class)
@WebMvcTest(AdminController.class)
class AdminControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnAdminView() throws Exception {
this.mockMvc
.perform(get("/admin"))
.andExpect(status().is(200));
}
}
Instead of returning the HTTP status code 200 (aka. OK), our backend (Spring Security, to be more specific) redirects us to the login screen with an HTTP 302 response code.
That’s what we’re expecting as this endpoint is protected.
We can slightly refactor this test and adjust the expected response code to 302. Such a test will verify that anonymous users won’t be able to access our endpoints. This can be quite helpful when we’re doing larger refactorings for our security configuration.
Moving on, how can we invoke our endpoint with MockMvc and associate a logged-in user?
This is where the spring-security-test dependency comes into play. Before we continue testing our secured endpoint, we add this dependency to our test dependencies with our pom.xml:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
As MockMvc operates in a mocked servlet environment, we can interfere with the Spring Security context and place any principal into it. The spring-security-test dependency provides RequestPostProcessors for us to associate with the MockMvc request.
When preparing the request with MockMvc, we can use the with() method to attach such a processor. There’s a processor for all common authentication mechanisms.
For the happy-path test of accessing the Thymeleaf controller with an authenticated user, we can use the user(“username”) RequestPostProcessor:
@Test
void shouldReturnAdminView() throws Exception {
this.mockMvc.perform(get("/admin")
.with(SecurityMockMvcRequestPostProcessors.user("diffblue")))
.andExpect(status().is(200));
}
The test above now verifies the happy path of invoking our controller endpoint with a logged-in user. Behind the scenes, Spring Security Test will place an authenticated user into the SecurityContext, letting our request pass.
As an alternative, we can use the @WithMockUser from the spring-security-test on top of our test method. This annotation will ensure our entire test runs with the associated users, and we pass any security filter by default:
@Test
@WithMockUser("diffblue")
void shouldReturnAdminViewAlternative() throws Exception {
this.mockMvc.perform(get("/admin"))
.andExpect(status().is(200));
}
Testing Recipe Two: JWT Authentication
For our second testing recipe, we’ll move on to our JWT-protected REST API endpoint.
Imagine we have to create a valid JWT for our tests. We’d need access to the identity server to create a valid and signed JWT. That’s quite a lot of additional test setup, and would make things unnecessarily complex.
Lucky us, we can inject the authenticated user similarly to the last recipe.
Similar to the user() RequestPostProcessor, there’s a jwt() processor ready to be used:
@Import(SecurityConfig.class)
@WebMvcTest(CustomerController.class)
class CustomerControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnCustomerList() throws Exception {
this.mockMvc
.perform(get("/api/customers")
.with(SecurityMockMvcRequestPostProcessors.jwt()))
.andExpect(status().is(200));
}
}
If our endpoint or any filter access specific claims of our JWT, we can even pass additional claims on our own and fine-tune the associated JWT:
@Test
void shouldReturnCustomerList() throws Exception {
this.mockMvc
.perform(get("/api/customers")
.with(jwt().jwt(builder -> builder
.claim("email", "[email protected]")
.claim("custom_claim", "value42")
.claim("preferred_username", "duke"))))
.andExpect(status().is(200));
}
This low-level access to the JWT and the simplicity of testing a secured endpoint makes it simple to test various scenarios.
Testing Recipe Three: Testing with Different Roles
While the last two recipes have covered different authentication mechanisms, let’s see how we can test the authorization part with this last recipe.
As defined in our Spring Security configuration, we allow any authenticated user to fetch all customers from our REST API. But only users that have the ADMIN role are allowed to actually delete users.
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority(‘ROLE_ADMIN’)")
public void deleteCustomer(@PathVariable("id") Long id) {
// delete customer by id
}
So what’s left for us is to write a test for the HTTP DELETE endpoint for the scenario of an authorized and unauthorized use case.
Let’s start with verifying that our endpoint returns 403 if the user is authenticated but not authorized (aka. lacking the ADMIN role). We’re going to build on top the second recipe and use the jwt() processor:
@Test
void shouldForbidDeletingCustomerWithMissingRole() throws Exception {
this.mockMvc
.perform(delete("/api/customers/1")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().is(403));
}
We chain the authorities() method to the jwt() RequestPostProcessor and statically define the authorities of the associated user. For this test, we attach the USER role to the user and expect an HTTP forbidden status code.
Running the test will result in the expected outcome, and our security configuration successfully restricts the access even though the user is authenticated.
Testing the happy path of accessing the endpoint with a user that has the required role is now a matter of seconds. We can use the previous test as a blueprint, adjust the role setup and change the expected HTTP response code to 200:
@Test
void shouldAllowDeletingCustomerWithMissingRole() throws Exception {
this.mockMvc
.perform(delete("/api/customers/1")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().is(200));
}
Summary of Testing Secured Spring Boot Endpoints
Testing our security configuration is crucial. If we don’t ensure our endpoints are properly protected, we risk exposing sensitive data to the public. Having tests for our authentication and authorization rules in place also lets us upgrade our Spring Boot application more confidently. We can even refactor our Spring Security configuration without having to test the protection mechanism manually.
Given these three testing recipes, we’ve seen that testing a protected endpoint is almost as convenient as testing an unprotected endpoint.
Thanks to the spring-security-test dependency and Spring Security’s excellent integration with MockMvc, we can fine-tune the authenticated user on a per request basis. The SecurityMockMvcRequestPostProcessor class offers a utility method for all common authentication mechanisms.
Last but not least, we can also fall back to using @WithMockUser to associate an authenticated user for an entire test method or class.