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 *