mitul bhatnagar
mitul bhatnagar

Reputation: 33

TestContainers with Spring Boot 3.0 and Keycloak: Should I use Keycloak Container for Unit or Integration Tests?

I'm in the process of exploring TestContainers with Spring Boot 3.0 for my Hello World application which has security-enabled endpoints. In the past, I relied on mocked tokens for unit testing purposes. However, with the integration of TestContainers, I'm wondering about the best approach for incorporating Keycloak.

Specifically, I'm unsure whether I should use the Keycloak Container for my unit tests or reserve it for integration tests. My understanding is that TestContainers are often used to provide real instances of external services, like databases, during integration testing. But would it also make sense to use a Keycloak Container for unit tests to ensure that the authentication and authorization aspects are thoroughly tested within the controlled environment of a container?

Could someone please provide insights into the pros and cons of using the Keycloak Container for both unit and integration tests in the context of Spring Boot 3.0? Additionally, any guidance on the best practices for incorporating Keycloak with TestContainers in different testing scenarios would be greatly appreciated.

Thank you!

Initial Test Case with Mockito

@Test     
void indexGreetsAuthenticatedUser() throws Exception {
    this.mockMvc.perform(get("/hello")
        .with(jwt().jwt((jwt) -> jwt.claim("client_id","testclient"))))
        .andExpect(content().string(containsString("Hello world")));     
}

New Code with Test Containers

@Test     
void indexGreetsAuthenticatedUser() throws Exception {
    RestAssured.given()
               .header("Authorization",getClientCredBearer()).when()
               .get("/hello").then().body(containsString("Hello world"));     
}

where getClientCredBearer() enables the keycloak container and calls the token endpoint to generate a client Credential token.

Upvotes: 0

Views: 1043

Answers (1)

ch4mp
ch4mp

Reputation: 12754

I would say "certainly not use test container during unit tests". This is way slower:

  • with a test container, in addition to lifting the container, you request for valid tokens over the network, decode it and then build the Authentication instance to put in the security-context
  • with MockMvc request postprocessors (the .with(jwt()) you are using), WebTestClient mutators and test annotations, build the Authentication directly (no token is created nor decoded, nor validated, we skip directly to the result: building the Authentication instance).

Also, with test annotations, you can setup security when unit-testing other components than @Controller. Consider the following for instance:

@Service
public class SecuredService {
    @PreAuthorize("hasAuthority('NICE')")
    String nice() {
        return "Dear %s, glad to see you!".formatted(SecurityContextHolder.getContext().getAuthentication().getName());
    }
    
    @PreAuthorize("isAuthenticated()")
    String hello() {
        return "Hello %s.".formatted(SecurityContextHolder.getContext().getAuthentication().getName());
    }
}

With RestAssured and a test container, the security-context is built as part of the request filtering. But it makes no sense to send an HTTP request to unit-test a @Service.

Instead of the MockMvc post processor I contributed to spring-security-test, you might consider using the test annotations I publish in spring-addons-oauth2-test.

With "my" annotations, you have different choices to test the service above (note the compatibility with JUnit 5 @ParameterizedTest):

  • if defining just authorities (and maybe name and Authentication type to build), @WithMockAuthentication is probably enough:
@SpringBootTest(classes = { SecurityConfig.class, MessageService.class })
class MessageServiceTests {

    @Autowired
    private SecuredService securedService;
    
    @Test
    @WithMockAuthentication("BAD_BOY")
    void givenUserIsNotGrantedWithNice_whenCallNice_thenThrows() {
        assertThrows(Exception.class, () -> securedService.nice());
    }

    @Test
    @WithMockAuthentication(name = "brice", authorities = "NICE")
    void givenUserIsNice_whenCallNice_thenReturnsGreeting() {
        assertThat(securedService.nice()).isEqualTo("Dear brice, glad to see you!");
    }

    @ParameterizedTest
    @AuthenticationSource(
        @WithMockAuthentication(name = "brice", authorities = "NICE"),
        @WithMockAuthentication(name = "ch4mp", authorities = { "VERY_NICE", "AUTHOR" }))
    void givenUserIsAuthenticated_whenCallHello_thenReturnsGreeting(@ParameterizedAuthentication Authentication auth) {
        assertThat(securedService.hello()).isEqualTo("Hello %s.".formatted(auth.getName()));
    }
}
  • if you want a complete control on all claims, then @WithJwt (or @WithOpaqueToken if you're using introspection) might be better suited:
@AddonsWebmvcComponentTest // omit if you're not using the starter, this loads a minimal subset of spring-addons security conf
@SpringBootTest(classes = { SecurityConfig.class, MessageService.class })
class MessageServiceTests {

    @Autowired
    private SecuredService securedService;

    @Autowired
    WithJwt.AuthenticationFactory authFactory;
    
    @Test
    @WithJwt("igor.json")
    void givenUserIsIgor_whenCallNice_thenThrows() {
        assertThrows(Exception.class, () -> securedService.nice());
    }

    @Test
    @WithJwt("brice.json")
    void givenUserIsBrice_whenCallNice_thenReturnsGreeting() {
        assertThat(securedService.nice()).isEqualTo("Dear brice, glad to see you!");
    }

    @ParameterizedTest
    @MethodSource("identities")
    void givenUserIsAuthenticated_whenCallHello_thenReturnsGreeting(@ParameterizedAuthentication Authentication auth) {
        assertThat(securedService.hello()).isEqualTo("Hello %s.".formatted(auth.getName()));
    }

    Stream<AbstractAuthenticationToken> identities() {
        return authFactory.authenticationsFrom("brice.json", "igor.json");
    }
}

@WithJwt will search for an authentication converter in your security conf (a Converter<Jwt, ? extends AbstractAuthenticationToken> as you most probably have defined to turn Keycloak roles into Spring Security authorities) and use it to build an Authentication instance out of a JSON payload in the test class-ath (inspired by a JWT payload or introspection response). So basically, the Authentication instance should be the exact same as with a real authorization server delivering a token with the same claims (name, authorities, Authentication impl, etc.).

Upvotes: 1

Related Questions