Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {
Expand All @@ -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)보다 먼저 실행됨
Expand Down
Original file line number Diff line number Diff line change
@@ -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 본문을 반환한다.
*
* <p>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));
}
}
Original file line number Diff line number Diff line change
@@ -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 본문을 반환한다.
*
* <p>기본 동작은 본문 없는 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));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -66,7 +68,7 @@
* 실제 Service 실행 없이 Controller 경계에서 확인한다.
*/
@WebMvcTest(IndexingJobAdminController.class)
@Import(SecurityConfig.class)
@Import({SecurityConfig.class, RestAuthenticationEntryPoint.class, RestAccessDeniedHandler.class})
@DisplayName("IndexingJobAdminController 테스트")
class IndexingJobAdminControllerTest {

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Arguments> manualRetryBusinessErrors() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,16 @@
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;

/**
* 관리자 인덱싱 Job 조회 API의 Pagination, 민감 정보 비노출, Validation과 Security 계약을 검증한다.
*/
@WebMvcTest(IndexingJobAdminController.class)
@Import(SecurityConfig.class)
@Import({SecurityConfig.class, RestAuthenticationEntryPoint.class, RestAccessDeniedHandler.class})
@DisplayName("IndexingJobAdminController 조회 테스트")
class IndexingJobAdminQueryControllerTest {

Expand Down Expand Up @@ -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<Arguments> invalidQueryRequests() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@
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;

/**
* 관리자 사용자 목록 API의 필터·Pagination·민감 정보 비노출과 ADMIN Security 계약을 검증한다.
*/
@WebMvcTest(AdminUserController.class)
@Import(SecurityConfig.class)
@Import({SecurityConfig.class, RestAuthenticationEntryPoint.class, RestAccessDeniedHandler.class})
@DisplayName("AdminUserController 테스트")
class AdminUserControllerTest {

Expand Down Expand Up @@ -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
Expand Down
Loading