Site icon JVM Advent

Mastering Spring Security integration testing for your apps

“99 little bugs in the code,
99 little bugs.
Track one down, patch it around,
There’s 113 little bugs in the code.”
—Anonymous

This article is based on chapter 18 of Spring Security in Action, 2nd edition (a book I wrote). Find more detailed content in the book.

Over time, as software grew more complex and development teams expanded, it became unfeasible for individual developers to keep track of all the functionalities added by their peers. To ensure that new bug fixes or features didn’t disrupt existing functionalities, developers needed an effective strategy. The primary purpose of writing unit and integration tests is to validate the newly implemented functionalities and prevent the inadvertent disruption of existing ones when modifying code. This process is known as regression testing.

In modern development practices, when a developer completes a code modification, they upload these changes to a shared server for code version management. This action triggers a continuous integration tool that automatically executes all pre-existing tests. If a test fails due to the recent changes, indicating a disruption in existing functionality, the continuous integration tool alerts the entire team (as illustrated in figure 1). This approach significantly reduces the risk of introducing changes that negatively impact established features.

Figure 1 Testing is part of the development process. Anytime a developer uploads code, the tests run. If any test fails, a continuous integration tool notifies the developer.

It’s important to recognize that testing your application involves more than just examining your own code. Equally crucial is testing how your application interacts with the frameworks and libraries it utilizes (as depicted in figure 2). At some point, you might update these frameworks or libraries to their newer versions. During such updates, it’s essential to verify that your application still seamlessly integrates with these updated dependencies. If the integration isn’t as smooth as before, you’ll want to swiftly identify and rectify the areas in your application that need adjustments to resolve any integration issues.

Figure 2 The functionality of an application relies on many dependencies. When you upgrade or change a dependency, you might affect existing functionality. Having integration tests with dependencies helps you to discover quickly if a change in a dependency affects the existing functionality of your application.

Understanding how to test your application’s integration with Spring Security is a vital aspect of this article. The Spring framework, including Spring Security, is rapidly evolving. As you update your application with new versions of these frameworks, it’s crucial to assess whether these updates introduce any vulnerabilities, errors, or incompatibilities. Prioritizing security from the initial design stage of your app is essential. Implementing tests for security configurations should be a standard procedure, and a task shouldn’t be considered complete without these security tests.

This article delves into various strategies for testing an application’s integration with Spring Security. We’ll analyse some examples to guide you on crafting effective integration tests for the functionalities you’ve implemented. Testing, as a general concept, is fundamental, and gaining an in-depth understanding of it offers numerous advantages.

Our focus will be on the interaction between an application and Spring Security. Before jumping into examples, I recommend a few resources that have deepened my understanding of this topic. For a more detailed understanding or a quick refresher, consider these insightful books:

I. Using mock users for tests

This section focuses on the use of mock users for testing authorization configurations, a method that is both straightforward and popular. By employing a mock user in a test, you bypass the authentication process entirely (as illustrated in figure 3).

Testing authorization configurations often involves skipping the authentication step. This is because it’s not necessary to verify the authentication process each time you’re assessing whether the system correctly implements an authorization rule. It’s important to remember that while authentication and authorization are interdependent, they are separated within the security context. To isolate and test an authorization configuration, you can create a mock security context, allowing you to control and test various authorization scenarios as needed.

In most applications, there are typically a few authentication methods (often just one), but a much broader array of authorization rules that apply to different use cases or endpoints. Therefore, it’s more efficient to test authorization rules in isolation without repeating authentication tests each time you need to confirm that the authorization for a specific component is functioning correctly.

The mock user, which is only active during the test, can be configured with any attributes necessary to validate specific scenarios. For instance, you can assign the user certain roles (like ADMIN or MANAGER) or various authorities to ensure that the application behaves as expected under these conditions.

 

Figure 3 We skip the shaded components in the Spring Security authentication flow when executing a test. The test directly uses a mock SecurityContext, which contains the mock user you define to call the tested functionality.

 

We need a couple of dependencies in the pom.xml file to write the tests. The next code snippet shows you the classes we use throughout the examples in this article. You should make sure you have these in your pom.xml file before starting to write the tests. Here are the dependencies:

<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>

 

In the test folder of your Spring Boot project, we add a class named MainTests. We write this class as part of the main package of the application.  In listing 1, you can find the definition of the empty class for the tests. We use the @SpringBootTest annotation, which represents a convenient way to manage the Spring context for our test suite.

Listing 1 A class for writing the tests

@SpringBootTest     
public class MainTests {

    // test class contents go here
}

A convenient way to implement a test for the behavior of an endpoint is by using Spring’s MockMvc. In a Spring Boot application, you can autoconfigure the MockMvc utility for testing endpoint calls by adding an annotation over the class, as the next listing presents.

Listing 2 Adding MockMvc for implementing test scenarios

@SpringBootTest
@AutoConfigureMockMvc 
public class MainTests {

  @Autowired
  private MockMvc mvc; 

}

Now that we have a tool we can use to test endpoint behavior, let’s get started with the first scenario. When calling the /hello endpoint without an authenticated user, the HTTP response status should be 401 Unauthorized.

You can visualize the relationship between the components for running this test in figure 4. The test calls the endpoint but uses a mock SecurityContext. We decide what we add to this SecurityContext. For this test, we need to check that if we don’t add a user that represents the situation in which someone calls the endpoint without authenticating, the app rejects the call with an HTTP response having the status 401 Unauthorized. When we add a user to the SecurityContext, the app accepts the call, and the HTTP response status is 200 OK.

Figure 4 During the test execution, we bypass the authentication step. The test employs a mock SecurityContext and accesses the /hello endpoint provided by HelloController. To ensure the app’s behavior aligns with the set authorization rules, a mock user is introduced within the test’s SecurityContext. In scenarios where a mock user isn’t defined, we anticipate the app will deny authorization for the call. Conversely, if a user is defined, the expectation is that the call will be successfully authorized.

The following listing presents this scenario’s implementation.

Listing 3 Testing that you can’t call the endpoint without an authenticated user


@SpringBootTest
@AutoConfigureMockMvc
public class MainTests {

  @Autowired
  private MockMvc mvc;

  @Test
  public void helloUnauthenticated() throws Exception {
    mvc.perform(get("/hello")) #A
       .andExpect(status().isUnauthorized()); 
  }

}

 

Mind that we statically import the methods get() and status(). You find the method get() and similar methods related to the requests we use in the examples of this article in this class:

org.springframework.test.web.servlet.request.MockMvcRequestBuilders

Also, you find the method status() and similar methods related to the result of the calls that we use in the next examples of this article in this class:

 
org.springframework.test.web.servlet.result.MockMvcResultMatchers

You can run the tests now and see the status in your IDE. Usually, in any IDE, to run the tests, you can right-click on the test’s class and then select Run. The IDE displays a successful test with green and a failing one with another color (usually red or yellow).

NOTE In the projects provided with the book, above each method implementing a test, I also use the @DisplayName annotation. This annotation allows us to have a longer, more detailed description of the test scenario. To occupy less space and allow you to focus on the functionality of the tests we discuss, I took the @DisplayName annotation out of the listings in the book.

 

To test the second scenario, we need a mock user. To validate the behavior of calling the /hello endpoint with an authenticated user, we use the @WithMockUser annotation. By adding this annotation above the test method, we instruct Spring to set up a SecurityContext that contains a UserDetails implementation instance. It’s basically skipping authentication. Now, calling the endpoint behaves like the user defined with the @WithMockUser annotation successfully authenticated.

With this simple example, we don’t care about the details of the mock user like its username, roles, or authorities. So we add the @WithMockUser annotation, which provides some defaults for the mock user’s attributes. Later in this article, you’ll learn to configure the user’s attributes for test scenarios in which their values are important. The next listing provides the implementation for the second test scenario.

Listing 4 Using @WithMockUser to define a mock authenticated user

@SpringBootTest
@AutoConfigureMockMvc
public class MainTests {

  @Autowired
  private MockMvc mvc;

  // Omitted code

  @Test 
  @WithMockUser 
  public void helloAuthenticated() throws Exception {
    mvc.perform(get("/hello")) 
       .andExpect(content().string("Hello!"))
       .andExpect(status().isOk());
  }

}

 

Run this test now and observe its success. But in some situations, we need to use a specific name or give the user specific roles or authorities to implement the test.

II. Testing with users from a User Details Service

This section explores the method of sourcing user details for testing from a UserDetailsService, offering an alternative to the use of mock users. The key variation here is that, rather than fabricating a user, we retrieve the user information from a specified UserDetailsService. This technique is particularly useful when you aim to test the integration with the data source that your application utilizes for loading user details (as shown in figure 5).

 

Figure 5 Instead of creating a mock user for the test when building the SecurityContext used by the test, we take the user details from a UserDetailsService. This way, you can test authorization using real users taken from a data source. During the test, the flow of execution skips the shaded components.

Note that, with this approach, we need to have a UserDetailsService bean in the context. To specify the user we authenticate from this UserDetailsService, we annotate the test method with @WithUserDetails. With the @WithUserDetails annotation, to find the user, you specify the username. The following listing presents the implementation of the test for the /hello endpoint using the @WithUserDetails annotation to define the authenticated user.

Listing 5 Defining the authenticated user with the @WithUserDetails annotation

@SpringBootTest
@AutoConfigureMockMvc
public class MainTests {

  @Autowired
  private MockMvc mvc;

  @Test
  @WithUserDetails("john") 
  public void helloAuthenticated() throws Exception {
    mvc.perform(get("/hello"))
    .andExpect(status().isOk());
  }

}

 

III. Using custom Authentication objects for testing

In most cases when employing a mock user for testing, the specific class used by the framework to generate Authentication instances in the SecurityContext is not a concern. However, there might be scenarios where your controller logic depends on the type of the object within the SecurityContext. This raises the question: is it possible to guide the framework to create a specific type of Authentication object for testing purposes? The answer is affirmative, and that’s the focus of this section.

The strategy is straightforward. We introduce a factory class tasked with constructing the SecurityContext. This gives us complete command over the creation process of the SecurityContext for the test, including its contents (as demonstrated in figure 6). For instance, this allows for the inclusion of a custom Authentication object in the SecurityContext.

Figure 6 For complete mastery over the definition of the SecurityContext in testing, we construct a factory class. This class provides guidance on constructing the SecurityContext for the test, offering enhanced flexibility. It allows us to select specific details, such as the type of object to employ as an Authentication object. In the accompanying figure, the components that are bypassed in the test flow are highlighted for clarity.

 

Let’s  write a test in which we configure the mock SecurityContext and instruct the framework on how to create the Authentication object. An interesting aspect to remember about this example is that we use it to prove the implementation of a custom AuthenticationProvider. The custom AuthenticationProvider we implement in our case only authenticates a user named John. However, as in the other two previous approaches we discussed in sections I and II, the current approach skips authentication.

For this reason, you see at the end of the example that we can give our mock user any name. We follow three steps to achieve this behavior (figure 7):

  1. Write an annotation to use over the test similarly to the way we use @WithMockUser or @WithUserDetails.
  2. Write a class that implements the WithSecurityContextFactory interface. This class implements the createSecurityContext() method that returns the mock SecurityContext the framework uses for the test.
  3. Link the custom annotation created in step 1 with the factory class created in step 2 via the @WithSecurityContext annotation.

 

Figure 7 To enable the test to use a custom SecurityContext, you need to follow the three steps illustrated in this figure. 

 

STEP 1: DEFINING A CUSTOM ANNOTATION

In listing 6, you find the definition of the custom annotation we define for the test, named @WithCustomUser. As properties of the annotation, you can define whatever details you need to create the mock Authentication object. I added only the username here for my demonstration. Also, don’t forget to use the annotation @Retention (RetentionPolicy.RUNTIME) to set the retention policy to runtime. Spring needs to read this annotation using Java reflection at runtime. To allow Spring to read this annotation, you need to change its retention policy to RetentionPolicy.RUNTIME.

Listing 6 Defining the @WithCustomUser annotation

@Retention(RetentionPolicy.RUNTIME)
public @interface WithCustomUser {

  String username();
}

STEP 2: CREATING A FACTORY CLASS FOR THE MOCK SECURITYCONTEXT

The second step consists in implementing the code that builds the SecurityContext that the framework uses for the test’s execution. Here’s where we decide what kind of Authentication to use for the test. The following listing demonstrates the implementation of the factory class.

Listing 7 The implementation of a factory for the SecurityContext

public class CustomSecurityContextFactory 
  implements WithSecurityContextFactory<WithCustomUser> {

  @Override 
  public SecurityContext createSecurityContext(
    WithCustomUser withCustomUser) {

    SecurityContext context = 
      SecurityContextHolder.createEmptyContext();

    var a = new UsernamePasswordAuthenticationToken(
    withCustomUser.username(), null, null); 

    context.setAuthentication(a); 

    return context;
  }
}

STEP 3: LINKING THE CUSTOM ANNOTATION TO THE FACTORY CLASS

Using the @WithSecurityContext annotation, we now link the custom annotation we created in step 1 to the factory class for the SecurityContext we implemented in step 2. The following listing presents the change to our @WithCustomUser annotation to link it to the SecurityContext factory class.

Listing 8 Linking the custom annotation to the SecurityContext factory class

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = CustomSecurityContextFactory.class)
public @interface WithCustomUser {

  String username();
}

With this setup complete, we can write a test to use the custom SecurityContext. The next listing defines the test.

Listing 9 Writing a test that uses the custom SecurityContext


@SpringBootTest
@AutoConfigureMockMvc
public class MainTests {
 
  @Autowired
  private MockMvc mvc;

  @Test
  @WithCustomUser(username = "mary") 
  public void helloAuthenticated() throws Exception {
    mvc.perform(get("/hello"))
       .andExpect(status().isOk());
  }
}

Running the test, you observe a successful result. You might think, “Wait! In this example, we implemented a custom AuthenticationProvider that only authenticates a user named John. How could the test be successful with the username Mary?” As in the case of @WithMockUser and @WithUserDetails, with this method we skip the authentication logic. So you can use it only to test what’s related to authorization and onward.

IV Summary

 

Exit mobile version