diff --git a/backend/src/test/java/backend/fullstack/config/ConfigSimpleComponentsTest.java b/backend/src/test/java/backend/fullstack/config/ConfigSimpleComponentsTest.java new file mode 100644 index 0000000..6040471 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/config/ConfigSimpleComponentsTest.java @@ -0,0 +1,102 @@ +package backend.fullstack.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import backend.fullstack.user.role.Role; +import io.swagger.v3.oas.models.OpenAPI; + +class ConfigSimpleComponentsTest { + + @Test + void apiResponseConstructorsFactoriesAndSettersWork() { + ApiResponse empty = new ApiResponse<>(); + empty.setSuccess(true); + empty.setMessage("ok"); + empty.setData("payload"); + + assertTrue(empty.isSuccess()); + assertEquals("ok", empty.getMessage()); + assertEquals("payload", empty.getData()); + + ApiResponse constructed = new ApiResponse<>(false, "nope", "x"); + assertFalse(constructed.isSuccess()); + assertEquals("nope", constructed.getMessage()); + assertEquals("x", constructed.getData()); + + ApiResponse successDefault = ApiResponse.success("data"); + assertTrue(successDefault.isSuccess()); + assertEquals("Success", successDefault.getMessage()); + assertEquals("data", successDefault.getData()); + + ApiResponse successCustom = ApiResponse.success("created", 42); + assertTrue(successCustom.isSuccess()); + assertEquals("created", successCustom.getMessage()); + assertEquals(42, successCustom.getData()); + + ApiResponse error = ApiResponse.error("boom"); + assertFalse(error.isSuccess()); + assertEquals("boom", error.getMessage()); + } + + @Test + void jwtPropertiesGettersSettersAndCompatibilityAccessorsWork() { + JwtProperties properties = new JwtProperties(); + properties.setSecret("0123456789012345678901234567890123456789012345678901234567890123"); + properties.setExpirationMs(1234L); + properties.setCookieName("auth"); + properties.setCookieSecure(false); + + assertEquals(properties.getSecret(), properties.secret()); + assertEquals(properties.getExpirationMs(), properties.expirationMs()); + assertEquals(properties.getCookieName(), properties.cookieName()); + assertEquals(properties.isCookieSecure(), properties.cookieSecure()); + } + + @Test + void jwtPrincipalNormalizesAndDefensivelyCopiesLocationIds() { + JwtPrincipal withNull = new JwtPrincipal(1L, "u@example.com", Role.ADMIN, 10L, null); + assertTrue(withNull.locationIds().isEmpty()); + + List mutable = new ArrayList<>(List.of(1L, 2L)); + JwtPrincipal withList = new JwtPrincipal(2L, "v@example.com", Role.MANAGER, 11L, mutable); + mutable.add(3L); + + assertEquals(List.of(1L, 2L), withList.locationIds()); + assertThrows(UnsupportedOperationException.class, () -> withList.locationIds().add(9L)); + } + + @Test + void passwordConfigProvidesBcryptEncoder() { + PasswordConfig config = new PasswordConfig(); + PasswordEncoder encoder = config.passwordEncoder(); + + assertNotNull(encoder); + assertTrue(encoder instanceof BCryptPasswordEncoder); + assertTrue(encoder.matches("secret", encoder.encode("secret"))); + } + + @Test + void swaggerConfigBuildsOpenApiWithBearerScheme() { + SwaggerConfig config = new SwaggerConfig(); + OpenAPI openApi = config.openAPI(); + + assertEquals("IK-Control API", openApi.getInfo().getTitle()); + assertEquals("1.0.0", openApi.getInfo().getVersion()); + assertEquals("Bearer Auth", openApi.getSecurity().get(0).keySet().iterator().next()); + assertEquals("bearer", openApi.getComponents() + .getSecuritySchemes() + .get("Bearer Auth") + .getScheme()); + } +} diff --git a/backend/src/test/java/backend/fullstack/config/GlobalExceptionHandlerTest.java b/backend/src/test/java/backend/fullstack/config/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..0550e75 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/config/GlobalExceptionHandlerTest.java @@ -0,0 +1,118 @@ +package backend.fullstack.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import java.util.Map; +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import backend.fullstack.dto.ErrorResponse; +import backend.fullstack.exceptions.AccessDeniedException; +import backend.fullstack.exceptions.InvalidThresholdException; +import backend.fullstack.exceptions.LocationException; +import backend.fullstack.exceptions.OrganizationConflictException; +import backend.fullstack.exceptions.PasswordException; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.exceptions.RoleException; +import backend.fullstack.exceptions.UnitInactiveException; +import backend.fullstack.exceptions.UnitNotFoundException; +import backend.fullstack.exceptions.UserConflictException; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void appExceptionHandlerUsesExceptionMetadata() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/meta"); + + ResponseEntity response = handler.handleAppException(new PasswordException("bad pwd"), request); + + assertEquals(400, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("PASSWORD_ERROR", response.getBody().errorCode()); + assertEquals("bad pwd", response.getBody().message()); + } + + @Test + void appExceptionDerivedHandlersBuildExpectedResponses() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/test"); + + assertError(handler.handleResourceNotFound(new ResourceNotFoundException("missing"), request), 404, "RESOURCE_NOT_FOUND"); + assertError(handler.handleUnitNotFound(new UnitNotFoundException(7L), request), 404, "UNIT_NOT_FOUND"); + assertError(handler.handleInvalidThreshold(new InvalidThresholdException(), request), 400, "INVALID_THRESHOLD"); + assertError(handler.handleUnitInactive(new UnitInactiveException(8L), request), 409, "UNIT_INACTIVE"); + } + + @Test + void conflictAndBadRequestHandlersBuildExpectedResponses() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/test"); + + assertError(handler.handleOrganizationConflict(new OrganizationConflictException("org exists"), request), 409, "CONFLICT"); + assertError(handler.handleUserConflict(new UserConflictException("dup"), request), 409, "CONFLICT"); + assertError(handler.handleLocationException(new LocationException("loc err"), request), 409, "CONFLICT"); + assertError(handler.handleRoleException(new RoleException("role err"), request), 400, "ROLE_ERROR"); + assertError(handler.handlePasswordException(new PasswordException("pwd err"), request), 400, "PASSWORD_ERROR"); + assertError(handler.handleIllegalArgument(new IllegalArgumentException("bad arg"), request), 400, "BAD_REQUEST"); + } + + @Test + void accessDeniedHandlersBuildExpectedResponses() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/secure"); + + assertError(handler.handleCustomAccessDeniedException(new AccessDeniedException("denied"), request), 403, "ACCESS_DENIED"); + assertError(handler.handleSpringAccessDeniedException(new org.springframework.security.access.AccessDeniedException("denied"), request), 403, "ACCESS_DENIED"); + } + + @Test + void validationHandlerCollectsFirstFieldErrorPerField() throws Exception { + Object target = new ValidationTarget(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "validationTarget"); + List fieldErrors = List.of( + new FieldError("obj", "email", "must be valid"), + new FieldError("obj", "email", "duplicate should be ignored"), + new FieldError("obj", "name", "must not be blank") + ); + fieldErrors.forEach(bindingResult::addError); + + Method method = ValidationTarget.class.getDeclaredMethod("submit", ValidationTarget.class); + MethodParameter parameter = new MethodParameter(method, 0); + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(parameter, bindingResult); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/users"); + + ResponseEntity response = handler.handleValidationException(exception, request); + + assertEquals(400, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("VALIDATION_ERROR", response.getBody().errorCode()); + assertEquals("Validation failed", response.getBody().message()); + assertEquals(Map.of("email", "must be valid", "name", "must not be blank"), response.getBody().fieldErrors()); + } + + private static void assertError(ResponseEntity response, int status, String code) { + assertEquals(status, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(code, response.getBody().errorCode()); + } + + private static final class ValidationTarget { + @SuppressWarnings("unused") + void submit(ValidationTarget value) { + } + } +} diff --git a/backend/src/test/java/backend/fullstack/config/JwtAuthFilterTest.java b/backend/src/test/java/backend/fullstack/config/JwtAuthFilterTest.java new file mode 100644 index 0000000..4feb904 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/config/JwtAuthFilterTest.java @@ -0,0 +1,92 @@ +package backend.fullstack.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +import backend.fullstack.user.role.Role; +import io.jsonwebtoken.Claims; + +class JwtAuthFilterTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void authenticatesFromCookieTokenWhenValid() throws Exception { + JwtUtil jwtUtil = mock(JwtUtil.class); + Claims claims = mock(Claims.class); + when(jwtUtil.getJwtFromCookies(org.mockito.ArgumentMatchers.any())).thenReturn("cookie-jwt"); + when(jwtUtil.validateJwtToken("cookie-jwt")).thenReturn(true); + when(jwtUtil.getClaimsFromJwtToken("cookie-jwt")).thenReturn(claims); + when(claims.getSubject()).thenReturn("user@example.com"); + when(claims.get("role", String.class)).thenReturn("MANAGER"); + when(claims.get("userId")).thenReturn(10); + when(claims.get("organizationId")).thenReturn(20); + when(claims.get("locationIds")).thenReturn(List.of(1, 2)); + + JwtAuthFilter filter = new JwtAuthFilter(jwtUtil); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, new MockFilterChain()); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + JwtPrincipal principal = (JwtPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + assertEquals("user@example.com", principal.email()); + assertEquals(Role.MANAGER, principal.role()); + assertEquals(List.of(1L, 2L), principal.locationIds()); + } + + @Test + void authenticatesFromBearerHeaderWhenCookieMissing() throws Exception { + JwtUtil jwtUtil = mock(JwtUtil.class); + Claims claims = mock(Claims.class); + when(jwtUtil.getJwtFromCookies(org.mockito.ArgumentMatchers.any())).thenReturn(null); + when(jwtUtil.validateJwtToken("header-jwt")).thenReturn(true); + when(jwtUtil.getClaimsFromJwtToken("header-jwt")).thenReturn(claims); + when(claims.getSubject()).thenReturn("admin@example.com"); + when(claims.get("role", String.class)).thenReturn("ROLE_ADMIN"); + when(claims.get("userId")).thenReturn(1L); + when(claims.get("organizationId")).thenReturn(99L); + when(claims.get("locationIds")).thenReturn("not-a-list"); + + JwtAuthFilter filter = new JwtAuthFilter(jwtUtil); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer header-jwt"); + + filter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain()); + + JwtPrincipal principal = (JwtPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + assertEquals(Role.ADMIN, principal.role()); + assertEquals(List.of(), principal.locationIds()); + } + + @Test + void leavesSecurityContextEmptyForInvalidTokenOrErrors() throws Exception { + JwtUtil jwtUtil = mock(JwtUtil.class); + when(jwtUtil.getJwtFromCookies(org.mockito.ArgumentMatchers.any())).thenReturn("bad"); + when(jwtUtil.validateJwtToken("bad")).thenReturn(false); + + JwtAuthFilter filter = new JwtAuthFilter(jwtUtil); + filter.doFilterInternal(new MockHttpServletRequest(), new MockHttpServletResponse(), new MockFilterChain()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + + when(jwtUtil.getJwtFromCookies(org.mockito.ArgumentMatchers.any())).thenThrow(new RuntimeException("boom")); + filter.doFilterInternal(new MockHttpServletRequest(), new MockHttpServletResponse(), new MockFilterChain()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } +} diff --git a/backend/src/test/java/backend/fullstack/config/JwtUtilTest.java b/backend/src/test/java/backend/fullstack/config/JwtUtilTest.java new file mode 100644 index 0000000..24e6ac1 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/config/JwtUtilTest.java @@ -0,0 +1,77 @@ +package backend.fullstack.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseCookie; +import org.springframework.mock.web.MockHttpServletRequest; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.Cookie; + +class JwtUtilTest { + + private JwtUtil jwtUtil; + + @BeforeEach + void setUp() { + JwtProperties properties = new JwtProperties(); + properties.setSecret("0123456789012345678901234567890123456789012345678901234567890123"); + properties.setExpirationMs(60_000L); + properties.setCookieName("jwt"); + properties.setCookieSecure(false); + jwtUtil = new JwtUtil(properties); + } + + @Test + void generateValidateAndParseToken() { + String token = jwtUtil.generateToken("user@example.com", 10L, "MANAGER", 20L, List.of(1L, 2L)); + + assertTrue(jwtUtil.validateJwtToken(token)); + + Claims claims = jwtUtil.getClaimsFromJwtToken(token); + assertEquals("user@example.com", claims.getSubject()); + assertEquals("MANAGER", claims.get("role", String.class)); + assertEquals(10L, ((Number) claims.get("userId")).longValue()); + assertEquals(20L, ((Number) claims.get("organizationId")).longValue()); + assertNotNull(claims.get("locationIds")); + } + + @Test + void getJwtFromCookiesReturnsCookieValueOrNull() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("jwt", "token-123")); + assertEquals("token-123", jwtUtil.getJwtFromCookies(request)); + + MockHttpServletRequest requestWithoutCookie = new MockHttpServletRequest(); + assertEquals(null, jwtUtil.getJwtFromCookies(requestWithoutCookie)); + } + + @Test + void generateCookiesUseExpectedDefaults() { + ResponseCookie jwtCookie = jwtUtil.generateJwtCookie("u@example.com", 1L, "ADMIN", 2L, List.of(7L)); + assertEquals("jwt", jwtCookie.getName()); + assertTrue(jwtCookie.toString().contains("HttpOnly")); + assertTrue(jwtCookie.toString().contains("SameSite=Strict")); + + ResponseCookie fromTokenCookie = jwtUtil.generateJwtCookieFromToken("abc"); + assertEquals("jwt", fromTokenCookie.getName()); + assertEquals("abc", fromTokenCookie.getValue()); + + ResponseCookie cleanCookie = jwtUtil.getCleanJwtCookie(); + assertEquals("", cleanCookie.getValue()); + assertTrue(cleanCookie.toString().contains("Max-Age=0")); + } + + @Test + void validateJwtTokenReturnsFalseForInvalidInput() { + assertFalse(jwtUtil.validateJwtToken("not-a-jwt")); + assertFalse(jwtUtil.validateJwtToken("")); + } +} diff --git a/backend/src/test/java/backend/fullstack/config/SecurityConfigBeansTest.java b/backend/src/test/java/backend/fullstack/config/SecurityConfigBeansTest.java new file mode 100644 index 0000000..f11b478 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/config/SecurityConfigBeansTest.java @@ -0,0 +1,118 @@ +package backend.fullstack.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; + +class SecurityConfigBeansTest { + + @Test + void userDetailsServiceReturnsUserOrThrows() { + UserRepository userRepository = mock(UserRepository.class); + User user = mock(User.class); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(userRepository.findByEmail("missing@example.com")).thenReturn(Optional.empty()); + + SecurityConfig config = new SecurityConfig( + new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), + userRepository, + new BCryptPasswordEncoder() + ); + + UserDetailsService service = config.userDetailsService(); + assertEquals(user, service.loadUserByUsername("user@example.com")); + assertThrows(UsernameNotFoundException.class, () -> service.loadUserByUsername("missing@example.com")); + } + + @Test + void authenticationBeansAndCorsSourceAreConfigured() throws Exception { + UserRepository userRepository = mock(UserRepository.class); + SecurityConfig config = new SecurityConfig( + new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), + userRepository, + new BCryptPasswordEncoder() + ); + + DaoAuthenticationProvider provider = config.authenticationProvider(); + assertNotNull(provider); + + AuthenticationManager manager = mock(AuthenticationManager.class); + AuthenticationConfiguration authenticationConfiguration = mock(AuthenticationConfiguration.class); + when(authenticationConfiguration.getAuthenticationManager()).thenReturn(manager); + assertEquals(manager, config.authenticationManager(authenticationConfiguration)); + + CorsConfigurationSource source = config.corsConfigurationSource(); + CorsConfiguration cors = source.getCorsConfiguration(new MockHttpServletRequest()); + assertNotNull(cors); + assertNotNull(cors.getAllowedOrigins()); + assertNotNull(cors.getAllowedMethods()); + assertEquals(true, cors.getAllowCredentials()); + } + + @Test + void privateBootstrapAuthorizationDecisionReflectsUserCount() throws Exception { + UserRepository emptyRepo = mock(UserRepository.class); + when(emptyRepo.count()).thenReturn(0L); + SecurityConfig allowConfig = new SecurityConfig( + new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), + emptyRepo, + new BCryptPasswordEncoder() + ); + + Method method = SecurityConfig.class.getDeclaredMethod( + "canAccessBootstrapSetup", + Supplier.class, + RequestAuthorizationContext.class + ); + method.setAccessible(true); + + AuthorizationDecision allowDecision = (AuthorizationDecision) method.invoke( + allowConfig, + (Supplier) () -> null, + new RequestAuthorizationContext(new MockHttpServletRequest()) + ); + assertEquals(true, allowDecision.isGranted()); + + UserRepository nonEmptyRepo = mock(UserRepository.class); + when(nonEmptyRepo.count()).thenReturn(2L); + SecurityConfig denyConfig = new SecurityConfig( + new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), + nonEmptyRepo, + new BCryptPasswordEncoder() + ); + + AuthorizationDecision denyDecision = (AuthorizationDecision) method.invoke( + denyConfig, + (Supplier) () -> null, + new RequestAuthorizationContext(new MockHttpServletRequest()) + ); + assertEquals(false, denyDecision.isGranted()); + } +} diff --git a/backend/src/test/java/backend/fullstack/config/SecurityErrorHandlerTest.java b/backend/src/test/java/backend/fullstack/config/SecurityErrorHandlerTest.java new file mode 100644 index 0000000..3faa7dd --- /dev/null +++ b/backend/src/test/java/backend/fullstack/config/SecurityErrorHandlerTest.java @@ -0,0 +1,87 @@ +package backend.fullstack.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; + +class SecurityErrorHandlerTest { + + @Test + void commenceWritesUnauthorizedJsonResponse() throws Exception { + ObjectMapper mapper = JsonMapper.builder().findAndAddModules().build(); + SecurityErrorHandler handler = new SecurityErrorHandler(mapper); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/private"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.commence(request, response, new BadCredentialsException("bad credentials")); + + assertEquals(401, response.getStatus()); + assertEquals(MediaType.APPLICATION_JSON_VALUE, response.getContentType()); + + JsonNode body = mapper.readTree(response.getContentAsString()); + assertEquals("UNAUTHORIZED", body.get("errorCode").asText()); + assertEquals("bad credentials", body.get("message").asText()); + assertEquals("/api/private", body.get("path").asText()); + } + + @Test + void handleWritesForbiddenJsonResponseAndFallbackMessage() throws Exception { + ObjectMapper mapper = JsonMapper.builder().findAndAddModules().build(); + SecurityErrorHandler handler = new SecurityErrorHandler(mapper); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/admin"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.handle(request, response, new AccessDeniedException(null)); + + assertEquals(403, response.getStatus()); + assertEquals(MediaType.APPLICATION_JSON_VALUE, response.getContentType()); + + JsonNode body = mapper.readTree(response.getContentAsString()); + assertEquals("ACCESS_DENIED", body.get("errorCode").asText()); + assertTrue(body.get("message").asText().contains("Access denied")); + assertEquals("/api/admin", body.get("path").asText()); + } + + @Test + void commenceUsesFallbackMessageWhenAuthMessageIsNull() throws Exception { + ObjectMapper mapper = JsonMapper.builder().findAndAddModules().build(); + SecurityErrorHandler handler = new SecurityErrorHandler(mapper); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/private"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.commence(request, response, new AuthenticationException(null) { + private static final long serialVersionUID = 1L; + }); + + JsonNode body = mapper.readTree(response.getContentAsString()); + assertTrue(body.get("message").asText().contains("Authentication is required")); + } + + @Test + void handleUsesCustomMessageWhenProvided() throws Exception { + ObjectMapper mapper = JsonMapper.builder().findAndAddModules().build(); + SecurityErrorHandler handler = new SecurityErrorHandler(mapper); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/admin"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.handle(request, response, new AccessDeniedException("specific denied")); + + JsonNode body = mapper.readTree(response.getContentAsString()); + assertEquals("specific denied", body.get("message").asText()); + } +}