Testing Spring REST controllers is a fundamental skill for any developer working with Spring Boot. However, when Spring Security is added into the mix, testing the web layer of an application becomes a more complex task. This is because Spring Security enforces stringent access controls, allowing only authenticated and authorized users to access specific endpoints. This topic will delve into the tools for testing these protected endpoints that ensure your application remains secure and functional.
Getting started
To illustrate the problem and its solution, we will create a simple web application protected by Spring Security and test its endpoints. Here are the necessary dependencies:
Maven
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Gradle (Groovy)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}Gradle (Kotlin)
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}Our REST controller will have two GET endpoints:
Java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DemoController {
@GetMapping(path = "/secured")
public String secured() {
return "Granted access to GET /secured";
}
@GetMapping(path = "/read")
public String read() {
return "Granted access to GET /read";
}
}Kotlin
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class DemoController {
@GetMapping(path = ["/secured"])
fun secured(): String = "Granted access to GET /secured"
@GetMapping(path = ["/read"])
fun read(): String = "Granted access to GET /read"
}The Spring Security configuration for this controller could look like this:
Java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/read").hasAuthority("read")
.anyRequest().authenticated()
)
.build();
}
@Bean
public UserDetailsService userDetailsService() {
var userDetailsManager = new InMemoryUserDetailsManager();
var reader = User.withUsername("reader")
.password("{noop}pass")
.authorities("read")
.build();
userDetailsManager.createUser(reader);
return userDetailsManager;
}
}Kotlin
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.config.Customizer
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
@Configuration
class SecurityConfig {
@Bean
@Throws(Exception::class)
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests { auth ->
auth
.requestMatchers(HttpMethod.GET, "/read").hasAuthority("read")
.requestMatchers(HttpMethod.GET, "/books/**").permitAll()
.anyRequest().authenticated()
}
.build()
}
@Bean
fun userDetailsService(): UserDetailsService {
val userDetailsManager = InMemoryUserDetailsManager()
val reader = User.withUsername("reader")
.password("{noop}pass")
.authorities("read")
.build()
userDetailsManager.createUser(reader)
return userDetailsManager
}
}In this configuration, we enabled basic HTTP authentication with default settings. Any request to /secured requires the user to be authenticated, and any request to /read requires the user to have the "read" authority. We used InMemoryUserDetailsManager to store user details in memory and created a hard-coded user with username "reader", password "pass", and authority "read". We store the user's password in plain text (the "{noop}" prefix) for simplicity.
First test
If you attempt to write a unit test with MockMvc, as we usually do for REST controllers, you will find out that the test method can't authenticate, so the 401 UNAUTHORIZED status code will be returned:
Java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(DemoController.class)
class DemoControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void secured_returns401() throws Exception {
var requestBuilder = get("/secured");
mockMvc.perform(requestBuilder)
.andExpect(status().isUnauthorized());
}
// other tests go here...
}Kotlin
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@WebMvcTest(DemoController::class)
@Import(SecurityConfig::class)
class DemoControllerTest(@Autowired private val mockMvc: MockMvc) {
@Test
@Throws(Exception::class)
fun `GET secured returns 401`() {
val requestBuilder = get("/secured")
mockMvc.perform(requestBuilder)
.andExpect(status().isUnauthorized())
}
// other tests go here...
}We create a MockHttpServletRequestBuilder for a GET request to the /secured endpoint and assert that the response status is 401 UNAUTHORIZED. This is where we encounter our problem: the test cannot authenticate.
In the following sections, we will explore how to pass authentication details to access the endpoint using different tools provided by Spring Security test support.
Mock user
Spring Security test support offers the @WithMockUser annotation as a solution to the authentication issue. This annotation establishes a security context for the test, simulating an authenticated user. Here's how to implement it:
Java
@Test
@WithMockUser
void secured_withMockUser_returns200() throws Exception {
var requestBuilder = get("/secured");
mockMvc.perform(requestBuilder)
.andExpect(status().isOk());
}Kotlin
@Test
@WithMockUser
@Throws(Exception::class)
fun `GET secured with MockUser returns 200`() {
val requestBuilder = get("/secured")
mockMvc.perform(requestBuilder)
.andExpect(status().isOk())
}This annotation allows the test to access the endpoint successfully. However, if we use this annotation to access the /read endpoint, which requires specific user authority for authorization, a 403 FORBIDDEN status code will be returned:
Java
@Test
@WithMockUser
void read_withMockUser_returns403() throws Exception {
var requestBuilder = get("/read");
mockMvc.perform(requestBuilder)
.andExpect(status().isForbidden());
}Kotlin
@Test
@WithMockUser
@Throws(Exception::class)
fun `GET read with MockUser returns 403`() {
val requestBuilder = get("/read")
mockMvc.perform(requestBuilder)
.andExpect(status().isForbidden())
}This happens because @WithMockUser, by default, creates a user with the username "user", password "password", and a single authority "ROLE_USER". We can customize these details by adding parameters to the annotation:
Java
@Test
@WithMockUser(username = "fake user", password = "fake password", authorities = {"read", "write"})
void read_withReaderMockUser_returns200() throws Exception {
var requestBuilder = get("/read");
mockMvc.perform(requestBuilder)
.andExpect(status().isOk());
}Kotlin
@Test
@WithMockUser(username = "fake user", password = "fake password", authorities = ["read", "write"])
@Throws(Exception::class)
fun `GET read with 'reader' MockUser returns 200`() {
val requestBuilder = MockMvcRequestBuilders.get("/read")
mockMvc.perform(requestBuilder)
.andExpect(status().isOk())
}This sets up a SecurityContext for the test, with an Authentication object containing the given username, password, and authorities. This Authentication is used throughout the test method.
Although @WithMockUser is useful for testing authorization rules, it doesn't cover all scenarios. For example, it doesn't support testing with different users in a single test. For these situations, we use MockMvc request post-processors.
Request post-processors
The MockMvcRequestBuilders class has a static with method that accepts a RequestPostProcessor instance as an argument to set up security context for a request when testing with MockMvc. The post-processor instances are created using static methods of the SecurityMockMvcRequestPostProcessors class. Such post-processors are used to modify a request from MockMvc after it's been built, but before it's passed to the DispatcherServlet.
SecurityMockMvcRequestPostProcessors has a variety of utility methods to support different authentication scenarios. For example, it has the overloaded user method to build a RequestPostProcessor object that will populate the test security context with the corresponding Authentication object:
Java
@Test
void read_withReaderFakeUser_returns200() throws Exception {
var postProcessor = SecurityMockMvcRequestPostProcessors
.user("fake user")
.authorities(() -> "read");
var requestBuilder = MockMvcRequestBuilders.get("/read")
.with(postProcessor);
mockMvc.perform(requestBuilder)
.andExpect(status().isOk());
}Kotlin
@Test
@Throws(Exception::class)
fun `GET read with 'reader' fake user returns 200`() {
val postProcessor = SecurityMockMvcRequestPostProcessors
.user("fake user")
.authorities(GrantedAuthority { "read" })
val requestBuilder = get("/read")
.with(postProcessor)
mockMvc.perform(requestBuilder)
.andExpect(status().isOk())
}Here, the user method generates a RequestPostProcessor that fills the SecurityContext with an Authentication object containing the specified user details. Consequently, the request is authorized, and the tested controller returns a 200 OK status code.
However, testing with fake users isn't always ideal. For example, for basic HTTP authentication, the httpBasic method creates a RequestPostProcessor that adds a basic HTTP authentication header with the given username and password:
Java
@Test
void read_withRealReaderUser_returns200() throws Exception {
var postProcessor = SecurityMockMvcRequestPostProcessors
.httpBasic("reader", "pass");
var requestBuilder = get("/read")
.with(postProcessor);
mockMvc.perform(requestBuilder)
.andExpect(status().isOk());
}Kotlin
@Test
@Throws(Exception::class)
fun `GET read with real 'reader' user returns 200`() {
val postProcessor = SecurityMockMvcRequestPostProcessors
.httpBasic("reader", "pass")
val requestBuilder = get("/read")
.with(postProcessor)
mockMvc.perform(requestBuilder)
.andExpect(status().isOk())
}Here, we test both authentication and authorization using actual user credentials. Unlike using fake user details, providing incorrect credentials will result in failed authentication:
Java
@Test
void read_withBadCredentials_returns401() throws Exception {
var postProcessor = SecurityMockMvcRequestPostProcessors
.httpBasic("hacker", "12345");
var requestBuilder = get("/read")
.with(postProcessor);
mockMvc.perform(requestBuilder)
.andExpect(status().isUnauthorized());
}Kotlin
@Test
@Throws(Exception::class)
fun `GET read with bad credentials returns 401`() {
val requestBuilder = get("/read")
.with(SecurityMockMvcRequestPostProcessors.httpBasic("hacker", "12345"))
mockMvc.perform(requestBuilder)
.andExpect(status().isUnauthorized())
}These methods offer extensive flexibility in setting up your tests' security context, enabling you to examine a broad range of security scenarios.
Conclusion
Testing REST controllers in Spring Boot that are protected with Spring Security can be challenging due to the authentication and authorization requirements. Spring Security test support provides the right tools, such as @WithMockUser and SecurityMockMvcRequestPostProcessors, to effectively test secured endpoints. These tools allow you to ensure accurate authentication and proper enforcement of authorization rules.