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 기본 동작은 둘 다 본문 없는 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 }: {