diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java index 54447b8..dc2e025 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java @@ -3,31 +3,41 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.opensource.docgrid.domain.auth.annotation.CurrentUser; import com.opensource.docgrid.domain.collection.dto.request.AddDocumentRequest; import com.opensource.docgrid.domain.collection.dto.request.CreateCollectionRequest; +import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse; import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentResponse; import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse; import com.opensource.docgrid.domain.collection.service.command.CollectionCommandService; import com.opensource.docgrid.domain.collection.service.query.CollectionQueryService; import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.common.response.ResponseUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +/** + * 컬렉션 생성·조회·삭제와 컬렉션 문서 구성 및 읽기 가능한 문서 목록 API를 제공한다. + */ @Tag(name = "Collection", description = "컬렉션 관련 API") +@Validated @RestController @RequestMapping("/collections") @RequiredArgsConstructor @@ -95,6 +105,20 @@ public ResponseEntity> getCollection( return ResponseUtils.ok(collectionQueryService.getCollection(userId, collectionId)); } + @Operation( + summary = "컬렉션 문서 목록 조회", + description = "컬렉션을 읽을 수 있는 사용자가 개별 문서 읽기 권한도 가진 항목만 추가 최신순으로 페이지 조회합니다. " + + "숨김 문서는 응답 데이터와 전체 개수에 포함하지 않습니다." + ) + @GetMapping("/{collectionId}/documents") + public ResponseEntity>> getCollectionDocuments( + @PathVariable Long collectionId, + @Parameter(hidden = true) @CurrentUser Long userId, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseUtils.ok(collectionQueryService.getCollectionDocuments(userId, collectionId, page, size)); + } + @Operation( summary = "컬렉션에 문서 추가", description = "컬렉션에 문서를 추가합니다. 컬렉션 쓰기 권한(WRITE 또는 ADMIN, 소유자 포함)이 있는 사용자만 가능합니다. 이미 추가된 문서면 409를 반환합니다." diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java index 36fa7ba..e8ff2fb 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java @@ -2,14 +2,25 @@ import org.springframework.stereotype.Component; +import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse; import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentResponse; import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse; import com.opensource.docgrid.domain.collection.entity.CollectionDocument; import com.opensource.docgrid.domain.collection.entity.DocumentCollection; +import com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter; +import lombok.RequiredArgsConstructor; + +/** + * 컬렉션과 컬렉션-문서 매핑 Entity를 공개 응답 DTO로 변환한다. + * 문서 목록 응답은 읽기 권한 검증이 끝난 매핑만 전달받는다. + */ @Component +@RequiredArgsConstructor public class CollectionConverter { + private final DocumentSummaryConverter documentSummaryConverter; + public CollectionResponse toResponse(DocumentCollection collection) { Long parentId = collection.getParentCollection() != null ? collection.getParentCollection().getId() @@ -37,4 +48,17 @@ public CollectionDocumentResponse toDocumentResponse(CollectionDocument cd) { cd.getAddedAt() ); } + + public CollectionDocumentListItemResponse toDocumentListItemResponse(CollectionDocument collectionDocument) { + Long addedById = collectionDocument.getAddedBy() != null + ? collectionDocument.getAddedBy().getId() + : null; + + return new CollectionDocumentListItemResponse( + collectionDocument.getCollection().getId(), + documentSummaryConverter.toResponse(collectionDocument.getDocument()), + addedById, + collectionDocument.getAddedAt() + ); + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionDocumentListItemResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionDocumentListItemResponse.java new file mode 100644 index 0000000..7866280 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionDocumentListItemResponse.java @@ -0,0 +1,20 @@ +package com.opensource.docgrid.domain.collection.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 컬렉션 문서 목록에서 문서 Metadata와 컬렉션 추가 이력을 함께 노출하는 응답이다. + * 개별 문서 읽기 권한을 통과한 문서만 이 응답으로 변환된다. + */ +@Schema(description = "컬렉션에 포함된 읽기 가능한 문서") +public record CollectionDocumentListItemResponse( + @Schema(description = "컬렉션 ID") Long collectionId, + @Schema(description = "문서 Metadata와 현재 버전 요약") DocumentSummaryResponse document, + @Schema(description = "컬렉션에 문서를 추가한 사용자 ID") Long addedBy, + @Schema(description = "컬렉션에 문서를 추가한 시각") LocalDateTime addedAt +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java index 174e800..f06ae58 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java @@ -3,7 +3,11 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.opensource.docgrid.domain.collection.entity.CollectionDocument; @@ -13,6 +17,34 @@ public interface CollectionDocumentRepository extends JpaRepository findAllByCollectionId(Long collectionId); + /** + * 권한 선필터를 통과한 컬렉션 문서를 현재 버전 Metadata와 함께 페이지 조회한다. + */ + @Query( + value = """ + SELECT cd + FROM CollectionDocument cd + JOIN FETCH cd.collection + JOIN FETCH cd.document d + JOIN FETCH d.owner + LEFT JOIN FETCH d.currentVersion + LEFT JOIN FETCH cd.addedBy + WHERE cd.collection.id = :collectionId + AND d.id IN :documentIds + """, + countQuery = """ + SELECT COUNT(cd) + FROM CollectionDocument cd + WHERE cd.collection.id = :collectionId + AND cd.document.id IN :documentIds + """ + ) + Page findReadableDocuments( + @Param("collectionId") Long collectionId, + @Param("documentIds") List documentIds, + Pageable pageable + ); + // 컬렉션-문서 연결 단건 조회 (문서 제거용) Optional findByCollectionIdAndDocumentId(Long collectionId, Long documentId); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java index f9d02f2..e789cb1 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java @@ -1,16 +1,27 @@ package com.opensource.docgrid.domain.collection.service.query; +import java.util.EnumSet; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.opensource.docgrid.domain.collection.converter.CollectionConverter; +import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse; import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse; +import com.opensource.docgrid.domain.collection.entity.CollectionDocument; import com.opensource.docgrid.domain.collection.entity.DocumentCollection; import com.opensource.docgrid.domain.collection.enums.CollectionStatus; +import com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository; import com.opensource.docgrid.domain.collection.repository.CollectionRepository; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; @@ -21,7 +32,17 @@ @RequiredArgsConstructor public class CollectionQueryService { + private static final List LISTABLE_DOCUMENT_STATUSES = EnumSet.complementOf( + EnumSet.of(DocumentStatus.DELETED) + ).stream().map(DocumentStatus::name).toList(); + private static final Sort COLLECTION_DOCUMENT_SORT = Sort.by( + Sort.Order.desc("addedAt"), + Sort.Order.desc("id") + ); + private final CollectionRepository collectionRepository; + private final CollectionDocumentRepository collectionDocumentRepository; + private final DocumentRepository documentRepository; private final CollectionConverter collectionConverter; private final PermissionQueryService permissionQueryService; @@ -43,4 +64,43 @@ public List getMyCollections(Long userId) { .map(collectionConverter::toResponse) .toList(); } + + /** + * 컬렉션을 볼 수 있고 각 문서도 읽을 수 있는 항목만 페이지 응답으로 반환한다. + */ + public PageResponse getCollectionDocuments( + Long userId, + Long collectionId, + int page, + int size) { + // 1. 컬렉션 자체의 존재·삭제·읽기 권한을 문서 Metadata 조회보다 먼저 검증한다. + DocumentCollection collection = collectionRepository.findById(collectionId) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + if (!permissionQueryService.canReadCollection(userId, collection)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + // 2. 읽기 가능한 문서 ID만 선별해 숨김 문서가 Content와 전체 개수 모두에 포함되지 않게 한다. + Pageable pageable = PageRequest.of(page, size, COLLECTION_DOCUMENT_SORT); + List readableDocumentIds = documentRepository.findReadableDocumentIdsInCollection( + userId, + collectionId, + LISTABLE_DOCUMENT_STATUSES + ); + if (readableDocumentIds.isEmpty()) { + return PageResponse.from(Page.empty(pageable), List.of()); + } + + // 3. Transaction 안에서 현재 버전과 추가 이력을 공개 DTO로 변환한다. + Page collectionDocuments = collectionDocumentRepository.findReadableDocuments( + collectionId, + readableDocumentIds, + pageable + ); + List content = collectionDocuments.getContent().stream() + .map(collectionConverter::toDocumentListItemResponse) + .toList(); + return PageResponse.from(collectionDocuments, content); + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/controller/PermissionController.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/controller/PermissionController.java index b899faa..ec9c774 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/controller/PermissionController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/controller/PermissionController.java @@ -1,5 +1,7 @@ package com.opensource.docgrid.domain.permission.controller; +import java.util.List; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -49,6 +51,30 @@ public ResponseEntity> getMyDocum return ResponseUtils.ok(permissionQueryService.checkDocumentPermission(userId, documentId)); } + @Operation( + summary = "문서 직접 권한 목록 조회", + description = "문서 ADMIN 권한 보유자가 해당 문서에 직접 부여된 USER/ROLE/DEPARTMENT 권한 전체를 조회합니다. " + + "계산되거나 컬렉션에서 상속된 권한은 포함하지 않으며 만료된 직접 권한은 포함합니다." + ) + @GetMapping("/documents/{documentId}") + public ResponseEntity>> getDocumentPermissions( + @PathVariable Long documentId, + @Parameter(hidden = true) @CurrentUser Long userId) { + return ResponseUtils.ok(permissionQueryService.getDocumentPermissions(userId, documentId)); + } + + @Operation( + summary = "컬렉션 직접 권한 목록 조회", + description = "컬렉션 ADMIN 권한 보유자가 해당 컬렉션에 직접 부여된 USER/ROLE/DEPARTMENT 권한 전체를 조회합니다. " + + "계산된 권한은 포함하지 않으며 만료된 직접 권한은 포함합니다." + ) + @GetMapping("/collections/{collectionId}") + public ResponseEntity>> getCollectionPermissions( + @PathVariable Long collectionId, + @Parameter(hidden = true) @CurrentUser Long userId) { + return ResponseUtils.ok(permissionQueryService.getCollectionPermissions(userId, collectionId)); + } + @Operation( summary = "컬렉션 권한 부여", description = "컬렉션에 USER/ROLE/DEPARTMENT 단위로 권한을 부여합니다. 컬렉션 ADMIN 권한 보유자(소유자 포함)만 가능합니다. " + diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/converter/PermissionConverter.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/converter/PermissionConverter.java index 0aff1ad..5cb43e0 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/converter/PermissionConverter.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/converter/PermissionConverter.java @@ -7,6 +7,9 @@ import com.opensource.docgrid.domain.permission.entity.CollectionPermission; import com.opensource.docgrid.domain.permission.entity.DocumentPermission; +/** + * 문서·컬렉션 직접 권한 Entity를 권한 관리 API의 공개 응답으로 변환한다. + */ @Component public class PermissionConverter { diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java index 700326d..656ecd3 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java @@ -13,6 +13,22 @@ public interface CollectionPermissionRepository extends JpaRepository findAllByCollectionId(Long collectionId); + /** + * 컬렉션에 직접 부여된 권한을 대상·부여자 정보와 함께 최신순으로 조회한다. + */ + @Query(""" + SELECT cp + FROM CollectionPermission cp + JOIN FETCH cp.collection + LEFT JOIN FETCH cp.user + LEFT JOIN FETCH cp.role + LEFT JOIN FETCH cp.department + LEFT JOIN FETCH cp.grantedBy + WHERE cp.collection.id = :collectionId + ORDER BY cp.grantedAt DESC, cp.id DESC + """) + List findAllWithTargetsByCollectionId(@Param("collectionId") Long collectionId); + // ROLE live — 사용자 역할 기반 컬렉션→문서 읽기 권한 존재 여부 @Query(""" SELECT COUNT(cp) > 0 FROM CollectionPermission cp diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/DocumentPermissionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/DocumentPermissionRepository.java index 99716d6..d265e3b 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/DocumentPermissionRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/DocumentPermissionRepository.java @@ -1,5 +1,7 @@ package com.opensource.docgrid.domain.permission.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -8,6 +10,22 @@ public interface DocumentPermissionRepository extends JpaRepository { + /** + * 문서에 직접 부여된 권한을 대상·부여자 정보와 함께 최신순으로 조회한다. + */ + @Query(""" + SELECT dp + FROM DocumentPermission dp + JOIN FETCH dp.document + LEFT JOIN FETCH dp.user + LEFT JOIN FETCH dp.role + LEFT JOIN FETCH dp.department + LEFT JOIN FETCH dp.grantedBy + WHERE dp.document.id = :documentId + ORDER BY dp.grantedAt DESC, dp.id DESC + """) + List findAllWithTargetsByDocumentId(@Param("documentId") Long documentId); + // ROLE live — 사용자 역할 기반 문서 읽기 권한 존재 여부 @Query(""" SELECT COUNT(dp) > 0 FROM DocumentPermission dp diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java index cde64af..99abedf 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java @@ -12,6 +12,9 @@ import com.opensource.docgrid.domain.document.entity.Document; import com.opensource.docgrid.domain.document.enums.VisibilityType; import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.permission.converter.PermissionConverter; +import com.opensource.docgrid.domain.permission.dto.response.CollectionPermissionResponse; +import com.opensource.docgrid.domain.permission.dto.response.DocumentPermissionResponse; import com.opensource.docgrid.domain.permission.dto.response.DocumentPermissionSummaryResponse; import com.opensource.docgrid.domain.permission.enums.PermissionSourceType; import com.opensource.docgrid.domain.permission.repository.CollectionPermissionRepository; @@ -34,6 +37,37 @@ public class PermissionQueryService { private final UserDocumentAccessCacheRepository cacheRepository; private final DocumentPermissionRepository documentPermissionRepository; private final CollectionPermissionRepository collectionPermissionRepository; + private final PermissionConverter permissionConverter; + + /** + * 문서 ADMIN 권한 보유자에게 문서에 직접 부여된 전체 권한을 반환한다. + */ + public List getDocumentPermissions(Long userId, Long documentId) { + // 1. 목록 조회 전에 소유권·직접·상속 ADMIN 권한을 공통 규칙으로 검증한다. + if (!canAdminDocument(userId, documentId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + // 2. 만료된 기록도 회수·감사 화면에서 관리할 수 있도록 직접 부여 기록 전체를 반환한다. + return documentPermissionRepository.findAllWithTargetsByDocumentId(documentId).stream() + .map(permissionConverter::toDocumentPermissionResponse) + .toList(); + } + + /** + * 컬렉션 ADMIN 권한 보유자에게 컬렉션에 직접 부여된 전체 권한을 반환한다. + */ + public List getCollectionPermissions(Long userId, Long collectionId) { + // 1. 삭제 컬렉션 차단과 ADMIN 권한 판단을 기존 컬렉션 권한 규칙에 위임한다. + if (!canAdminCollection(userId, collectionId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + // 2. 계산·상속 권한은 포함하지 않고 회수 API가 참조할 직접 권한 ID만 반환한다. + return collectionPermissionRepository.findAllWithTargetsByCollectionId(collectionId).stream() + .map(permissionConverter::toCollectionPermissionResponse) + .toList(); + } // 문서 읽기 권한 판단 (5단계) public boolean canReadDocument(Long userId, Long documentId) { diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java index 5ed9d58..b8a88bd 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java @@ -1,32 +1,62 @@ package com.opensource.docgrid.domain.user.controller; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.opensource.docgrid.domain.auth.annotation.CurrentUser; import com.opensource.docgrid.domain.user.dto.request.AssignRoleRequest; +import com.opensource.docgrid.domain.user.dto.response.AdminUserResponse; import com.opensource.docgrid.domain.user.dto.response.UserRoleResponse; +import com.opensource.docgrid.domain.user.enums.UserStatus; import com.opensource.docgrid.domain.user.service.command.UserRoleCommandService; +import com.opensource.docgrid.domain.user.service.query.AdminUserQueryService; import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.common.response.ResponseUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +/** + * ADMIN 전용 사용자 목록 조회와 사용자 역할 부여 API를 제공한다. + */ @Tag(name = "Admin - User", description = "관리자 전용 사용자 관리 API") +@Validated @RestController @RequestMapping("/admin/users") @RequiredArgsConstructor public class AdminUserController { private final UserRoleCommandService userRoleCommandService; + private final AdminUserQueryService adminUserQueryService; + + @Operation( + summary = "전체 사용자 목록 조회", + description = "이름·이메일 검색과 부서·상태 필터를 적용해 사용자와 역할 코드를 최근 가입순으로 페이지 조회합니다. " + + "status 미입력 시 DELETED 사용자는 제외하며, 비밀번호와 인증 내부 정보는 반환하지 않습니다." + ) + @GetMapping + public ResponseEntity>> getUsers( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @Positive Long departmentId, + @RequestParam(required = false) UserStatus status, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseUtils.ok(adminUserQueryService.getUsers(keyword, departmentId, status, page, size)); + } @Operation(summary = "역할 부여", description = "특정 사용자에게 역할을 부여합니다. ADMIN 권한이 필요합니다. 이미 부여된 역할이면 409를 반환합니다.") @PostMapping("/{userId}/roles") diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/converter/AdminUserConverter.java b/backend/src/main/java/com/opensource/docgrid/domain/user/converter/AdminUserConverter.java new file mode 100644 index 0000000..7ed798b --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/converter/AdminUserConverter.java @@ -0,0 +1,32 @@ +package com.opensource.docgrid.domain.user.converter; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.user.dto.response.AdminUserResponse; +import com.opensource.docgrid.domain.user.entity.Department; +import com.opensource.docgrid.domain.user.entity.User; + +/** + * 사용자와 사전 조회된 역할 코드를 민감 정보가 없는 관리자 목록 응답으로 변환한다. + */ +@Component +public class AdminUserConverter { + + public AdminUserResponse toResponse(User user, List roles) { + Department department = user.getDepartment(); + return new AdminUserResponse( + user.getId(), + user.getName(), + user.getEmail(), + user.getNickname(), + department != null ? department.getId() : null, + department != null ? department.getName() : null, + user.getStatus(), + List.copyOf(roles), + user.getLastLoginAt(), + user.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/AdminUserResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/AdminUserResponse.java new file mode 100644 index 0000000..ac59fad --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/AdminUserResponse.java @@ -0,0 +1,27 @@ +package com.opensource.docgrid.domain.user.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.opensource.docgrid.domain.user.enums.UserStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 관리자 사용자 목록에 필요한 계정·부서·역할 Metadata만 노출하는 응답이다. + * 비밀번호 해시와 인증 관련 내부 정보는 이 계약의 범위에 포함하지 않는다. + */ +@Schema(description = "관리자 사용자 목록 항목") +public record AdminUserResponse( + @Schema(description = "사용자 ID") Long userId, + @Schema(description = "이름") String name, + @Schema(description = "이메일") String email, + @Schema(description = "닉네임") String nickname, + @Schema(description = "부서 ID") Long departmentId, + @Schema(description = "부서명") String departmentName, + @Schema(description = "계정 상태") UserStatus status, + @Schema(description = "부여된 역할 코드 목록") List roles, + @Schema(description = "마지막 로그인 시각") LocalDateTime lastLoginAt, + @Schema(description = "가입 시각") LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRepository.java index 142d986..c26f7c7 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRepository.java @@ -2,13 +2,60 @@ import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.enums.UserStatus; public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByEmail(String email); + + /** + * 관리자 사용자 화면의 검색·부서·상태 필터를 적용하고 부서를 함께 페이지 조회한다. + */ + @Query( + value = """ + SELECT u + FROM User u + LEFT JOIN FETCH u.department + WHERE ( + (:status IS NULL AND u.status <> :deletedStatus) + OR (:status IS NOT NULL AND u.status = :status) + ) + AND ( + :keyword IS NULL + OR LOWER(u.name) LIKE CONCAT(CONCAT('%', LOWER(:keyword)), '%') + OR LOWER(u.email) LIKE CONCAT(CONCAT('%', LOWER(:keyword)), '%') + ) + AND (:departmentId IS NULL OR u.department.id = :departmentId) + """, + countQuery = """ + SELECT COUNT(u) + FROM User u + WHERE ( + (:status IS NULL AND u.status <> :deletedStatus) + OR (:status IS NOT NULL AND u.status = :status) + ) + AND ( + :keyword IS NULL + OR LOWER(u.name) LIKE CONCAT(CONCAT('%', LOWER(:keyword)), '%') + OR LOWER(u.email) LIKE CONCAT(CONCAT('%', LOWER(:keyword)), '%') + ) + AND (:departmentId IS NULL OR u.department.id = :departmentId) + """ + ) + Page findAdminUsers( + @Param("keyword") String keyword, + @Param("departmentId") Long departmentId, + @Param("status") UserStatus status, + @Param("deletedStatus") UserStatus deletedStatus, + Pageable pageable + ); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java index 98750fc..de9a1f1 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java @@ -13,6 +13,19 @@ public interface UserRoleRepository extends JpaRepository { @Query("SELECT ur FROM UserRole ur JOIN FETCH ur.role WHERE ur.user.id = :userId") List findAllWithRoleByUserId(@Param("userId") Long userId); + /** + * 사용자 페이지에 포함된 역할을 한 번에 조회해 사용자별 역할 N+1을 방지한다. + */ + @Query(""" + SELECT ur + FROM UserRole ur + JOIN FETCH ur.user + JOIN FETCH ur.role role + WHERE ur.user.id IN :userIds + ORDER BY role.code ASC + """) + List findAllWithRoleByUserIdIn(@Param("userIds") List userIds); + @Query("SELECT ur.role.code FROM UserRole ur WHERE ur.user.id = :userId") List findRoleCodesByUserId(@Param("userId") Long userId); diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/AdminUserQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/AdminUserQueryService.java new file mode 100644 index 0000000..728cdee --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/AdminUserQueryService.java @@ -0,0 +1,76 @@ +package com.opensource.docgrid.domain.user.service.query; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.user.converter.AdminUserConverter; +import com.opensource.docgrid.domain.user.dto.response.AdminUserResponse; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; +import com.opensource.docgrid.global.common.response.PageResponse; + +import lombok.RequiredArgsConstructor; + +/** + * 관리자 사용자 목록의 검색·필터·페이지 조회와 사용자별 역할 조합을 담당한다. + * HTTP ADMIN 인가는 SecurityConfig가 담당하며 이 서비스는 조회 계약만 처리한다. + */ +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class AdminUserQueryService { + + private static final Sort USER_SORT = Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ); + + private final UserRepository userRepository; + private final UserRoleRepository userRoleRepository; + private final AdminUserConverter adminUserConverter; + + public PageResponse getUsers( + String keyword, + Long departmentId, + UserStatus status, + int page, + int size) { + // 1. 공백 검색어는 필터 없음으로 정규화하고 DELETED 기본 제외 조건으로 페이지를 조회한다. + String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); + Page users = userRepository.findAdminUsers( + normalizedKeyword, + departmentId, + status, + UserStatus.DELETED, + PageRequest.of(page, size, USER_SORT) + ); + + // 2. 현재 페이지 사용자의 역할만 한 번에 조회해 사용자별 역할 조회를 반복하지 않는다. + List userIds = users.getContent().stream().map(User::getId).toList(); + Map> roleCodesByUserId = userIds.isEmpty() + ? Map.of() + : userRoleRepository.findAllWithRoleByUserIdIn(userIds).stream() + .collect(Collectors.groupingBy( + userRole -> userRole.getUser().getId(), + Collectors.mapping(userRole -> userRole.getRole().getCode(), Collectors.toList()) + )); + + // 3. 비밀번호 해시를 포함하지 않는 공개 관리자 응답으로 Transaction 안에서 변환한다. + List content = users.getContent().stream() + .map(user -> adminUserConverter.toResponse( + user, + roleCodesByUserId.getOrDefault(user.getId(), List.of()) + )) + .toList(); + return PageResponse.from(users, content); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/controller/CollectionControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/controller/CollectionControllerTest.java new file mode 100644 index 0000000..b07138f --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/controller/CollectionControllerTest.java @@ -0,0 +1,101 @@ +package com.opensource.docgrid.domain.collection.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse; +import com.opensource.docgrid.domain.collection.service.command.CollectionCommandService; +import com.opensource.docgrid.domain.collection.service.query.CollectionQueryService; +import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentType; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.enums.VisibilityType; +import com.opensource.docgrid.global.common.response.PageResponse; + +/** + * 컬렉션 문서 목록 API의 인증 사용자 전달, Pagination과 공개 응답 구조를 검증한다. + */ +@WebMvcTest(CollectionController.class) +@DisplayName("CollectionController 테스트") +class CollectionControllerTest { + + private static final String DOCUMENTS_URL = "/collections/{collectionId}/documents"; + + @Autowired private MockMvc mockMvc; + + @MockitoBean private CollectionCommandService collectionCommandService; + @MockitoBean private CollectionQueryService collectionQueryService; + @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @Test + @DisplayName("인증된 사용자가 읽기 가능한 컬렉션 문서를 페이지 조회한다") + void getCollectionDocuments_returnsReadableDocumentPage() throws Exception { + DocumentSummaryResponse document = new DocumentSummaryResponse( + 5L, + "운영 가이드", + "배포 절차", + DocumentType.PDF, + DocumentStatus.INDEXED, + VisibilityType.PRIVATE, + 10L, + 2, + DocumentVersionStatus.INDEXED, + LocalDateTime.of(2026, 8, 1, 10, 0), + LocalDateTime.of(2026, 8, 2, 10, 0) + ); + CollectionDocumentListItemResponse item = new CollectionDocumentListItemResponse( + 1L, + document, + 10L, + LocalDateTime.of(2026, 8, 3, 10, 0) + ); + given(collectionQueryService.getCollectionDocuments(10L, 1L, 0, 20)) + .willReturn(new PageResponse<>(List.of(item), 0, 20, 1, 1, true, true)); + + mockMvc.perform(get(DOCUMENTS_URL, 1L) + .with(authentication(authenticationWithUserId(10L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].collectionId").value(1)) + .andExpect(jsonPath("$.data.content[0].document.documentId").value(5)) + .andExpect(jsonPath("$.data.content[0].document.title").value("운영 가이드")) + .andExpect(jsonPath("$.data.content[0].document.currentVersionNo").value(2)) + .andExpect(jsonPath("$.data.content[0].addedBy").value(10)) + .andExpect(jsonPath("$.data.totalElements").value(1)); + } + + @Test + @DisplayName("페이지 입력 범위를 벗어나면 400을 반환한다") + void getCollectionDocuments_returnsBadRequest_whenPageInputIsInvalid() throws Exception { + mockMvc.perform(get(DOCUMENTS_URL, 1L) + .param("size", "101") + .with(authentication(authenticationWithUserId(10L)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-002")); + } + + private UsernamePasswordAuthenticationToken authenticationWithUserId(Long userId) { + UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( + "user", + "password", + List.of() + ); + authentication.setDetails(userId); + return authentication; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java index c56f5cd..6c6a09c 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java @@ -4,26 +4,40 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import java.util.List; 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.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import com.opensource.docgrid.domain.collection.converter.CollectionConverter; +import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse; import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse; +import com.opensource.docgrid.domain.collection.entity.CollectionDocument; import com.opensource.docgrid.domain.collection.entity.DocumentCollection; import com.opensource.docgrid.domain.collection.enums.CollectionStatus; import com.opensource.docgrid.domain.collection.fixture.CollectionFixture; +import com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository; import com.opensource.docgrid.domain.collection.repository.CollectionRepository; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +/** + * 컬렉션 단건과 읽기 가능한 컬렉션 문서 페이지의 권한·Pagination 계약을 검증한다. + */ @ExtendWith(MockitoExtension.class) @DisplayName("CollectionQueryService 단위 테스트") class CollectionQueryServiceTest { @@ -34,6 +48,12 @@ class CollectionQueryServiceTest { @Mock private CollectionRepository collectionRepository; + @Mock + private CollectionDocumentRepository collectionDocumentRepository; + + @Mock + private DocumentRepository documentRepository; + @Mock private CollectionConverter collectionConverter; @@ -89,4 +109,98 @@ void getCollection_throws_when_noReadPermission() { .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); } + + @Test + @DisplayName("컬렉션 문서 목록은 읽기 가능한 문서만 최신 추가순으로 페이지 반환한다") + void getCollectionDocuments_returnsOnlyReadableDocuments() { + DocumentCollection collection = CollectionFixture.createCollection(); + CollectionDocument collectionDocument = org.mockito.Mockito.mock(CollectionDocument.class); + CollectionDocumentListItemResponse expected = org.mockito.Mockito.mock( + CollectionDocumentListItemResponse.class + ); + List readableDocumentIds = List.of(CollectionFixture.DOCUMENT_ID); + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, collection)).willReturn(true); + given(documentRepository.findReadableDocumentIdsInCollection( + org.mockito.ArgumentMatchers.eq(CollectionFixture.USER_ID), + org.mockito.ArgumentMatchers.eq(CollectionFixture.COLLECTION_ID), + org.mockito.ArgumentMatchers.anyList() + )).willReturn(readableDocumentIds); + given(collectionDocumentRepository.findReadableDocuments( + org.mockito.ArgumentMatchers.eq(CollectionFixture.COLLECTION_ID), + org.mockito.ArgumentMatchers.eq(readableDocumentIds), + org.mockito.ArgumentMatchers.any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(collectionDocument), PageRequest.of(0, 20), 1)); + given(collectionConverter.toDocumentListItemResponse(collectionDocument)).willReturn(expected); + + PageResponse result = collectionQueryService.getCollectionDocuments( + CollectionFixture.USER_ID, + CollectionFixture.COLLECTION_ID, + 0, + 20 + ); + + assertThat(result.content()).containsExactly(expected); + assertThat(result.totalElements()).isEqualTo(1); + then(documentRepository).should().findReadableDocumentIdsInCollection( + org.mockito.ArgumentMatchers.eq(CollectionFixture.USER_ID), + org.mockito.ArgumentMatchers.eq(CollectionFixture.COLLECTION_ID), + org.mockito.ArgumentMatchers.argThat(statuses -> !statuses.contains("DELETED")) + ); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + then(collectionDocumentRepository).should().findReadableDocuments( + org.mockito.ArgumentMatchers.eq(CollectionFixture.COLLECTION_ID), + org.mockito.ArgumentMatchers.eq(readableDocumentIds), + pageableCaptor.capture() + ); + assertThat(pageableCaptor.getValue().getSort().getOrderFor("addedAt").isDescending()).isTrue(); + assertThat(pageableCaptor.getValue().getSort().getOrderFor("id").isDescending()).isTrue(); + } + + @Test + @DisplayName("읽기 가능한 문서가 없으면 숨김 문서 수를 노출하지 않는 빈 페이지를 반환한다") + void getCollectionDocuments_returnsEmptyPage_whenNoDocumentIsReadable() { + DocumentCollection collection = CollectionFixture.createCollection(); + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, collection)).willReturn(true); + given(documentRepository.findReadableDocumentIdsInCollection( + org.mockito.ArgumentMatchers.eq(CollectionFixture.USER_ID), + org.mockito.ArgumentMatchers.eq(CollectionFixture.COLLECTION_ID), + org.mockito.ArgumentMatchers.anyList() + )).willReturn(List.of()); + + PageResponse result = collectionQueryService.getCollectionDocuments( + CollectionFixture.USER_ID, + CollectionFixture.COLLECTION_ID, + 0, + 20 + ); + + assertThat(result.content()).isEmpty(); + assertThat(result.totalElements()).isZero(); + then(collectionDocumentRepository).should(never()).findReadableDocuments( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyList(), + org.mockito.ArgumentMatchers.any(Pageable.class) + ); + } + + @Test + @DisplayName("컬렉션 읽기 권한이 없으면 문서 ID 조회 전에 거부한다") + void getCollectionDocuments_throwsBeforeDocumentQuery_whenCollectionReadIsDenied() { + DocumentCollection collection = CollectionFixture.createCollection(); + Long otherUserId = 99L; + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + given(permissionQueryService.canReadCollection(otherUserId, collection)).willReturn(false); + + assertThatThrownBy(() -> collectionQueryService.getCollectionDocuments( + otherUserId, + CollectionFixture.COLLECTION_ID, + 0, + 20 + )) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + then(documentRepository).shouldHaveNoInteractions(); + } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/controller/PermissionControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/controller/PermissionControllerTest.java new file mode 100644 index 0000000..a10eef6 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/controller/PermissionControllerTest.java @@ -0,0 +1,122 @@ +package com.opensource.docgrid.domain.permission.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.opensource.docgrid.domain.permission.dto.response.CollectionPermissionResponse; +import com.opensource.docgrid.domain.permission.dto.response.DocumentPermissionResponse; +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; +import com.opensource.docgrid.domain.permission.service.command.CollectionPermissionCommandService; +import com.opensource.docgrid.domain.permission.service.command.DocumentPermissionCommandService; +import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** + * 문서·컬렉션 직접 권한 목록 API의 URL 계약과 자원 ADMIN 거부 응답을 검증한다. + */ +@WebMvcTest(PermissionController.class) +@DisplayName("PermissionController 테스트") +class PermissionControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private CollectionPermissionCommandService collectionPermissionCommandService; + @MockitoBean private DocumentPermissionCommandService documentPermissionCommandService; + @MockitoBean private PermissionQueryService permissionQueryService; + @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @Test + @DisplayName("문서 ADMIN 사용자가 문서 직접 권한 목록을 조회한다") + void getDocumentPermissions_returnsDirectPermissions() throws Exception { + DocumentPermissionResponse response = new DocumentPermissionResponse( + 100L, + 5L, + PermissionTargetType.USER, + 20L, + null, + null, + PermissionType.READ, + true, + false, + false, + 10L, + LocalDateTime.of(2026, 8, 1, 10, 0), + null + ); + given(permissionQueryService.getDocumentPermissions(10L, 5L)).willReturn(List.of(response)); + + mockMvc.perform(get("/permissions/documents/5") + .with(authentication(authenticationWithUserId(10L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].permissionId").value(100)) + .andExpect(jsonPath("$.data[0].targetType").value("USER")) + .andExpect(jsonPath("$.data[0].userId").value(20)); + } + + @Test + @DisplayName("컬렉션 ADMIN 사용자가 컬렉션 직접 권한 목록을 조회한다") + void getCollectionPermissions_returnsDirectPermissions() throws Exception { + CollectionPermissionResponse response = new CollectionPermissionResponse( + 200L, + 1L, + PermissionTargetType.ROLE, + null, + 30L, + null, + PermissionType.ADMIN, + true, + true, + true, + 10L, + LocalDateTime.of(2026, 8, 1, 10, 0), + null + ); + given(permissionQueryService.getCollectionPermissions(10L, 1L)).willReturn(List.of(response)); + + mockMvc.perform(get("/permissions/collections/1") + .with(authentication(authenticationWithUserId(10L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].permissionId").value(200)) + .andExpect(jsonPath("$.data[0].roleId").value(30)) + .andExpect(jsonPath("$.data[0].canAdmin").value(true)); + } + + @Test + @DisplayName("자원 ADMIN 권한이 없으면 403을 반환한다") + void getDocumentPermissions_returnsForbidden_whenResourceAdminIsDenied() throws Exception { + given(permissionQueryService.getDocumentPermissions(10L, 5L)) + .willThrow(new DocGridException(ErrorCode.PERMISSION_DENIED)); + + mockMvc.perform(get("/permissions/documents/5") + .with(authentication(authenticationWithUserId(10L)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("ROLE-002")); + } + + private UsernamePasswordAuthenticationToken authenticationWithUserId(Long userId) { + UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( + "user", + "password", + List.of() + ); + authentication.setDetails(userId); + return authentication; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryServiceTest.java index 8f7f023..3fad9a8 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -21,7 +22,12 @@ import com.opensource.docgrid.domain.document.entity.Document; import com.opensource.docgrid.domain.document.enums.VisibilityType; import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.permission.converter.PermissionConverter; +import com.opensource.docgrid.domain.permission.dto.response.CollectionPermissionResponse; +import com.opensource.docgrid.domain.permission.dto.response.DocumentPermissionResponse; import com.opensource.docgrid.domain.permission.dto.response.DocumentPermissionSummaryResponse; +import com.opensource.docgrid.domain.permission.entity.CollectionPermission; +import com.opensource.docgrid.domain.permission.entity.DocumentPermission; import com.opensource.docgrid.domain.permission.enums.PermissionSourceType; import com.opensource.docgrid.domain.permission.repository.CollectionPermissionRepository; import com.opensource.docgrid.domain.permission.repository.DocumentPermissionRepository; @@ -30,6 +36,9 @@ import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +/** + * 문서·컬렉션 권한 계산과 ADMIN 전용 직접 권한 목록 조회 계약을 검증한다. + */ @ExtendWith(MockitoExtension.class) @DisplayName("PermissionQueryService 단위 테스트") class PermissionQueryServiceTest { @@ -42,6 +51,7 @@ class PermissionQueryServiceTest { @Mock private UserDocumentAccessCacheRepository cacheRepository; @Mock private DocumentPermissionRepository documentPermissionRepository; @Mock private CollectionPermissionRepository collectionPermissionRepository; + @Mock private PermissionConverter permissionConverter; // ==================== canReadDocument ==================== @@ -751,4 +761,57 @@ void canAdminCollection_noPermission_returnsFalse() { assertThat(result).isFalse(); } + + @Test + @DisplayName("문서 소유자는 직접 부여된 문서 권한 전체를 조회한다") + void getDocumentPermissions_returnsDirectPermissions_whenUserCanAdmin() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + DocumentPermission permission = org.mockito.Mockito.mock(DocumentPermission.class); + DocumentPermissionResponse response = org.mockito.Mockito.mock(DocumentPermissionResponse.class); + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + given(documentPermissionRepository.findAllWithTargetsByDocumentId(CollectionFixture.DOCUMENT_ID)) + .willReturn(List.of(permission)); + given(permissionConverter.toDocumentPermissionResponse(permission)).willReturn(response); + + List result = service.getDocumentPermissions( + CollectionFixture.USER_ID, + CollectionFixture.DOCUMENT_ID + ); + + assertThat(result).containsExactly(response); + } + + @Test + @DisplayName("컬렉션 소유자는 직접 부여된 컬렉션 권한 전체를 조회한다") + void getCollectionPermissions_returnsDirectPermissions_whenUserCanAdmin() { + DocumentCollection collection = CollectionFixture.createCollection(); + CollectionPermission permission = org.mockito.Mockito.mock(CollectionPermission.class); + CollectionPermissionResponse response = org.mockito.Mockito.mock(CollectionPermissionResponse.class); + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + given(collectionPermissionRepository.findAllWithTargetsByCollectionId(CollectionFixture.COLLECTION_ID)) + .willReturn(List.of(permission)); + given(permissionConverter.toCollectionPermissionResponse(permission)).willReturn(response); + + List result = service.getCollectionPermissions( + CollectionFixture.USER_ID, + CollectionFixture.COLLECTION_ID + ); + + assertThat(result).containsExactly(response); + } + + @Test + @DisplayName("문서 ADMIN 권한이 없으면 직접 권한 목록을 조회하지 않는다") + void getDocumentPermissions_throwsBeforeListQuery_whenAdminPermissionIsDenied() { + Document document = CollectionFixture.createDocument(CollectionFixture.createOwner()); + Long otherUserId = 99L; + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + + assertThatThrownBy(() -> service.getDocumentPermissions(otherUserId, CollectionFixture.DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + then(documentPermissionRepository).should(never()) + .findAllWithTargetsByDocumentId(CollectionFixture.DOCUMENT_ID); + } } 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 new file mode 100644 index 0000000..17e4760 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java @@ -0,0 +1,99 @@ +package com.opensource.docgrid.domain.user.controller; + +import static org.mockito.BDDMockito.given; +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; +import com.opensource.docgrid.domain.user.dto.response.AdminUserResponse; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.service.command.UserRoleCommandService; +import com.opensource.docgrid.domain.user.service.query.AdminUserQueryService; +import com.opensource.docgrid.global.common.response.PageResponse; +import com.opensource.docgrid.global.config.SecurityConfig; + +/** + * 관리자 사용자 목록 API의 필터·Pagination·민감 정보 비노출과 ADMIN Security 계약을 검증한다. + */ +@WebMvcTest(AdminUserController.class) +@Import(SecurityConfig.class) +@DisplayName("AdminUserController 테스트") +class AdminUserControllerTest { + + private static final String USERS_URL = "/admin/users"; + + @Autowired private MockMvc mockMvc; + + @MockitoBean private UserRoleCommandService userRoleCommandService; + @MockitoBean private AdminUserQueryService adminUserQueryService; + @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; + @MockitoBean private JwtProvider jwtProvider; + @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; + @MockitoBean private CorsConfigurationSource corsConfigurationSource; + + @Test + @DisplayName("ADMIN 사용자가 검색·부서·상태 필터로 사용자와 역할을 페이지 조회한다") + void getUsers_returnsFilteredUserPage() throws Exception { + AdminUserResponse response = new AdminUserResponse( + 10L, + "관리자", + "admin@example.com", + "admin", + 3L, + "플랫폼팀", + UserStatus.ACTIVE, + List.of("ADMIN", "USER"), + LocalDateTime.of(2026, 8, 10, 10, 0), + LocalDateTime.of(2026, 1, 1, 10, 0) + ); + given(adminUserQueryService.getUsers("admin", 3L, UserStatus.ACTIVE, 0, 20)) + .willReturn(new PageResponse<>(List.of(response), 0, 20, 1, 1, true, true)); + + mockMvc.perform(get(USERS_URL) + .param("keyword", "admin") + .param("departmentId", "3") + .param("status", "ACTIVE") + .with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].userId").value(10)) + .andExpect(jsonPath("$.data.content[0].email").value("admin@example.com")) + .andExpect(jsonPath("$.data.content[0].departmentName").value("플랫폼팀")) + .andExpect(jsonPath("$.data.content[0].roles[0]").value("ADMIN")) + .andExpect(jsonPath("$.data.content[0].passwordHash").doesNotExist()) + .andExpect(jsonPath("$.data.totalElements").value(1)); + } + + @Test + @DisplayName("ADMIN이 아닌 사용자는 전체 사용자 목록을 조회할 수 없다") + 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()); + } + + @Test + @DisplayName("페이지 입력 범위를 벗어나면 400을 반환한다") + void getUsers_returnsBadRequest_whenPageInputIsInvalid() throws Exception { + mockMvc.perform(get(USERS_URL) + .param("page", "-1") + .with(user("admin").roles("ADMIN"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-002")); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/user/service/query/AdminUserQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/user/service/query/AdminUserQueryServiceTest.java new file mode 100644 index 0000000..81d7efd --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/user/service/query/AdminUserQueryServiceTest.java @@ -0,0 +1,102 @@ +package com.opensource.docgrid.domain.user.service.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.opensource.docgrid.domain.user.converter.AdminUserConverter; +import com.opensource.docgrid.domain.user.dto.response.AdminUserResponse; +import com.opensource.docgrid.domain.user.entity.Role; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.entity.UserRole; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; +import com.opensource.docgrid.global.common.response.PageResponse; + +/** + * 관리자 사용자 목록의 검색어 정규화, 기본 삭제 제외, 역할 일괄 조회와 Pagination을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminUserQueryService 단위 테스트") +class AdminUserQueryServiceTest { + + @Mock private UserRepository userRepository; + @Mock private UserRoleRepository userRoleRepository; + @Mock private AdminUserConverter adminUserConverter; + + @InjectMocks private AdminUserQueryService adminUserQueryService; + + @Test + @DisplayName("검색·부서·상태 필터와 사용자 역할을 결합해 최근 가입순 페이지를 반환한다") + void getUsers_returnsFilteredUsersWithRoles() { + User user = org.mockito.Mockito.mock(User.class); + UserRole userRole = org.mockito.Mockito.mock(UserRole.class); + Role role = org.mockito.Mockito.mock(Role.class); + AdminUserResponse response = org.mockito.Mockito.mock(AdminUserResponse.class); + given(user.getId()).willReturn(10L); + given(userRole.getUser()).willReturn(user); + given(userRole.getRole()).willReturn(role); + given(role.getCode()).willReturn("ADMIN"); + given(userRepository.findAdminUsers( + org.mockito.ArgumentMatchers.eq("Alice"), + org.mockito.ArgumentMatchers.eq(3L), + org.mockito.ArgumentMatchers.eq(UserStatus.ACTIVE), + org.mockito.ArgumentMatchers.eq(UserStatus.DELETED), + org.mockito.ArgumentMatchers.any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(user), PageRequest.of(1, 5), 6)); + given(userRoleRepository.findAllWithRoleByUserIdIn(List.of(10L))).willReturn(List.of(userRole)); + given(adminUserConverter.toResponse(user, List.of("ADMIN"))).willReturn(response); + + PageResponse result = adminUserQueryService.getUsers( + " Alice ", + 3L, + UserStatus.ACTIVE, + 1, + 5 + ); + + assertThat(result.content()).containsExactly(response); + assertThat(result.totalElements()).isEqualTo(6); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + then(userRepository).should().findAdminUsers( + org.mockito.ArgumentMatchers.eq("Alice"), + org.mockito.ArgumentMatchers.eq(3L), + org.mockito.ArgumentMatchers.eq(UserStatus.ACTIVE), + org.mockito.ArgumentMatchers.eq(UserStatus.DELETED), + pageableCaptor.capture() + ); + assertThat(pageableCaptor.getValue().getSort().getOrderFor("createdAt").isDescending()).isTrue(); + assertThat(pageableCaptor.getValue().getSort().getOrderFor("id").isDescending()).isTrue(); + } + + @Test + @DisplayName("빈 검색어와 상태 미입력은 필터 없음과 DELETED 기본 제외 조건으로 전달한다") + void getUsers_normalizesBlankKeyword_andSkipsRoleQueryForEmptyPage() { + given(userRepository.findAdminUsers( + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.eq(UserStatus.DELETED), + org.mockito.ArgumentMatchers.any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + + PageResponse result = adminUserQueryService.getUsers(" ", null, null, 0, 20); + + assertThat(result.content()).isEmpty(); + then(userRoleRepository).shouldHaveNoInteractions(); + } +}