Step 5: Testing & Validation – Spring Security Series

๐ŸŽฅ YouTube Video: Coming Soon – Subscribe to JavaiOS Channel


Custom Spring Security Testing in Spring Boot ๐Ÿงช

Testing Spring Security implementations, especially custom authentication providers and JWT-based authentication, requires specific strategies and tools. This guide covers comprehensive testing approaches for the AdventureTube authentication system, including integration tests, unit tests, and common testing challenges.


๐ŸŽฏ Testing Strategy Overview

Testing Pyramid for Security

                    E2E Tests
                   โ†—         โ†–
              Integration Tests (Medium)
             โ†—                       โ†–
        Unit Tests (Many)         Component Tests
       โ†—                                       โ†–
MockMvc Tests                              Security Tests

Testing Layers:

  • Unit Tests – Individual components (AuthenticationProvider, UserDetailsService)
  • Integration Tests – Complete authentication flow with mocked external services
  • Security Tests – Authorization, JWT validation, endpoint protection
  • Component Tests – Controller testing with security context
  • E2E Tests – Full system testing including external integrations

๐Ÿ”ง Test Setup and Configuration

Test Dependencies

<dependencies>
    <!-- Spring Boot Test Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    
    <!-- Spring Security Test -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    
    <!-- TestContainers for Integration Tests -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    
    <!-- WireMock for External Service Mocking -->
    <dependency>
        <groupId>com.github.tomakehurst</groupId>
        <artifactId>wiremock-jre8</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Test Configuration Class

@TestConfiguration
public class TestSecurityConfig {

    @Bean
    @Primary
    public PasswordEncoder testPasswordEncoder() {
        // Use NoOpPasswordEncoder for faster tests
        return NoOpPasswordEncoder.getInstance();
    }

    @Bean
    @Primary
    public JwtUtil testJwtUtil() {
        return new JwtUtil() {
            @Override
            public String generateAccessToken(UserDetails userDetails) {
                return "test-access-token-" + userDetails.getUsername();
            }
            
            @Override
            public boolean validateToken(String token, UserDetails userDetails) {
                return token.equals("test-access-token-" + userDetails.getUsername());
            }
        };
    }

    @Bean
    @Primary
    public RestTemplate testRestTemplate() {
        return new RestTemplate();
    }
}

๐Ÿงช Unit Testing Authentication Components

Testing Custom Authentication Provider

@ExtendWith(MockitoExtension.class)
class CustomAuthenticationProviderTest {

    @Mock
    private CustomUserDetailService userDetailsService;
    
    @Mock
    private PasswordEncoder passwordEncoder;
    
    @InjectMocks
    private CustomAuthenticationProvider authenticationProvider;

    private UserDetails testUserDetails;
    private Authentication testAuthentication;

    @BeforeEach
    void setUp() {
        testUserDetails = CustomUserDetails.builder()
            .username("test@example.com")
            .password("encoded-password")
            .googleId("google-123")
            .role("USER")
            .enabled(true)
            .build();

        testAuthentication = new UsernamePasswordAuthenticationToken(
            "test@example.com", "google-123");
    }

    @Test
    @DisplayName("Should authenticate successfully with valid credentials")
    void authenticate_WithValidCredentials_ShouldReturnAuthentication() {
        // Given
        when(userDetailsService.loadUserByUsername("test@example.com"))
            .thenReturn(testUserDetails);

        // When
        Authentication result = authenticationProvider.authenticate(testAuthentication);

        // Then
        assertThat(result).isNotNull();
        assertThat(result.isAuthenticated()).isTrue();
        assertThat(result.getPrincipal()).isEqualTo(testUserDetails);
        assertThat(result.getCredentials()).isEqualTo("google-123");
    }

    @Test
    @DisplayName("Should throw BadCredentialsException for invalid Google ID")
    void authenticate_WithInvalidGoogleId_ShouldThrowException() {
        // Given
        UserDetails invalidUser = CustomUserDetails.builder()
            .username("test@example.com")
            .googleId("different-google-id")
            .build();
            
        when(userDetailsService.loadUserByUsername("test@example.com"))
            .thenReturn(invalidUser);

        // When & Then
        assertThatThrownBy(() -> authenticationProvider.authenticate(testAuthentication))
            .isInstanceOf(BadCredentialsException.class)
            .hasMessage("Invalid credentials");
    }

    @Test
    @DisplayName("Should throw BadCredentialsException when user not found")
    void authenticate_WithNonExistentUser_ShouldThrowException() {
        // Given
        when(userDetailsService.loadUserByUsername("test@example.com"))
            .thenThrow(new UsernameNotFoundException("User not found"));

        // When & Then
        assertThatThrownBy(() -> authenticationProvider.authenticate(testAuthentication))
            .isInstanceOf(BadCredentialsException.class)
            .hasMessage("Invalid credentials");
    }

    @Test
    @DisplayName("Should support UsernamePasswordAuthenticationToken")
    void supports_WithCorrectAuthenticationType_ShouldReturnTrue() {
        // When & Then
        assertThat(authenticationProvider.supports(UsernamePasswordAuthenticationToken.class))
            .isTrue();
    }
}

Testing User Details Service with MockRestServiceServer

@ExtendWith(SpringExtension.class)
class CustomUserDetailServiceTest {

    @Mock
    private RestTemplate restTemplate;
    
    @InjectMocks
    private CustomUserDetailService userDetailsService;
    
    private MockRestServiceServer mockServer;
    private ObjectMapper objectMapper;

    @BeforeEach
    void setUp() {
        mockServer = MockRestServiceServer.createServer(restTemplate);
        objectMapper = new ObjectMapper();
        
        ReflectionTestUtils.setField(userDetailsService, "memberServiceUrl", 
            "http://member-service");
    }

    @Test
    @DisplayName("Should load user details successfully")
    void loadUserByUsername_WithValidEmail_ShouldReturnUserDetails() throws Exception {
        // Given
        String email = "test@example.com";
        MemberDTO memberDTO = MemberDTO.builder()
            .email(email)
            .password("encoded-password")
            .googleId("google-123")
            .role("USER")
            .build();

        mockServer.expect(requestTo("http://member-service/api/members/email/" + email))
            .andExpect(method(HttpMethod.GET))
            .andRespond(withSuccess(objectMapper.writeValueAsString(memberDTO), 
                MediaType.APPLICATION_JSON));

        // When
        UserDetails result = userDetailsService.loadUserByUsername(email);

        // Then
        assertThat(result).isNotNull();
        assertThat(result.getUsername()).isEqualTo(email);
        assertThat(result.getPassword()).isEqualTo("encoded-password");
        assertThat(((CustomUserDetails) result).getGoogleId()).isEqualTo("google-123");
        
        mockServer.verify();
    }

    @Test
    @DisplayName("Should throw UsernameNotFoundException when user not found")
    void loadUserByUsername_WithNonExistentEmail_ShouldThrowException() {
        // Given
        String email = "nonexistent@example.com";
        
        mockServer.expect(requestTo("http://member-service/api/members/email/" + email))
            .andExpect(method(HttpMethod.GET))
            .andRespond(withStatus(HttpStatus.NOT_FOUND));

        // When & Then
        assertThatThrownBy(() -> userDetailsService.loadUserByUsername(email))
            .isInstanceOf(UsernameNotFoundException.class)
            .hasMessageContaining("User not found");
            
        mockServer.verify();
    }

    @Test
    @DisplayName("Should throw AuthenticationServiceException on service error")
    void loadUserByUsername_WithServiceError_ShouldThrowException() {
        // Given
        String email = "test@example.com";
        
        mockServer.expect(requestTo("http://member-service/api/members/email/" + email))
            .andExpected(method(HttpMethod.GET))
            .andRespond(withServerError());

        // When & Then
        assertThatThrownBy(() -> userDetailsService.loadUserByUsername(email))
            .isInstanceOf(AuthenticationServiceException.class)
            .hasMessageContaining("User service unavailable");
            
        mockServer.verify();
    }
}

๐Ÿ”’ Integration Testing with Spring Security

Testing Authentication Endpoints

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
    "member.service.url=http://localhost:${wiremock.server.port}",
    "jwt.secret=test-secret-key-for-testing-purposes"
})
class AuthControllerIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(wireMockConfig().port(0))
        .build();

    @BeforeEach
    void setUp() {
        System.setProperty("wiremock.server.port", String.valueOf(wireMock.getPort()));
    }

    @Test
    @DisplayName("Should authenticate successfully with valid credentials")
    void issueToken_WithValidCredentials_ShouldReturnTokens() throws Exception {
        // Given
        AuthRequest request = AuthRequest.builder()
            .email("test@example.com")
            .googleIdToken("valid-google-token")
            .googleId("google-123")
            .build();

        MemberDTO memberDTO = MemberDTO.builder()
            .email("test@example.com")
            .password("encoded-password")
            .googleId("google-123")
            .role("USER")
            .build();

        // Mock member service response
        wireMock.stubFor(get(urlEqualTo("/api/members/email/test@example.com"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody(objectMapper.writeValueAsString(memberDTO))));

        // Mock Google token verification (if using external service)
        mockGoogleTokenVerification("valid-google-token", "test@example.com");

        // When
        ResponseEntity<AuthResponse> response = restTemplate.postForEntity(
            "/auth/token", request, AuthResponse.class);

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().getAccessToken()).isNotEmpty();
        assertThat(response.getBody().getRefreshToken()).isNotEmpty();
        assertThat(response.getBody().getTokenType()).isEqualTo("Bearer");
    }

    @Test
    @DisplayName("Should return 401 for invalid credentials")
    void issueToken_WithInvalidCredentials_ShouldReturn401() {
        // Given
        AuthRequest request = AuthRequest.builder()
            .email("invalid@example.com")
            .googleIdToken("invalid-token")
            .googleId("invalid-google-id")
            .build();

        wireMock.stubFor(get(urlEqualTo("/api/members/email/invalid@example.com"))
            .willReturn(aResponse().withStatus(404)));

        // When
        ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
            "/auth/token", request, ErrorResponse.class);

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().getError()).isEqualTo("Authentication Failed");
    }

    private void mockGoogleTokenVerification(String token, String email) {
        // Mock Google API response for token verification
        wireMock.stubFor(get(urlMatching("/oauth2/v3/tokeninfo\\?id_token=" + token))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{"
                    + "\"email\":\"" + email + "\","
                    + "\"email_verified\":true,"
                    + "\"aud\":\"your-google-client-id\""
                    + "}")));
    }
}

Testing JWT Filter and Security Chain

@SpringBootTest
@AutoConfigureMockMvc
@Import(TestSecurityConfig.class)
class JwtAuthFilterIntegrationTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private JwtUtil jwtUtil;
    
    @MockBean
    private CustomUserDetailService userDetailsService;

    @Test
    @DisplayName("Should allow access to public endpoints without token")
    void publicEndpoint_WithoutToken_ShouldAllow() throws Exception {
        mockMvc.perform(get("/auth/public/health"))
            .andExpect(status().isOk());
    }

    @Test
    @DisplayName("Should deny access to protected endpoints without token")
    void protectedEndpoint_WithoutToken_ShouldDeny() throws Exception {
        mockMvc.perform(get("/admin/users"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @DisplayName("Should allow access to protected endpoints with valid token")
    void protectedEndpoint_WithValidToken_ShouldAllow() throws Exception {
        // Given
        UserDetails userDetails = CustomUserDetails.builder()
            .username("test@example.com")
            .role("ADMIN")
            .enabled(true)
            .build();
            
        when(userDetailsService.loadUserByUsername("test@example.com"))
            .thenReturn(userDetails);
            
        String token = jwtUtil.generateAccessToken(userDetails);

        // When & Then
        mockMvc.perform(get("/admin/users")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }

    @Test
    @DisplayName("Should deny access with invalid token")
    void protectedEndpoint_WithInvalidToken_ShouldDeny() throws Exception {
        mockMvc.perform(get("/admin/users")
                .header("Authorization", "Bearer invalid-token"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @DisplayName("Should deny access to admin endpoints for non-admin users")
    @WithMockUser(username = "user@example.com", roles = "USER")
    void adminEndpoint_WithUserRole_ShouldDeny() throws Exception {
        mockMvc.perform(get("/admin/users"))
            .andExpect(status().isForbidden());
    }
}

๐ŸŽฏ Testing Best Practices

1. Security Context Testing

@Test
@WithMockUser(username = "admin@example.com", roles = "ADMIN")
@DisplayName("Should process admin request with security context")
void adminOperation_WithSecurityContext_ShouldSucceed() {
    // Given
    SecurityContext context = SecurityContextHolder.getContext();
    Authentication auth = context.getAuthentication();
    
    // Then
    assertThat(auth).isNotNull();
    assertThat(auth.getName()).isEqualTo("admin@example.com");
    assertThat(auth.getAuthorities())
        .extracting("authority")
        .contains("ROLE_ADMIN");
}

@Test
@WithUserDetails(value = "test@example.com", userDetailsServiceBeanName = "customUserDetailService")
@DisplayName("Should load real user details for testing")
void userOperation_WithRealUserDetails_ShouldWork() {
    // Test with actual UserDetails loaded from service
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    assertThat(auth.getPrincipal()).isInstanceOf(CustomUserDetails.class);
}

2. Custom Security Annotations

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
    String email() default "test@example.com";
    String googleId() default "google-123";
    String role() default "USER";
}

public class WithMockCustomUserSecurityContextFactory 
        implements WithSecurityContextFactory<WithMockCustomUser> {

    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
        CustomUserDetails userDetails = CustomUserDetails.builder()
            .username(annotation.email())
            .googleId(annotation.googleId())
            .role(annotation.role())
            .enabled(true)
            .build();

        Authentication auth = new UsernamePasswordAuthenticationToken(
            userDetails, null, userDetails.getAuthorities());

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(auth);
        return context;
    }
}

// Usage
@Test
@WithMockCustomUser(email = "admin@example.com", role = "ADMIN")
void testWithCustomUser() {
    // Test implementation
}

3. Performance Testing

@Test
@DisplayName("Authentication should complete within acceptable time")
void authentication_PerformanceTest() {
    // Given
    AuthRequest request = createValidAuthRequest();
    
    // When & Then
    assertTimeout(Duration.ofMillis(500), () -> {
        for (int i = 0; i < 100; i++) {
            authService.issueToken(request);
        }
    });
}

@Test
@DisplayName("JWT validation should be fast")
void jwtValidation_PerformanceTest() {
    // Given
    UserDetails userDetails = createTestUserDetails();
    String token = jwtUtil.generateAccessToken(userDetails);
    
    // When & Then
    assertTimeout(Duration.ofMillis(10), () -> {
        for (int i = 0; i < 1000; i++) {
            jwtUtil.validateToken(token, userDetails);
        }
    });
}

โš ๏ธ Common Testing Pitfalls

1. Forgetting to Reset Security Context

// โŒ Security context pollution between tests
@Test
void test1() {
    SecurityContextHolder.getContext().setAuthentication(mockAuth);
    // Test logic
} // Context not cleared

@Test
void test2() {
    // This test might be affected by previous test's context
}

// โœ… Proper cleanup
@AfterEach
void tearDown() {
    SecurityContextHolder.clearContext();
}

// Or use @DirtiesContext
@Test
@DirtiesContext
void isolatedTest() {
    // Test logic
}

2. Not Mocking External Dependencies

// โŒ Tests calling real external services
@Test
void authTest() {
    // Calls real Google API and member service
    AuthResponse response = authController.issueToken(request);
}

// โœ… Properly mocked dependencies
@Test
void authTest() {
    // Mock all external calls
    when(googleTokenVerifier.verify(any())).thenReturn(mockToken);
    when(memberServiceClient.getUser(any())).thenReturn(mockUser);
    
    AuthResponse response = authController.issueToken(request);
}

3. Incomplete Test Coverage

// โœ… Comprehensive test coverage
@Nested
@DisplayName("Authentication Provider Tests")
class AuthenticationProviderTests {
    
    @Nested
    @DisplayName("Success Scenarios")
    class SuccessScenarios {
        @Test void validCredentials() { }
        @Test void adminUser() { }
        @Test void regularUser() { }
    }
    
    @Nested
    @DisplayName("Failure Scenarios")  
    class FailureScenarios {
        @Test void invalidCredentials() { }
        @Test void userNotFound() { }
        @Test void serviceUnavailable() { }
        @Test void invalidGoogleId() { }
    }
    
    @Nested
    @DisplayName("Edge Cases")
    class EdgeCases {
        @Test void nullCredentials() { }
        @Test void emptyCredentials() { }
        @Test void malformedToken() { }
    }
}

๐Ÿ“Š Test Coverage and Metrics

Coverage Configuration

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.7</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <execution>
            <id>check</id>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>CLASS</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

๐ŸŽ‰ Security Journey Complete!

Part of the AdventureTube technical blog series supporting the JavaiOS YouTube channel.

Leave a Comment

Your email address will not be published. Required fields are marked *