diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadService.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadService.java index ed524875..35b5d70b 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadService.java @@ -133,6 +133,11 @@ private void validate(Document document, Long userId, ValidatedFile file, String if (!document.getOwner().getId().equals(userId)) { throw new DocGridException(ErrorCode.PERMISSION_DENIED); } + // 삭제된 문서는 다른 조회·수정 경로와 같이 존재하지 않는 것으로 다룬다. + // 상태 분기까지 내려가면 이 경우만 409가 되어 나머지 API의 404와 어긋난다. + if (document.getStatus() == DocumentStatus.DELETED) { + throw new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND); + } if (document.getSourceType() != DocumentSourceType.UPLOAD) { throw new DocGridException(ErrorCode.DOCUMENT_VERSION_NOT_ALLOWED); } diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java index dc541525..6fe4cc9f 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java @@ -18,6 +18,8 @@ import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.security.McpApiKeyAuthFilter; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; +import com.opensource.docgrid.global.exception.RestAccessDeniedHandler; +import com.opensource.docgrid.global.exception.RestAuthenticationEntryPoint; import lombok.RequiredArgsConstructor; @@ -31,6 +33,8 @@ public class SecurityConfig { private final TokenBlacklistService tokenBlacklistService; private final RoleAuthorityService roleAuthorityService; private final McpAccessTokenCommandService mcpAccessTokenCommandService; + private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; + private final RestAccessDeniedHandler restAccessDeniedHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -52,6 +56,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) + // 인증 실패와 권한 부족을 상태 코드로 구분하고, 본문 없는 기본 응답 대신 공통 ErrorResponse를 준다. + .exceptionHandling(handling -> handling + .authenticationEntryPoint(restAuthenticationEntryPoint) + .accessDeniedHandler(restAccessDeniedHandler) + ) /* * UsernamePasswordAuthenticationFilter는 위치 기준점(앵커)일 뿐이며, * 실제 목적은 두 필터(JwtAuthenticationFilter, McpApiKeyAuthFilter)가 최종 인증 판정(authorizeHttpRequests)보다 먼저 실행됨 diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/RestAccessDeniedHandler.java b/backend/src/main/java/com/opensource/docgrid/global/exception/RestAccessDeniedHandler.java new file mode 100644 index 00000000..8ffcdaab --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/RestAccessDeniedHandler.java @@ -0,0 +1,41 @@ +package com.opensource.docgrid.global.exception; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.opensource.docgrid.global.common.response.ErrorResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * 인증은 됐지만 권한이 없는 요청에 403과 공통 ErrorResponse 본문을 반환한다. + * + *

Service 계층은 이미 PERMISSION_DENIED로 본문 있는 403을 주는데, SecurityConfig의 + * 경로 단위 거부만 본문 없이 나가고 있었다. 같은 의미의 응답이 형식까지 같도록 맞춘다. + */ +@Component +@RequiredArgsConstructor +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + ErrorCode errorCode = ErrorCode.PERMISSION_DENIED; + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), ErrorResponse.of(errorCode, request)); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/RestAuthenticationEntryPoint.java b/backend/src/main/java/com/opensource/docgrid/global/exception/RestAuthenticationEntryPoint.java new file mode 100644 index 00000000..75e3ac59 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/RestAuthenticationEntryPoint.java @@ -0,0 +1,41 @@ +package com.opensource.docgrid.global.exception; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.opensource.docgrid.global.common.response.ErrorResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * 인증하지 않은 요청에 401과 공통 ErrorResponse 본문을 반환한다. + * + *

기본 동작은 본문 없는 403이라 권한 부족과 구분되지 않았다. Client가 만료·누락 Token을 + * 인지하고 다시 로그인할 수 있도록 상태 코드와 응답 형식을 다른 오류와 맞춘다. + */ +@Component +@RequiredArgsConstructor +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + ErrorCode errorCode = ErrorCode.UNAUTHORIZED; + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), ErrorResponse.of(errorCode, request)); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadServiceTest.java new file mode 100644 index 00000000..034ea57e --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadServiceTest.java @@ -0,0 +1,98 @@ +package com.opensource.docgrid.domain.document.service.command; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.enums.DocumentSourceType; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentType; +import com.opensource.docgrid.domain.document.enums.VisibilityType; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.document.repository.DocumentVersionRepository; +import com.opensource.docgrid.domain.document.service.ValidatedFile; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.service.query.EmbeddingModelQueryService; +import com.opensource.docgrid.domain.sync.service.command.SyncEventWriter; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** + * 새 버전 접수 전 검증 규칙을 확인한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("DocumentVersionUploadService 단위 테스트") +class DocumentVersionUploadServiceTest { + + private static final Long USER_ID = 7L; + private static final Long DOCUMENT_ID = 11L; + private static final String FILE_HASH = "hash"; + + @Mock private UserRepository userRepository; + @Mock private FileObjectResolutionService fileObjectResolutionService; + @Mock private DocumentRepository documentRepository; + @Mock private DocumentVersionRepository documentVersionRepository; + @Mock private EmbeddingJobRepository embeddingJobRepository; + @Mock private EmbeddingModelQueryService embeddingModelQueryService; + @Mock private SyncEventWriter syncEventWriter; + + @InjectMocks private DocumentVersionUploadService documentVersionUploadService; + + @Test + @DisplayName("예외 케이스: 삭제된 문서에 새 버전을 올리면 문서를 찾을 수 없다고 응답한다") + void prepare_throwsNotFound_whenDocumentDeleted() { + // 상태 분기까지 내려가면 이 경우만 409가 되어 나머지 조회·수정 API의 404와 어긋난다. + given(documentRepository.findById(DOCUMENT_ID)).willReturn(Optional.of(deletedDocument())); + + assertThatThrownBy(() -> documentVersionUploadService.prepare( + USER_ID, DOCUMENT_ID, validatedFile(), FILE_HASH + )) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_NOT_FOUND); + } + + @Test + @DisplayName("예외 케이스: 소유자가 아니면 삭제 여부보다 먼저 권한 없음으로 막는다") + void prepare_throwsPermissionDenied_whenNotOwner() { + // 삭제 검사가 앞서면 타인의 삭제된 문서 존재 여부가 드러나므로 순서를 고정한다. + given(documentRepository.findById(DOCUMENT_ID)).willReturn(Optional.of(deletedDocument())); + + assertThatThrownBy(() -> documentVersionUploadService.prepare( + USER_ID + 1, DOCUMENT_ID, validatedFile(), FILE_HASH + )) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + } + + private Document deletedDocument() { + User owner = User.builder().build(); + ReflectionTestUtils.setField(owner, "id", USER_ID); + + Document document = Document.builder() + .owner(owner) + .title("삭제된 문서") + .documentType(DocumentType.PDF) + .sourceType(DocumentSourceType.UPLOAD) + .status(DocumentStatus.DELETED) + .visibility(VisibilityType.PRIVATE) + .build(); + ReflectionTestUtils.setField(document, "id", DOCUMENT_ID); + return document; + } + + private ValidatedFile validatedFile() { + return new ValidatedFile("new.pdf", "pdf", "application/pdf", 100L, DocumentType.PDF); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java index e2842695..517e8b9e 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java @@ -55,6 +55,8 @@ import com.opensource.docgrid.domain.embedding.service.query.IndexingJobAdminQueryService; import com.opensource.docgrid.domain.worker.enums.AttemptStatus; import com.opensource.docgrid.global.config.SecurityConfig; +import com.opensource.docgrid.global.exception.RestAccessDeniedHandler; +import com.opensource.docgrid.global.exception.RestAuthenticationEntryPoint; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; @@ -66,7 +68,7 @@ * 실제 Service 실행 없이 Controller 경계에서 확인한다. */ @WebMvcTest(IndexingJobAdminController.class) -@Import(SecurityConfig.class) +@Import({SecurityConfig.class, RestAuthenticationEntryPoint.class, RestAccessDeniedHandler.class}) @DisplayName("IndexingJobAdminController 테스트") class IndexingJobAdminControllerTest { @@ -200,7 +202,7 @@ void claim_returnsForbidden_when_userIsNotAdmin() throws Exception { @DisplayName("미인증 사용자는 403으로 Job Claim이 거부된다") void claim_returnsForbidden_when_userIsNotAuthenticated() throws Exception { mockMvc.perform(post(CLAIM_URL).param("workerId", WORKER_ID.toString())) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test @@ -275,7 +277,7 @@ void renewLease_returnsForbidden_withoutAdminRole() throws Exception { mockMvc.perform(post(RENEW_LEASE_URL) .contentType("application/json") .content(VALID_RENEW_LEASE_BODY)) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test @@ -362,7 +364,7 @@ void startAttempt_returnsForbidden_when_userIsNotAuthenticated() throws Exceptio mockMvc.perform(post(ATTEMPT_URL) .contentType("application/json") .content(VALID_ATTEMPT_BODY)) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test @@ -445,7 +447,7 @@ void createChunks_returnsForbidden_withoutAdminRole() throws Exception { mockMvc.perform(post(CHUNKS_URL) .contentType("application/json") .content(VALID_ATTEMPT_BODY)) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test @@ -534,7 +536,7 @@ void createEmbeddings_returnsForbidden_withoutAdminRole() throws Exception { mockMvc.perform(post(EMBEDDINGS_URL) .contentType("application/json") .content(VALID_ATTEMPT_BODY)) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test @@ -611,7 +613,7 @@ void completeIndexing_returnsForbidden_withoutAdminRole() throws Exception { mockMvc.perform(post(COMPLETE_URL) .contentType("application/json") .content(VALID_ATTEMPT_BODY)) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test @@ -687,7 +689,7 @@ void failIndexing_returnsForbidden_withoutAdminRole() throws Exception { mockMvc.perform(post(FAIL_URL) .contentType("application/json") .content(VALID_FAILURE_BODY)) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test @@ -745,7 +747,7 @@ void retryIndexingJob_returnsForbidden_withoutAdminRole() throws Exception { .andExpect(status().isForbidden()); mockMvc.perform(post(RETRY_URL)) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } private static Stream manualRetryBusinessErrors() { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java index 92ca225f..27343d84 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java @@ -45,6 +45,8 @@ import com.opensource.docgrid.domain.worker.enums.IndexingEventType; import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.config.SecurityConfig; +import com.opensource.docgrid.global.exception.RestAccessDeniedHandler; +import com.opensource.docgrid.global.exception.RestAuthenticationEntryPoint; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; @@ -52,7 +54,7 @@ * 관리자 인덱싱 Job 조회 API의 Pagination, 민감 정보 비노출, Validation과 Security 계약을 검증한다. */ @WebMvcTest(IndexingJobAdminController.class) -@Import(SecurityConfig.class) +@Import({SecurityConfig.class, RestAuthenticationEntryPoint.class, RestAccessDeniedHandler.class}) @DisplayName("IndexingJobAdminController 조회 테스트") class IndexingJobAdminQueryControllerTest { @@ -181,7 +183,7 @@ void getJob_returnsNotFound_whenJobDoesNotExist() throws Exception { void getQueries_returnForbidden_withoutAdminRole(String description, String url) throws Exception { mockMvc.perform(get(url).with(user("user").roles("USER"))) .andExpect(status().isForbidden()); - mockMvc.perform(get(url)).andExpect(status().isForbidden()); + mockMvc.perform(get(url)).andExpect(status().isUnauthorized()); } private static Stream invalidQueryRequests() { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java index 1a75063e..caa112ba 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java @@ -36,12 +36,14 @@ import com.opensource.docgrid.domain.sync.service.command.SyncAdminCommandService; import com.opensource.docgrid.domain.sync.service.query.SyncAdminQueryService; import com.opensource.docgrid.global.config.SecurityConfig; +import com.opensource.docgrid.global.exception.RestAccessDeniedHandler; +import com.opensource.docgrid.global.exception.RestAuthenticationEntryPoint; /** * Sync 관리자 조회·수동 Reconciliation API의 응답, Validation과 ADMIN 권한 경계를 검증한다. */ @WebMvcTest(SyncAdminController.class) -@Import(SecurityConfig.class) +@Import({SecurityConfig.class, RestAuthenticationEntryPoint.class, RestAccessDeniedHandler.class}) @DisplayName("SyncAdminController 테스트") class SyncAdminControllerTest { @@ -81,7 +83,7 @@ void getSummary_returnsForbiddenWithoutAdminRole() throws Exception { mockMvc.perform(get("/admin/sync/summary").with(user("user").roles("USER"))) .andExpect(status().isForbidden()); mockMvc.perform(get("/admin/sync/summary")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java index 8fc95ecd..4f785cd9 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java @@ -35,6 +35,8 @@ import com.opensource.docgrid.domain.user.service.query.AdminUserQueryService; import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.config.SecurityConfig; +import com.opensource.docgrid.global.exception.RestAccessDeniedHandler; +import com.opensource.docgrid.global.exception.RestAuthenticationEntryPoint; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; @@ -42,7 +44,7 @@ * 관리자 사용자 목록 API의 필터·Pagination·민감 정보 비노출과 ADMIN Security 계약을 검증한다. */ @WebMvcTest(AdminUserController.class) -@Import(SecurityConfig.class) +@Import({SecurityConfig.class, RestAuthenticationEntryPoint.class, RestAccessDeniedHandler.class}) @DisplayName("AdminUserController 테스트") class AdminUserControllerTest { @@ -97,7 +99,7 @@ void getUsers_returnsFilteredUserPage() throws Exception { void getUsers_returnsForbidden_withoutAdminRole() throws Exception { mockMvc.perform(get(USERS_URL).with(user("user").roles("USER"))) .andExpect(status().isForbidden()); - mockMvc.perform(get(USERS_URL)).andExpect(status().isForbidden()); + mockMvc.perform(get(USERS_URL)).andExpect(status().isUnauthorized()); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java index 9981ad90..6f5437a6 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java @@ -28,9 +28,11 @@ import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.global.config.SecurityConfig; +import com.opensource.docgrid.global.exception.RestAccessDeniedHandler; +import com.opensource.docgrid.global.exception.RestAuthenticationEntryPoint; @WebMvcTest(WorkerAdminController.class) -@Import(SecurityConfig.class) +@Import({SecurityConfig.class, RestAuthenticationEntryPoint.class, RestAccessDeniedHandler.class}) @DisplayName("WorkerAdminController 테스트") class WorkerAdminControllerTest { @@ -96,6 +98,6 @@ void getWorkers_returnsForbidden_when_userIsNotAdmin() throws Exception { @DisplayName("인증되지 않은 사용자는 403으로 Worker 목록 조회가 거부된다") void getWorkers_returnsForbidden_when_userIsNotAuthenticated() throws Exception { mockMvc.perform(get(WORKERS_URL)) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } } diff --git a/backend/src/test/java/com/opensource/docgrid/global/exception/SecurityErrorResponseTest.java b/backend/src/test/java/com/opensource/docgrid/global/exception/SecurityErrorResponseTest.java new file mode 100644 index 00000000..24a6651d --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/global/exception/SecurityErrorResponseTest.java @@ -0,0 +1,67 @@ +package com.opensource.docgrid.global.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * Spring Security 경계에서 나가는 인증·권한 실패 응답의 상태 코드와 본문 형식을 검증한다. + * + *

기본 동작은 둘 다 본문 없는 403이라 Client가 재로그인 대상과 권한 부족을 구분할 수 없었다. + */ +@DisplayName("Security 예외 응답 단위 테스트") +class SecurityErrorResponseTest { + + // 실제 주입되는 Bean은 Spring Boot가 JavaTimeModule을 등록해 주므로 ErrorResponse.timestamp가 직렬화된다. + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + @DisplayName("인증하지 않은 요청은 401과 COMMON-007 본문을 받는다") + void entryPoint_writesUnauthorizedBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/documents"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new RestAuthenticationEntryPoint(objectMapper) + .commence(request, response, new BadCredentialsException("no token")); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(response.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE); + // 한글 메시지가 깨지지 않도록 인코딩까지 고정한다. + assertThat(response.getCharacterEncoding()).isEqualToIgnoringCase("UTF-8"); + + var body = objectMapper.readTree(response.getContentAsString()); + assertThat(body.get("code").asText()).isEqualTo(ErrorCode.UNAUTHORIZED.getCode()); + assertThat(body.get("message").asText()).isEqualTo(ErrorCode.UNAUTHORIZED.getMessage()); + assertThat(body.get("status").asInt()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(body.get("success").asBoolean()).isFalse(); + assertThat(body.get("method").asText()).isEqualTo("GET"); + assertThat(body.get("path").asText()).isEqualTo("/api/documents"); + } + + @Test + @DisplayName("권한이 없는 요청은 403과 ROLE-002 본문을 받는다") + void accessDeniedHandler_writesForbiddenBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/admin/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new RestAccessDeniedHandler(objectMapper) + .handle(request, response, new AccessDeniedException("denied")); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value()); + + var body = objectMapper.readTree(response.getContentAsString()); + // Service 계층이 던지는 PERMISSION_DENIED와 같은 코드라 Client가 한 경로로 처리할 수 있다. + assertThat(body.get("code").asText()).isEqualTo(ErrorCode.PERMISSION_DENIED.getCode()); + assertThat(body.get("status").asInt()).isEqualTo(HttpStatus.FORBIDDEN.value()); + } +} diff --git a/frontend/app/components/UploadModal.tsx b/frontend/app/components/UploadModal.tsx index 39145b3b..4e10e833 100644 --- a/frontend/app/components/UploadModal.tsx +++ b/frontend/app/components/UploadModal.tsx @@ -3,6 +3,7 @@ import { FormEvent, useState } from "react"; import { apiRequest, errorMessage } from "../lib/api"; import type { DocumentUploadResponse, DocumentVersionUploadResponse } from "../lib/api-types"; +import { formatBytes } from "./ui"; export function UploadModal({ documentId, onClose, onSuccess }: { documentId?: number; @@ -44,7 +45,7 @@ export function UploadModal({ documentId, onClose, onSuccess }: {

{versionMode ? "새 버전 업로드" : "문서 업로드"}

{versionMode ? `문서 #${documentId}에 새 파일 버전을 추가합니다.` : "파일 저장 후 비동기 인덱싱 Job을 생성합니다."}

{error ?
{error}
: null} - + {!versionMode ? <>