package com.project.auth; import com.project.auth.application.support.exception.AuthErrorCode; import com.project.auth.application.support.exception.CommonErrorCode; import com.project.auth.config.web.TraceIdFilter; import com.project.auth.domain.user.exception.DomainException; import com.project.auth.infrastructure.support.exception.InfrastructureErrorCode; import com.project.auth.infrastructure.support.exception.InfrastructureException; import com.project.auth.presentation.support.exception.PresentationErrorCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.WebApplicationContext; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.blankOrNullString; import static org.hamcrest.Matchers.not; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @Import(ExceptionHandlingIntegrationTest.ExceptionHandlingTestConfiguration.class) @ExtendWith(OutputCaptureExtension.class) class ExceptionHandlingIntegrationTest { @Autowired private WebApplicationContext context; private MockMvc mockMvc; @BeforeEach void setUpMockMvc() { this.mockMvc = MockMvcBuilders.webAppContextSetup(context) .addFilters(new TraceIdFilter()) .apply(springSecurity()) .build(); } @Test void unauthenticated_request_returns_401_json_with_trace_metadata() throws Exception { mockMvc.perform(get("/test-support/protected")) .andExpect(status().isUnauthorized()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(header().exists("X-Trace-Id")) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(AuthErrorCode.AUTHENTICATION_REQUIRED.code())) .andExpect(jsonPath("$.message").value(AuthErrorCode.AUTHENTICATION_REQUIRED.message())) .andExpect(jsonPath("$.traceId", not(blankOrNullString()))) .andExpect(jsonPath("$.timestamp", not(blankOrNullString()))); } @Test void authenticated_user_without_required_role_returns_403_json_without_exposing_principal( CapturedOutput output ) throws Exception { mockMvc.perform(get("/test-support/admin").with(user("alice@example.com").roles("USER"))) .andExpect(status().isForbidden()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(header().exists("X-Trace-Id")) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(AuthErrorCode.ACCESS_DENIED.code())) .andExpect(jsonPath("$.message").value(AuthErrorCode.ACCESS_DENIED.message())) .andExpect(jsonPath("$.traceId", not(blankOrNullString()))) .andExpect(jsonPath("$.timestamp", not(blankOrNullString()))); assertThat(output).contains("traceId="); assertThat(output).doesNotContain("principal=alice@example.com"); } @Test void malformed_json_returns_400_json() throws Exception { mockMvc.perform(post("/test-support/body") .with(user("user@example.com").roles("USER")) .contentType(MediaType.APPLICATION_JSON) .content("{invalid")) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(header().exists("X-Trace-Id")) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(PresentationErrorCode.INVALID_REQUEST_BODY.code())) .andExpect(jsonPath("$.message").value(PresentationErrorCode.INVALID_REQUEST_BODY.message())) .andExpect(jsonPath("$.traceId", not(blankOrNullString()))) .andExpect(jsonPath("$.timestamp", not(blankOrNullString()))); } @Test void unsupported_method_returns_405_json() throws Exception { mockMvc.perform(post("/test-support/protected").with(user("user@example.com").roles("USER"))) .andExpect(status().isMethodNotAllowed()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(header().exists("X-Trace-Id")) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(PresentationErrorCode.METHOD_NOT_ALLOWED.code())) .andExpect(jsonPath("$.message").value(PresentationErrorCode.METHOD_NOT_ALLOWED.message())) .andExpect(jsonPath("$.traceId", not(blankOrNullString()))) .andExpect(jsonPath("$.timestamp", not(blankOrNullString()))); } @Test void client_supplied_trace_id_is_ignored_and_server_generated_value_is_returned() throws Exception { mockMvc.perform(post("/test-support/body") .with(user("user@example.com").roles("USER")) .header("X-Trace-Id", "client-provided-trace") .contentType(MediaType.APPLICATION_JSON) .content("{invalid")) .andExpect(header().exists("X-Trace-Id")) .andExpect(header().string("X-Trace-Id", not("client-provided-trace"))) .andExpect(jsonPath("$.traceId", not("client-provided-trace"))); } @Test void missing_required_header_returns_header_specific_error_code() throws Exception { mockMvc.perform(get("/test-support/header-required").with(user("user@example.com").roles("USER"))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(PresentationErrorCode.MISSING_HEADER.code())) .andExpect(jsonPath("$.message").value(PresentationErrorCode.MISSING_HEADER.message())); } @Test void infrastructure_exception_returns_common_999_and_logs_internal_code( CapturedOutput output ) throws Exception { mockMvc.perform(get("/test-support/infrastructure").with(user("ops@example.com").roles("USER"))) .andExpect(status().isInternalServerError()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(header().exists("X-Trace-Id")) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(CommonErrorCode.INTERNAL_SERVER_ERROR.code())) .andExpect(jsonPath("$.message").value(CommonErrorCode.INTERNAL_SERVER_ERROR.message())) .andExpect(jsonPath("$.traceId", not(blankOrNullString()))) .andExpect(jsonPath("$.timestamp", not(blankOrNullString()))); assertThat(output).contains("traceId="); assertThat(output).contains("Infrastructure failure. errorCode="); assertThat(output).contains(InfrastructureErrorCode.EXTERNAL_SERVICE_ERROR.code()); assertThat(output).contains("method=GET requestPath=/test-support/infrastructure"); } @Test void access_denied_for_anonymous_user_maps_to_401_authentication_required() throws Exception { mockMvc.perform(get("/test-support/access-denied").with(anonymous())) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(AuthErrorCode.AUTHENTICATION_REQUIRED.code())); } @Test void access_denied_for_authenticated_user_remains_403_forbidden() throws Exception { mockMvc.perform(get("/test-support/access-denied").with(user("user@example.com").roles("USER"))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(AuthErrorCode.ACCESS_DENIED.code())); } @Test void framework_thrown_4xx_status_preserves_status_and_maps_to_unhandled_client_error() throws Exception { mockMvc.perform(get("/test-support/conflict").with(user("user@example.com").roles("USER"))) .andExpect(status().isConflict()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(PresentationErrorCode.UNHANDLED_CLIENT_ERROR.code())); } @Test void framework_thrown_5xx_status_preserves_status_and_collapses_to_common_999() throws Exception { mockMvc.perform(get("/test-support/service-unavailable").with(user("user@example.com").roles("USER"))) .andExpect(status().isServiceUnavailable()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(CommonErrorCode.INTERNAL_SERVER_ERROR.code())); } @Test void uncaught_runtime_exception_hits_safety_net_and_returns_common_999( CapturedOutput output ) throws Exception { mockMvc.perform(get("/test-support/uncaught").with(user("user@example.com").roles("USER"))) .andExpect(status().isInternalServerError()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(CommonErrorCode.INTERNAL_SERVER_ERROR.code())); assertThat(output).contains("Uncaught exception reached @ExceptionHandler safety net"); } @Test void missing_required_query_parameter_returns_400_with_missing_parameter_code() throws Exception { mockMvc.perform(get("/test-support/required-param").with(user("user@example.com").roles("USER"))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(PresentationErrorCode.MISSING_PARAMETER.code())); } @Test void type_mismatch_in_query_parameter_returns_400_with_type_mismatch_code() throws Exception { mockMvc.perform(get("/test-support/typed-param") .with(user("user@example.com").roles("USER")) .param("id", "not-a-number")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(PresentationErrorCode.TYPE_MISMATCH.code())); } @Test void unsupported_content_type_returns_415() throws Exception { mockMvc.perform(post("/test-support/body") .with(user("user@example.com").roles("USER")) .contentType(MediaType.APPLICATION_XML) .content("")) .andExpect(status().isUnsupportedMediaType()) .andExpect(jsonPath("$.code").value(PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE.code())); } @Test void unacceptable_accept_header_returns_406() throws Exception { // 406 응답은 협상 가능한 미디어 타입이 없어 본문 직렬화가 불가하므로 status만 단언한다. // (핸들러 자체의 라인 커버는 status 결정 시점에 모두 실행된다.) mockMvc.perform(get("/test-support/produces-json") .with(user("user@example.com").roles("USER")) .accept(MediaType.APPLICATION_PDF)) .andExpect(status().isNotAcceptable()); } @Test void unknown_route_returns_404_with_resource_not_found_code() throws Exception { mockMvc.perform(get("/test-support/this-path-does-not-exist") .with(user("user@example.com").roles("USER"))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(PresentationErrorCode.RESOURCE_NOT_FOUND.code())); } @Test void method_argument_not_valid_returns_400_with_invalid_input_and_field_errors() throws Exception { // @Valid @RequestBody 검증 실패 → MethodArgumentNotValidException 트리거 mockMvc.perform(post("/test-support/validated-body") .with(user("user@example.com").roles("USER")) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(PresentationErrorCode.INVALID_INPUT.code())) .andExpect(jsonPath("$.errors['/name']").isArray()); } @Test void business_exception_returns_mapped_status_and_code() throws Exception { mockMvc.perform(get("/test-support/business") .with(user("user@example.com").roles("USER"))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND.code())); } @Test void leaked_domain_exception_falls_back_to_common_999_without_exposing_message() throws Exception { mockMvc.perform(get("/test-support/domain").with(user("user@example.com").roles("USER"))) .andExpect(status().isInternalServerError()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(header().exists("X-Trace-Id")) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.code").value(CommonErrorCode.INTERNAL_SERVER_ERROR.code())) .andExpect(jsonPath("$.message").value(CommonErrorCode.INTERNAL_SERVER_ERROR.message())) .andExpect(jsonPath("$.traceId", not(blankOrNullString()))) .andExpect(jsonPath("$.timestamp", not(blankOrNullString()))); } @TestConfiguration(proxyBeanMethods = false) static class ExceptionHandlingTestConfiguration { @Bean TestExceptionController testExceptionController() { return new TestExceptionController(); } } @RestController static class TestExceptionController { @GetMapping("/test-support/protected") String protectedEndpoint() { return "ok"; } @PostMapping("/test-support/body") String bodyEndpoint(@RequestBody Map payload) { return payload.toString(); } @GetMapping("/test-support/admin") String adminOnly() { throw new org.springframework.security.authorization.AuthorizationDeniedException("Access Denied"); } @GetMapping("/test-support/infrastructure") String infrastructureFailure() { throw new InfrastructureException( InfrastructureErrorCode.EXTERNAL_SERVICE_ERROR, "Simulated external outage for integration test." ); } @GetMapping("/test-support/domain") String domainFailure() { throw new TestDomainException("테스트용 사용자 노출 메시지"); } @GetMapping("/test-support/header-required") String headerRequired( @org.springframework.web.bind.annotation.RequestHeader("X-Test-Header") String headerValue ) { return headerValue; } @GetMapping("/test-support/conflict") String frameworkThrown4xx() { throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT); } @GetMapping("/test-support/service-unavailable") String frameworkThrown5xx() { throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE); } @GetMapping("/test-support/uncaught") String uncaughtRuntime() { throw new IllegalStateException("simulated unknown failure"); } @GetMapping("/test-support/access-denied") String accessDenied() { throw new org.springframework.security.access.AccessDeniedException("simulated denial"); } @GetMapping("/test-support/required-param") String requiredParam(@org.springframework.web.bind.annotation.RequestParam("name") String name) { return name; } @GetMapping("/test-support/typed-param") String typedParam(@org.springframework.web.bind.annotation.RequestParam("id") long id) { return Long.toString(id); } @GetMapping(value = "/test-support/produces-json", produces = MediaType.APPLICATION_JSON_VALUE) String producesJson() { return "{\"ok\":true}"; } @PostMapping("/test-support/validated-body") String validatedBody( @org.springframework.web.bind.annotation.RequestBody @jakarta.validation.Valid ValidatedRequest request ) { return request.name(); } @GetMapping("/test-support/business") String businessException() { throw new com.project.auth.application.auth.exception.KeycloakUserNotFoundException(); } } record ValidatedRequest(@jakarta.validation.constraints.NotBlank(message = "이름은 필수입니다") String name) { } static final class TestDomainException extends DomainException { private TestDomainException(String message) { super(message); } } }