diff --git a/.claude/rules/java-style.md b/.claude/rules/java-style.md index e7cc6649..5bc30fc9 100644 --- a/.claude/rules/java-style.md +++ b/.claude/rules/java-style.md @@ -4,6 +4,21 @@ globs: "**/*.java" # Java 코드 스타일 +## 클래스 문서화 +- 새로 만드는 클래스/인터페이스/record에는 클래스 레벨 Javadoc으로 역할·책임·경계(무엇을 하고, 무엇을 하지 않는지)를 설명 +- 기존 파일에 소급 적용하지 않는다 — 새로 작성하는 파일부터 적용 (2026-08-18 도입, 그 이전 파일은 점진적으로 채워나감) + +```java +/** + * 역할 목록 조회 API. + * + *

권한 부여 대상(ROLE) 선택 등에 쓰는 역할 목록을 반환한다. + * 실제 조회·변환은 {@link RoleQueryService}에 위임한다. + */ +@RestController +public class RoleController { ... } +``` + ## 레이어 규칙 - Controller → Service → Repository 단방향 - Entity를 Controller 계층에 노출 금지 — 반드시 DTO 변환 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 dc2e025c..02c631bc 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 @@ -47,19 +47,25 @@ public class CollectionController { private final CollectionQueryService collectionQueryService; @Operation( - summary = "내 컬렉션 목록 조회", - description = "현재 로그인한 사용자가 소유한 ACTIVE 상태의 컬렉션 목록을 반환합니다." + summary = "컬렉션 목록 조회", + description = "현재 로그인한 사용자가 읽을 수 있는 ACTIVE 상태의 컬렉션을 최신 생성순으로 페이지 조회합니다. " + + "소유한 컬렉션, PUBLIC 컬렉션, 직접·역할·부서 단위로 권한을 부여받은 컬렉션(부모 컬렉션 상속 포함)을 모두 포함합니다. " + + "keyword를 입력하면 이름·설명에 포함된 것만 필터링합니다." ) @GetMapping - public ResponseEntity>> getMyCollections( - @Parameter(hidden = true) @CurrentUser Long userId) { - return ResponseUtils.ok(collectionQueryService.getMyCollections(userId)); + public ResponseEntity>> getCollections( + @Parameter(hidden = true) @CurrentUser Long userId, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseUtils.ok(collectionQueryService.getCollections(userId, keyword, page, size)); } @Operation( summary = "컬렉션 삭제", description = "컬렉션을 soft delete합니다. 소유자(owner)만 가능합니다. " + - "소속 권한(collection_permissions)이 모두 삭제되고, USER 대상 권한이 있었다면 캐시도 무효화됩니다." + "하위 컬렉션 전체와 그 안의 문서 매핑까지 함께 삭제됩니다(cascade). " + + "대상 전체의 소속 권한(collection_permissions)이 모두 삭제되고, USER 대상 권한이 있었다면 캐시도 무효화됩니다." ) @DeleteMapping("/{collectionId}") public ResponseEntity> deleteCollection( @@ -105,6 +111,18 @@ public ResponseEntity> getCollection( return ResponseUtils.ok(collectionQueryService.getCollection(userId, collectionId)); } + @Operation( + summary = "직계 자식 컬렉션 목록 조회", + description = "이 컬렉션 바로 아래에 있는 하위 컬렉션 목록을 반환합니다. 하위 컬렉션 자체까지만 반환하며, " + + "더 아래 단계를 보려면 반환된 하위 컬렉션 ID로 이 API를 다시 호출해야 합니다." + ) + @GetMapping("/{collectionId}/children") + public ResponseEntity>> getChildren( + @PathVariable Long collectionId, + @Parameter(hidden = true) @CurrentUser Long userId) { + return ResponseUtils.ok(collectionQueryService.getChildren(userId, collectionId)); + } + @Operation( summary = "컬렉션 문서 목록 조회", description = "컬렉션을 읽을 수 있는 사용자가 개별 문서 읽기 권한도 가진 항목만 추가 최신순으로 페이지 조회합니다. " + 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 f06ae58b..1f5f6c5d 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 @@ -17,6 +17,9 @@ public interface CollectionDocumentRepository extends JpaRepository findAllByCollectionId(Long collectionId); + // cascade 삭제용 — 대상 컬렉션 ID 목록(자기 자신+후손 전체)에 속한 문서 매핑 전체 조회 + List findAllByCollectionIdIn(List collectionIds); + /** * 권한 선필터를 통과한 컬렉션 문서를 현재 버전 Metadata와 함께 페이지 조회한다. */ diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java index a0a43417..1358ff62 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java @@ -2,13 +2,121 @@ import java.util.List; +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.DocumentCollection; import com.opensource.docgrid.domain.collection.enums.CollectionStatus; public interface CollectionRepository extends JpaRepository { - // 소유자 기준 상태별 컬렉션 목록 조회 (GET /collections) - List findAllByOwnerIdAndStatus(Long ownerId, CollectionStatus status); + /** + * 사용자가 읽을 수 있는 컬렉션 ID 전체 (GET /collections pre-filter). + * 4가지 접근 경로: OWNER / PUBLIC / USER 직접 권한 / ROLE·DEPARTMENT live(부모 컬렉션 체인 상속 포함). + * ACTIVE 상태만 대상으로 하며, keyword가 있으면 이름·설명 부분일치로도 필터링한다(keyword는 null 가능). + */ + @Query(value = """ + WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL + ) + SELECT c.id FROM collections c + WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_permissions cp ON cp.collection_id = c.id + WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_ancestors ca ON ca.collection_id = c.id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id + JOIN user_roles ur ON ur.role_id = cp.role_id + WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_ancestors ca ON ca.collection_id = c.id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id + JOIN users u ON u.department_id = cp.department_id + WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + """, nativeQuery = true) + List findReadableCollectionIds(@Param("userId") Long userId, @Param("keyword") String keyword); + + // pre-filter로 걸러진 ID를 받아 정렬·페이징만 담당 (GET /collections) + @Query("SELECT c FROM DocumentCollection c JOIN FETCH c.owner WHERE c.id IN :ids") + Page findAllByIdIn(@Param("ids") List ids, Pageable pageable); + + // 직계 자식 컬렉션 목록 조회 (GET /collections/{id}/children) + List findAllByParentCollectionIdAndStatus(Long parentCollectionId, CollectionStatus status); + + /** + * 자기 자신 + 모든 조상 컬렉션 ID (권한 상속 판단용). + * 삭제된 조상도 결과에 포함한다 — 삭제된 컬렉션은 권한이 비어있어 무해하고, + * status 필터를 넣으면 중간 조상이 삭제됐을 때 그 위 조상으로 체인이 끊기는 문제가 생긴다. + */ + @Query(value = """ + WITH RECURSIVE ancestors AS ( + SELECT id, parent_collection_id FROM collections WHERE id = :collectionId + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN ancestors a ON c.id = a.parent_collection_id + ) + SELECT id FROM ancestors + """, nativeQuery = true) + List findAncestorIdsInclusive(@Param("collectionId") Long collectionId); + + /** + * 자기 자신 + 모든 후손 컬렉션 ID (cascade 삭제 대상 판단용). + */ + @Query(value = """ + WITH RECURSIVE descendants AS ( + SELECT id, parent_collection_id FROM collections WHERE id = :collectionId + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN descendants d ON c.parent_collection_id = d.id + ) + SELECT id FROM descendants + """, nativeQuery = true) + List findDescendantIdsInclusive(@Param("collectionId") Long collectionId); + + /** + * 문서가 속한 모든 컬렉션(N:M) + 그 컬렉션들 각각의 조상 전체 ID (문서 권한 상속 판단용). + */ + @Query(value = """ + WITH RECURSIVE ancestors AS ( + SELECT c.id, c.parent_collection_id + FROM collections c + WHERE c.id IN ( + SELECT DISTINCT cd.collection_id FROM collection_documents cd WHERE cd.document_id = :documentId + ) + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN ancestors a ON c.id = a.parent_collection_id + ) + SELECT DISTINCT id FROM ancestors + """, nativeQuery = true) + List findEffectiveCollectionIdsForDocument(@Param("documentId") Long documentId); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java index bdc7fdd1..4b4dca80 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java @@ -53,7 +53,11 @@ public CollectionResponse createCollection(Long userId, CreateCollectionRequest DocumentCollection parentCollection = null; // 상위 폴더 지정은 선택 사항이라 null로 초기화 if (request.parentCollectionId() != null) { parentCollection = collectionRepository.findById(request.parentCollectionId()) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + if (!permissionQueryService.canWriteCollection(userId, parentCollection)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } } VisibilityType visibility = request.visibility() != null ? request.visibility() : VisibilityType.PRIVATE; @@ -102,25 +106,34 @@ public CollectionDocumentResponse addDocument(Long collectionId, Long userId, Ad return collectionConverter.toDocumentResponse(collectionDocument); } - // 컬렉션 soft delete — 소유자만 가능 + // 컬렉션 soft delete — 소유자만 가능. 하위 컬렉션 전체와 그 안의 문서 매핑까지 cascade로 함께 삭제한다. + // owner 체크는 삭제 대상 최상위(root)에서만 하고 하위 각각은 재확인하지 않는다 + // (구글드라이브 공유폴더 삭제와 동일한 멘탈모델 — root에 대한 권한으로 하위 전체가 지워짐). public void deleteCollection(Long collectionId, Long userId) { - DocumentCollection collection = collectionRepository.findById(collectionId) + DocumentCollection root = collectionRepository.findById(collectionId) .filter(c -> c.getStatus() != CollectionStatus.DELETED) .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); - if (!collection.getOwner().getId().equals(userId)) { + if (!root.getOwner().getId().equals(userId)) { throw new DocGridException(ErrorCode.PERMISSION_DENIED); } - // 폴더에 속한 모든 권한 삭제 및 캐시 무효화 - List permissions = collectionPermissionRepository.findAllByCollectionId(collectionId); + List targetIds = collectionRepository.findDescendantIdsInclusive(collectionId); // 자기 자신 포함 + + // 대상 전체(자기 자신+하위)에 속한 권한 삭제 및 캐시 무효화 + List permissions = collectionPermissionRepository.findAllByCollectionIdIn(targetIds); permissions.stream() .filter(p -> p.getTargetType() == PermissionTargetType.USER) // 컬렉션 권한이 USER 대상인 경우에만 캐시 무효화 .forEach(p -> cacheService.bulkRevokeBySource(AccessSourceType.DIRECT_COLLECTION_PERMISSION, p.getId())); collectionPermissionRepository.deleteAll(permissions); // 컬렉션 권한 삭제 - collection.markDeleted(LocalDateTime.now()); // 폴더 상태를 DELETED로 변경 + // 대상 전체(자기 자신+하위)의 문서 매핑 삭제 + List mappings = collectionDocumentRepository.findAllByCollectionIdIn(targetIds); + collectionDocumentRepository.deleteAll(mappings); + + LocalDateTime now = LocalDateTime.now(); + collectionRepository.findAllById(targetIds).forEach(c -> c.markDeleted(now)); // 대상 전체 상태를 DELETED로 변경 } // 컬렉션에서 문서 제거 — 소유자만 가능 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 e789cb1d..662c52e9 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 @@ -39,6 +39,10 @@ public class CollectionQueryService { Sort.Order.desc("addedAt"), Sort.Order.desc("id") ); + private static final Sort COLLECTION_SORT = Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ); private final CollectionRepository collectionRepository; private final CollectionDocumentRepository collectionDocumentRepository; @@ -57,10 +61,37 @@ public CollectionResponse getCollection(Long userId, Long collectionId) { return collectionConverter.toResponse(collection); } - // 내 컬렉션 목록 조회 (ACTIVE 상태만) - public List getMyCollections(Long userId) { - return collectionRepository.findAllByOwnerIdAndStatus(userId, CollectionStatus.ACTIVE) + /** + * 사용자가 읽을 수 있는 컬렉션 목록 페이지 조회 (owner + PUBLIC + 권한부여 + 부모 상속, ACTIVE만). + * keyword가 있으면 이름·설명 부분일치로도 필터링한다. + */ + public PageResponse getCollections(Long userId, String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page, size, COLLECTION_SORT); + List readableIds = collectionRepository.findReadableCollectionIds(userId, keyword); + if (readableIds.isEmpty()) { + return PageResponse.from(Page.empty(pageable), List.of()); + } + + Page collections = collectionRepository.findAllByIdIn(readableIds, pageable); + List content = collections.getContent().stream() + .map(collectionConverter::toResponse) + .toList(); + return PageResponse.from(collections, content); + } + + // 직계 자식 컬렉션 목록 조회 — 부모 읽기 권한 확인 후, 자식 각각의 읽기 권한도 확인 + // (자식 owner/visibility가 부모와 다를 수 있으므로 부모 권한만으로 자식을 노출하면 안 됨) + public List getChildren(Long userId, Long collectionId) { + DocumentCollection parent = collectionRepository.findById(collectionId) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + if (!permissionQueryService.canReadCollection(userId, parent)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + return collectionRepository.findAllByParentCollectionIdAndStatus(collectionId, CollectionStatus.ACTIVE) .stream() + .filter(child -> permissionQueryService.canReadCollection(userId, child)) .map(collectionConverter::toResponse) .toList(); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java index 637c74a1..0c7e0111 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java @@ -73,8 +73,17 @@ List findDocumentStatus( // 검색 pre-filter — 사용자가 읽을 수 있는 문서 ID 전체 (컬렉션 미지정) // 5가지 접근 경로: OWNER / PUBLIC / USER캐시 / ROLE live / DEPT live (문서·컬렉션 권한 모두 포함) + // 컬렉션 ROLE/DEPT 권한은 collection_ancestors closure를 통해 부모 컬렉션 체인까지 상속된다. // statuses는 DocumentStatus.name() 문자열 목록. 검색은 INDEXED만, 문서 목록은 처리 중 상태까지 넘긴다. @Query(value = """ + WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL + ) SELECT d.id FROM documents d WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION @@ -103,7 +112,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id - JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id JOIN user_roles ur ON ur.role_id = cp.role_id WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) @@ -111,7 +121,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id - JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id JOIN users u ON u.department_id = cp.department_id WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) @@ -123,7 +134,17 @@ List findReadableDocumentIds( ); // 검색 pre-filter — 특정 컬렉션 내에서 사용자가 읽을 수 있는 문서 ID + // 컬렉션 ROLE/DEPT 권한은 collection_ancestors closure를 통해 부모 컬렉션 체인까지 상속된다. + // 바깥쪽 WHERE는 "이 컬렉션에 직접 속한 문서만" 필터 — 하위 폴더 문서가 상위 폴더 목록에 섞이지 않게 한다. @Query(value = """ + WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL + ) SELECT sub.id FROM ( SELECT d.id FROM documents d WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status IN (:statuses) @@ -153,7 +174,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id - JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id JOIN user_roles ur ON ur.role_id = cp.role_id WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) @@ -161,7 +183,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id - JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id JOIN users u ON u.department_id = cp.department_id WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) 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 656ecd35..413b803a 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,9 @@ public interface CollectionPermissionRepository extends JpaRepository findAllByCollectionId(Long collectionId); + // cascade 삭제용 — 대상 컬렉션 ID 목록(자기 자신+후손 전체)에 걸린 권한 전체 조회 + List findAllByCollectionIdIn(List collectionIds); + /** * 컬렉션에 직접 부여된 권한을 대상·부여자 정보와 함께 최신순으로 조회한다. */ @@ -211,4 +214,73 @@ SELECT COUNT(cp) > 0 FROM CollectionPermission cp AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) """) boolean existsDeptAdminPermissionForCollection(@Param("userId") Long userId, @Param("collectionId") Long collectionId); + + // 컬렉션 트리 상속용 — 컬렉션 ID 목록(자기 자신+조상 또는 문서가 속한 컬렉션+조상) 중 + // 하나라도 ROLE/DEPARTMENT 권한이 있으면 true. 기존 단일-ID 메서드는 그대로 두고 추가로 병행한다. + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN UserRole ur ON ur.role = cp.role + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE + AND ur.user.id = :userId + AND cp.canRead = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsRoleReadPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN UserRole ur ON ur.role = cp.role + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE + AND ur.user.id = :userId + AND cp.canWrite = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsRoleWritePermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN UserRole ur ON ur.role = cp.role + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE + AND ur.user.id = :userId + AND cp.canAdmin = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsRoleAdminPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN User u ON u.department = cp.department + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT + AND u.id = :userId + AND cp.canRead = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsDeptReadPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN User u ON u.department = cp.department + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT + AND u.id = :userId + AND cp.canWrite = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsDeptWritePermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN User u ON u.department = cp.department + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT + AND u.id = :userId + AND cp.canAdmin = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsDeptAdminPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java index 8d8d4765..5760c2ec 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java @@ -73,6 +73,10 @@ public CollectionPermissionResponse grantPermission(Long collectionId, Long gran } else if (request.targetType() == PermissionTargetType.ROLE) { targetRole = roleRepository.findById(request.roleId()) .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + // 모든 사용자가 기본으로 가진 USER role을 대상으로 지정하면 사실상 전체 공개가 되므로 차단한다. + if ("USER".equals(targetRole.getCode())) { + throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE); + } } else { targetDepartment = departmentRepository.findById(request.departmentId()) .orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND)); diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java index 25113094..61ec58fa 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java @@ -66,6 +66,10 @@ public DocumentPermissionResponse grantPermission(Long documentId, Long grantorI } else if (request.targetType() == PermissionTargetType.ROLE) { targetRole = roleRepository.findById(request.roleId()) .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + // 모든 사용자가 기본으로 가진 USER role을 대상으로 지정하면 사실상 전체 공개가 되므로 차단한다. + if ("USER".equals(targetRole.getCode())) { + throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE); + } } else { targetDepartment = departmentRepository.findById(request.departmentId()) .orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND)); 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 99abedfc..44662774 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 @@ -120,6 +120,15 @@ public boolean canReadDocument(Long userId, Long documentId) { return true; } + // 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty() + && (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds) + || collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds))) { + log.info("[PERM] canRead inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start)); + return true; + } + double step5Ms = (System.nanoTime() - t5) / 1_000_000.0; log.info("[PERM] canRead denied doc={} user={} step5={}ms elapsed={}ms", documentId, userId, step5Ms, ms(start)); @@ -164,6 +173,15 @@ public boolean canWriteDocument(Long userId, Long documentId) { return true; } + // 5단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty() + && (collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, effectiveCollectionIds) + || collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, effectiveCollectionIds))) { + log.info("[PERM] canWrite inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start)); + return true; + } + double step4Ms = (System.nanoTime() - t4) / 1_000_000.0; log.info("[PERM] canWrite denied doc={} user={} step4={}ms elapsed={}ms", documentId, userId, step4Ms, ms(start)); @@ -208,6 +226,15 @@ public boolean canAdminDocument(Long userId, Long documentId) { return true; } + // 5단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty() + && (collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, effectiveCollectionIds) + || collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, effectiveCollectionIds))) { + log.info("[PERM] canAdmin inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start)); + return true; + } + double step4Ms = (System.nanoTime() - t4) / 1_000_000.0; log.info("[PERM] canAdmin denied doc={} user={} step4={}ms elapsed={}ms", documentId, userId, step4Ms, ms(start)); @@ -275,6 +302,26 @@ public DocumentPermissionSummaryResponse checkDocumentPermission(Long userId, Lo if (deptAdmin) canAdmin = true; } + // 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) — 기존 ROLE/DEPARTMENT 출처 값을 그대로 재사용한다 + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty()) { + boolean inheritedRoleRead = collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedRoleWrite = collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedRoleAdmin = collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedDeptRead = collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedDeptWrite = collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedDeptAdmin = collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, effectiveCollectionIds); + if ((inheritedRoleRead || inheritedRoleWrite || inheritedRoleAdmin) && !sources.contains(PermissionSourceType.ROLE)) { + sources.add(PermissionSourceType.ROLE); + } + if ((inheritedDeptRead || inheritedDeptWrite || inheritedDeptAdmin) && !sources.contains(PermissionSourceType.DEPARTMENT)) { + sources.add(PermissionSourceType.DEPARTMENT); + } + if (inheritedRoleRead || inheritedDeptRead) canRead = true; + if (inheritedRoleWrite || inheritedDeptWrite) canWrite = true; + if (inheritedRoleAdmin || inheritedDeptAdmin) canAdmin = true; + } + log.info("[PERM] checkDoc doc={} user={} canRead={} canWrite={} canAdmin={} sources={} elapsed={}ms", documentId, userId, canRead, canWrite, canAdmin, sources, ms(start)); return new DocumentPermissionSummaryResponse(documentId, canRead, canWrite, canAdmin, sources); @@ -293,7 +340,12 @@ public boolean canReadCollection(Long userId, DocumentCollection collection) { Long collectionId = collection.getId(); if (collectionPermissionRepository.existsUserReadPermission(userId, collectionId)) return true; if (collectionPermissionRepository.existsRoleReadPermissionForCollection(userId, collectionId)) return true; - return collectionPermissionRepository.existsDeptReadPermissionForCollection(userId, collectionId); + if (collectionPermissionRepository.existsDeptReadPermissionForCollection(userId, collectionId)) return true; + + // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId); + if (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, ancestorIds)) return true; + return collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, ancestorIds); } // 컬렉션 쓰기 권한 판단 (소유자, USER/ROLE/DEPT 직접 권한) @@ -307,7 +359,12 @@ public boolean canWriteCollection(Long userId, DocumentCollection collection) { Long collectionId = collection.getId(); if (collectionPermissionRepository.existsUserWritePermission(userId, collectionId)) return true; if (collectionPermissionRepository.existsRoleWritePermissionForCollection(userId, collectionId)) return true; - return collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId); + if (collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId)) return true; + + // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId); + if (collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, ancestorIds)) return true; + return collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, ancestorIds); } // 컬렉션 관리 권한 판단 (소유자, USER/ROLE/DEPT 직접 권한) @@ -321,7 +378,12 @@ public boolean canAdminCollection(Long userId, DocumentCollection collection) { Long collectionId = collection.getId(); if (collectionPermissionRepository.existsUserAdminPermission(userId, collectionId)) return true; if (collectionPermissionRepository.existsRoleAdminPermissionForCollection(userId, collectionId)) return true; - return collectionPermissionRepository.existsDeptAdminPermissionForCollection(userId, collectionId); + if (collectionPermissionRepository.existsDeptAdminPermissionForCollection(userId, collectionId)) return true; + + // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId); + if (collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, ancestorIds)) return true; + return collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, ancestorIds); } // collectionId로 조회하되, status가 DELETED인 컬렉션은 필터링해서 제외한다 (없는 것으로 취급). diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java new file mode 100644 index 00000000..11541bd0 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java @@ -0,0 +1,38 @@ +package com.opensource.docgrid.domain.user.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.user.dto.response.RoleResponse; +import com.opensource.docgrid.domain.user.service.query.RoleQueryService; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +/** + * 역할 목록 조회 API. + * + *

권한 부여 대상(ROLE) 선택 등 프론트에서 역할 이름이 필요한 화면에 쓰인다. + * 실제 조회·변환은 {@link RoleQueryService}에 위임한다. + */ +@Tag(name = "Role", description = "역할 관련 API") +@RestController +@RequestMapping("/roles") +@RequiredArgsConstructor +public class RoleController { + + private final RoleQueryService roleQueryService; + + @Operation(summary = "역할 목록 조회", description = "권한 부여 대상 선택 등에 쓰는 전체 역할 목록을 반환합니다.") + @GetMapping + public ResponseEntity>> getRoles() { + return ResponseUtils.ok(roleQueryService.getRoles()); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java new file mode 100644 index 00000000..1e6c70a0 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java @@ -0,0 +1,20 @@ +package com.opensource.docgrid.domain.user.dto.response; + +import com.opensource.docgrid.domain.user.entity.Role; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 역할 목록 조회 API 응답 DTO. + * + *

{@link Role} 엔티티를 Controller 계층에 직접 노출하지 않기 위한 변환 경계다. + */ +public record RoleResponse( + @Schema(description = "역할 ID") Long id, + @Schema(description = "역할명") String name, + @Schema(description = "역할 코드") String code +) { + public static RoleResponse from(Role role) { + return new RoleResponse(role.getId(), role.getName(), role.getCode()); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java new file mode 100644 index 00000000..cc404010 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java @@ -0,0 +1,31 @@ +package com.opensource.docgrid.domain.user.service.query; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.user.dto.response.RoleResponse; +import com.opensource.docgrid.domain.user.repository.RoleRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 역할 목록 조회 서비스. + * + *

{@link RoleRepository}에서 전체 역할을 읽어 {@link RoleResponse}로 변환하는 책임만 가진다. + */ +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class RoleQueryService { + + private final RoleRepository roleRepository; + + public List getRoles() { + return roleRepository.findAll() + .stream() + .map(RoleResponse::from) + .toList(); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 31015146..edfcc1d2 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -173,6 +173,8 @@ public enum ErrorCode { INVALID_TARGET_TYPE(HttpStatus.BAD_REQUEST, "PERMISSION-001", "target_type과 ID 필드 조합이 올바르지 않습니다."), COLLECTION_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "PERMISSION-002", "컬렉션 권한을 찾을 수 없습니다."), DOCUMENT_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "PERMISSION-003", "문서 권한을 찾을 수 없습니다."), + ROLE_NOT_GRANTABLE(HttpStatus.BAD_REQUEST, "PERMISSION-004", + "USER role은 모든 사용자가 보유하고 있어 권한 부여 대상으로 지정할 수 없습니다. 전체 공개가 목적이면 visibility를 PUBLIC으로 설정하세요."), // WORKER // Claim 요청의 Worker 식별자가 등록된 실행 인스턴스와 연결되지 않은 경우 사용한다. 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 index b07138f2..ce70706a 100644 --- 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 @@ -19,6 +19,8 @@ import org.springframework.test.web.servlet.MockMvc; import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse; +import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse; +import com.opensource.docgrid.domain.collection.enums.CollectionStatus; 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; @@ -36,6 +38,8 @@ class CollectionControllerTest { private static final String DOCUMENTS_URL = "/collections/{collectionId}/documents"; + private static final String CHILDREN_URL = "/collections/{collectionId}/children"; + private static final String COLLECTIONS_URL = "/collections"; @Autowired private MockMvc mockMvc; @@ -89,6 +93,39 @@ void getCollectionDocuments_returnsBadRequest_whenPageInputIsInvalid() throws Ex .andExpect(jsonPath("$.code").value("COMMON-002")); } + @Test + @DisplayName("인증된 사용자가 직계 자식 컬렉션 목록을 조회한다") + void getChildren_returnsChildCollections() throws Exception { + CollectionResponse child = new CollectionResponse( + 2L, "하위 컬렉션", null, 10L, 1L, VisibilityType.PRIVATE, CollectionStatus.ACTIVE, + LocalDateTime.of(2026, 8, 1, 10, 0) + ); + given(collectionQueryService.getChildren(10L, 1L)).willReturn(List.of(child)); + + mockMvc.perform(get(CHILDREN_URL, 1L) + .with(authentication(authenticationWithUserId(10L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].collectionId").value(2)) + .andExpect(jsonPath("$.data[0].parentCollectionId").value(1)); + } + + @Test + @DisplayName("인증된 사용자가 읽을 수 있는 컬렉션을 페이지 조회한다") + void getCollections_returnsReadableCollectionPage() throws Exception { + CollectionResponse collection = new CollectionResponse( + 1L, "인사팀", null, 10L, null, VisibilityType.PRIVATE, CollectionStatus.ACTIVE, + LocalDateTime.of(2026, 8, 1, 10, 0) + ); + given(collectionQueryService.getCollections(10L, null, 0, 20)) + .willReturn(new PageResponse<>(List.of(collection), 0, 20, 1, 1, true, true)); + + mockMvc.perform(get(COLLECTIONS_URL) + .with(authentication(authenticationWithUserId(10L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].collectionId").value(1)) + .andExpect(jsonPath("$.data.totalElements").value(1)); + } + private UsernamePasswordAuthenticationToken authenticationWithUserId(Long userId) { UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( "user", diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/fixture/CollectionFixture.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/fixture/CollectionFixture.java index febdb76a..4067efb2 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/collection/fixture/CollectionFixture.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/fixture/CollectionFixture.java @@ -66,6 +66,18 @@ public static DocumentCollection createCollection() { return createCollection(createOwner()); } + public static DocumentCollection createChildCollection(User owner, DocumentCollection parent, Long childId) { + DocumentCollection child = DocumentCollection.builder() + .owner(owner) + .parentCollection(parent) + .name("하위 컬렉션") + .visibility(VisibilityType.PRIVATE) + .status(CollectionStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(child, "id", childId); + return child; + } + public static Document createDocument(User owner) { Document document = Document.builder() .owner(owner) diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java new file mode 100644 index 00000000..0c389cab --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java @@ -0,0 +1,309 @@ +package com.opensource.docgrid.domain.collection.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +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.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +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.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.permission.entity.CollectionPermission; +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; +import com.opensource.docgrid.domain.permission.repository.CollectionPermissionRepository; +import com.opensource.docgrid.domain.user.entity.Department; +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.CommonStatus; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.DepartmentRepository; +import com.opensource.docgrid.domain.user.repository.RoleRepository; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; + +import jakarta.persistence.EntityManager; + +/** + * 컬렉션 트리(부모-자식) 재귀 쿼리와 직계 자식 조회를 3단 트리(root→child→grandchild)로 검증한다. + * findReadableCollectionIds의 owner/PUBLIC 노출, keyword 필터, 부모 컬렉션으로부터의 DEPARTMENT 권한 + * 상속도 함께 검증한다. + * 이동/수정 API가 없어 순환 참조가 API상 불가능하므로 순환 참조 케이스는 검증하지 않는다. + */ +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DisplayName("컬렉션 트리 Repository 테스트") +class CollectionTreeRepositoryTest { + + @Autowired private CollectionRepository collectionRepository; + @Autowired private CollectionDocumentRepository collectionDocumentRepository; + @Autowired private CollectionPermissionRepository collectionPermissionRepository; + @Autowired private DocumentRepository documentRepository; + @Autowired private UserRepository userRepository; + @Autowired private DepartmentRepository departmentRepository; + @Autowired private RoleRepository roleRepository; + @Autowired private UserRoleRepository userRoleRepository; + @Autowired private EntityManager entityManager; + + @Test + @DisplayName("findAncestorIdsInclusive는 자기 자신부터 최상위 조상까지 전부 반환한다") + void findAncestorIdsInclusive_returnsSelfAndAllAncestors() { + User owner = saveOwner(); + DocumentCollection root = saveCollection(owner, null); + DocumentCollection child = saveCollection(owner, root); + DocumentCollection grandchild = saveCollection(owner, child); + flushAndClear(); + + List ancestorsOfGrandchild = collectionRepository.findAncestorIdsInclusive(grandchild.getId()); + List ancestorsOfRoot = collectionRepository.findAncestorIdsInclusive(root.getId()); + + assertThat(ancestorsOfGrandchild).containsExactlyInAnyOrder(root.getId(), child.getId(), grandchild.getId()); + assertThat(ancestorsOfRoot).containsExactly(root.getId()); + } + + @Test + @DisplayName("findDescendantIdsInclusive는 자기 자신부터 모든 후손까지 전부 반환한다") + void findDescendantIdsInclusive_returnsSelfAndAllDescendants() { + User owner = saveOwner(); + DocumentCollection root = saveCollection(owner, null); + DocumentCollection child = saveCollection(owner, root); + DocumentCollection grandchild = saveCollection(owner, child); + flushAndClear(); + + List descendantsOfRoot = collectionRepository.findDescendantIdsInclusive(root.getId()); + List descendantsOfGrandchild = collectionRepository.findDescendantIdsInclusive(grandchild.getId()); + + assertThat(descendantsOfRoot).containsExactlyInAnyOrder(root.getId(), child.getId(), grandchild.getId()); + assertThat(descendantsOfGrandchild).containsExactly(grandchild.getId()); + } + + @Test + @DisplayName("findEffectiveCollectionIdsForDocument는 문서가 속한 컬렉션과 그 조상 전체를 반환한다") + void findEffectiveCollectionIdsForDocument_returnsContainingCollectionsAndAncestors() { + User owner = saveOwner(); + DocumentCollection root = saveCollection(owner, null); + DocumentCollection child = saveCollection(owner, root); + Document document = saveDocument(owner); + addToCollection(child, document, owner); + flushAndClear(); + + List effectiveIds = collectionRepository.findEffectiveCollectionIdsForDocument(document.getId()); + + assertThat(effectiveIds).containsExactlyInAnyOrder(root.getId(), child.getId()); + } + + @Test + @DisplayName("findEffectiveCollectionIdsForDocument는 문서가 어느 컬렉션에도 속하지 않으면 빈 목록을 반환한다") + void findEffectiveCollectionIdsForDocument_returnsEmpty_whenDocumentInNoCollection() { + User owner = saveOwner(); + Document document = saveDocument(owner); + flushAndClear(); + + List effectiveIds = collectionRepository.findEffectiveCollectionIdsForDocument(document.getId()); + + assertThat(effectiveIds).isEmpty(); + } + + @Test + @DisplayName("findAllByParentCollectionIdAndStatus는 직계 자식만 반환하고 손자는 포함하지 않는다") + void findAllByParentCollectionIdAndStatus_returnsDirectChildrenOnly() { + User owner = saveOwner(); + DocumentCollection root = saveCollection(owner, null); + DocumentCollection child = saveCollection(owner, root); + DocumentCollection grandchild = saveCollection(owner, child); + flushAndClear(); + + List children = collectionRepository.findAllByParentCollectionIdAndStatus( + root.getId(), CollectionStatus.ACTIVE + ); + + assertThat(children).extracting(DocumentCollection::getId).containsExactly(child.getId()); + assertThat(children).extracting(DocumentCollection::getId).doesNotContain(grandchild.getId()); + } + + @Test + @DisplayName("findReadableCollectionIds는 owner의 PRIVATE 컬렉션은 owner에게만, PUBLIC 컬렉션은 누구에게나 보여준다") + void findReadableCollectionIds_ownerAndPublic() { + User owner = saveOwner(); + User stranger = saveOwner(); + DocumentCollection privateCollection = saveCollection(owner, null); + DocumentCollection publicCollection = collectionRepository.save( + DocumentCollection.builder() + .owner(owner) + .name("공개 컬렉션") + .visibility(VisibilityType.PUBLIC) + .build() + ); + flushAndClear(); + + List ownerReadable = collectionRepository.findReadableCollectionIds(owner.getId(), null); + List strangerReadable = collectionRepository.findReadableCollectionIds(stranger.getId(), null); + + assertThat(ownerReadable).contains(privateCollection.getId(), publicCollection.getId()); + assertThat(strangerReadable).contains(publicCollection.getId()); + assertThat(strangerReadable).doesNotContain(privateCollection.getId()); + } + + @Test + @DisplayName("findReadableCollectionIds는 keyword가 있으면 이름·설명에 부분일치하는 컬렉션만 반환한다") + void findReadableCollectionIds_filtersByKeyword() { + User owner = saveOwner(); + DocumentCollection matching = collectionRepository.save( + DocumentCollection.builder().owner(owner).name("개발 문서").description("백엔드 관련").visibility(VisibilityType.PRIVATE).build() + ); + DocumentCollection nonMatching = collectionRepository.save( + DocumentCollection.builder().owner(owner).name("디자인 자료").description("UI 관련").visibility(VisibilityType.PRIVATE).build() + ); + flushAndClear(); + + List result = collectionRepository.findReadableCollectionIds(owner.getId(), "개발"); + + assertThat(result).contains(matching.getId()); + assertThat(result).doesNotContain(nonMatching.getId()); + } + + @Test + @DisplayName("findReadableCollectionIds는 부모 컬렉션에 부여된 DEPARTMENT 권한을 자식 컬렉션까지 상속해서 보여준다") + void findReadableCollectionIds_inheritsDepartmentPermissionFromParent() { + Department department = departmentRepository.save( + Department.builder().name("컬렉션목록 테스트 부서").code("CL-DEPT-" + UUID.randomUUID()).status(CommonStatus.ACTIVE).build() + ); + User owner = saveOwner(); + User deptMember = userRepository.save( + User.builder() + .department(department) + .email("collection-tree-dept-" + UUID.randomUUID() + "@test.com") + .passwordHash("hash") + .name("컬렉션목록 테스트 부서원") + .status(UserStatus.ACTIVE) + .build() + ); + DocumentCollection parent = saveCollection(owner, null); + DocumentCollection child = saveCollection(owner, parent); + collectionPermissionRepository.save( + CollectionPermission.builder() + .collection(parent) + .targetType(PermissionTargetType.DEPARTMENT) + .department(department) + .permissionType(PermissionType.READ) + .canRead(true) + .canWrite(false) + .canAdmin(false) + .grantedBy(owner) + .grantedAt(LocalDateTime.now()) + .build() + ); + flushAndClear(); + + List readable = collectionRepository.findReadableCollectionIds(deptMember.getId(), null); + + assertThat(readable).contains(parent.getId(), child.getId()); + } + + @Test + @DisplayName("findReadableCollectionIds는 부모 컬렉션에 부여된 ROLE 권한을 자식 컬렉션까지 상속해서 보여준다") + void findReadableCollectionIds_inheritsRolePermissionFromParent() { + Role role = roleRepository.save( + Role.builder().name("컬렉션목록 테스트 역할").code("CL-ROLE-" + UUID.randomUUID()).build() + ); + User owner = saveOwner(); + User roleMember = userRepository.save( + User.builder() + .email("collection-tree-role-" + UUID.randomUUID() + "@test.com") + .passwordHash("hash") + .name("컬렉션목록 테스트 역할 보유자") + .status(UserStatus.ACTIVE) + .build() + ); + userRoleRepository.save( + UserRole.builder().user(roleMember).role(role).assignedAt(LocalDateTime.now()).build() + ); + DocumentCollection parent = saveCollection(owner, null); + DocumentCollection child = saveCollection(owner, parent); + collectionPermissionRepository.save( + CollectionPermission.builder() + .collection(parent) + .targetType(PermissionTargetType.ROLE) + .role(role) + .permissionType(PermissionType.READ) + .canRead(true) + .canWrite(false) + .canAdmin(false) + .grantedBy(owner) + .grantedAt(LocalDateTime.now()) + .build() + ); + flushAndClear(); + + List readable = collectionRepository.findReadableCollectionIds(roleMember.getId(), null); + + assertThat(readable).contains(parent.getId(), child.getId()); + } + + private User saveOwner() { + return userRepository.save( + User.builder() + .email("collection-tree-" + UUID.randomUUID() + "@test.com") + .passwordHash("hash") + .name("컬렉션 트리 테스트 사용자") + .status(UserStatus.ACTIVE) + .build() + ); + } + + private DocumentCollection saveCollection(User owner, DocumentCollection parent) { + return collectionRepository.save( + DocumentCollection.builder() + .owner(owner) + .parentCollection(parent) + .name("컬렉션 트리 테스트 컬렉션") + .visibility(VisibilityType.PRIVATE) + .build() + ); + } + + private Document saveDocument(User owner) { + return documentRepository.save( + Document.builder() + .owner(owner) + .title("컬렉션 트리 테스트 문서") + .documentType(DocumentType.TXT) + .sourceType(DocumentSourceType.UPLOAD) + .status(DocumentStatus.INDEXED) + .visibility(VisibilityType.PRIVATE) + .build() + ); + } + + private void addToCollection(DocumentCollection collection, Document document, User addedBy) { + collectionDocumentRepository.save( + CollectionDocument.builder() + .collection(collection) + .document(document) + .addedBy(addedBy) + .addedAt(LocalDateTime.now()) + .build() + ); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandServiceTest.java index 47ee7c58..454b7cac 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandServiceTest.java @@ -114,7 +114,7 @@ void createCollection_defaults_visibility_to_private_when_null() { } @Test - @DisplayName("존재하는 상위 컬렉션 ID를 지정하면 parentCollection이 설정된 컬렉션이 생성된다") + @DisplayName("존재하는 상위 컬렉션 ID를 지정하고 쓰기 권한이 있으면 parentCollection이 설정된 컬렉션이 생성된다") void createCollection_succeeds_with_parentCollection() { User owner = CollectionFixture.createOwner(); DocumentCollection parent = CollectionFixture.createCollection(owner); @@ -124,12 +124,15 @@ void createCollection_succeeds_with_parentCollection() { ); given(userRepository.getReferenceById(CollectionFixture.USER_ID)).willReturn(owner); given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent)); + given(permissionQueryService.canWriteCollection(CollectionFixture.USER_ID, parent)).willReturn(true); given(collectionConverter.toResponse(any(DocumentCollection.class))).willReturn(expected); collectionCommandService.createCollection(CollectionFixture.USER_ID, request); then(collectionRepository).should().findById(CollectionFixture.COLLECTION_ID); - then(collectionRepository).should().save(any(DocumentCollection.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentCollection.class); + then(collectionRepository).should().save(captor.capture()); + assertThat(captor.getValue().getParentCollection()).isSameAs(parent); } @Test @@ -147,6 +150,23 @@ void createCollection_throws_when_parentNotFound() { .hasFieldOrPropertyWithValue("errorCode", ErrorCode.COLLECTION_NOT_FOUND); } + @Test + @DisplayName("상위 컬렉션에 쓰기 권한이 없으면 PERMISSION_DENIED 예외가 발생한다") + void createCollection_throws_when_noWritePermissionOnParent() { + User owner = CollectionFixture.createOwner(); + DocumentCollection parent = CollectionFixture.createCollection(owner); + CreateCollectionRequest request = new CreateCollectionRequest( + "하위 컬렉션", null, CollectionFixture.COLLECTION_ID, VisibilityType.PRIVATE + ); + Long otherUserId = 99L; + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent)); + given(permissionQueryService.canWriteCollection(otherUserId, parent)).willReturn(false); + + assertThatThrownBy(() -> collectionCommandService.createCollection(otherUserId, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + } + // ==================== addDocument ==================== @Test @@ -239,15 +259,45 @@ void deleteCollection_succeeds_when_owner() { User owner = CollectionFixture.createOwner(); DocumentCollection collection = CollectionFixture.createCollection(owner); CollectionPermission userPermission = PermissionFixture.createCollectionPermission(collection, owner); + List targetIds = List.of(CollectionFixture.COLLECTION_ID); given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); - given(collectionPermissionRepository.findAllByCollectionId(CollectionFixture.COLLECTION_ID)) + given(collectionRepository.findDescendantIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(targetIds); + given(collectionPermissionRepository.findAllByCollectionIdIn(targetIds)) .willReturn(List.of(userPermission)); + given(collectionDocumentRepository.findAllByCollectionIdIn(targetIds)).willReturn(List.of()); + given(collectionRepository.findAllById(targetIds)).willReturn(List.of(collection)); collectionCommandService.deleteCollection(CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID); then(cacheService).should().bulkRevokeBySource(any(), any()); - then(collectionPermissionRepository).should().deleteAll(any()); + then(collectionPermissionRepository).should().deleteAll(List.of(userPermission)); + assertThat(collection.getStatus()).isEqualTo(com.opensource.docgrid.domain.collection.enums.CollectionStatus.DELETED); + } + + @Test + @DisplayName("하위 컬렉션이 있으면 삭제 시 하위 컬렉션과 문서 매핑까지 cascade로 함께 삭제된다") + void deleteCollection_cascades_to_descendants() { + User owner = CollectionFixture.createOwner(); + DocumentCollection root = CollectionFixture.createCollection(owner); + Long childId = 2L; + DocumentCollection child = CollectionFixture.createChildCollection(owner, root, childId); + List targetIds = List.of(CollectionFixture.COLLECTION_ID, childId); + CollectionDocument childMapping = CollectionDocument.builder() + .collection(child).document(CollectionFixture.createDocument(owner)).addedBy(owner) + .addedAt(java.time.LocalDateTime.now()).build(); + + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(root)); + given(collectionRepository.findDescendantIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(targetIds); + given(collectionPermissionRepository.findAllByCollectionIdIn(targetIds)).willReturn(List.of()); + given(collectionDocumentRepository.findAllByCollectionIdIn(targetIds)).willReturn(List.of(childMapping)); + given(collectionRepository.findAllById(targetIds)).willReturn(List.of(root, child)); + + collectionCommandService.deleteCollection(CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID); + + then(collectionDocumentRepository).should().deleteAll(List.of(childMapping)); + assertThat(root.getStatus()).isEqualTo(com.opensource.docgrid.domain.collection.enums.CollectionStatus.DELETED); + assertThat(child.getStatus()).isEqualTo(com.opensource.docgrid.domain.collection.enums.CollectionStatus.DELETED); } @Test 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 6c6a09ce..1996fddf 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 @@ -110,6 +110,98 @@ void getCollection_throws_when_noReadPermission() { .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); } + @Test + @DisplayName("읽을 수 있는 컬렉션이 없으면 빈 페이지를 반환한다") + void getCollections_returnsEmptyPage_whenNoReadableCollection() { + given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, null)).willReturn(List.of()); + + PageResponse result = collectionQueryService.getCollections(CollectionFixture.USER_ID, null, 0, 20); + + assertThat(result.content()).isEmpty(); + assertThat(result.totalElements()).isZero(); + then(collectionRepository).should(never()).findAllByIdIn(org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("읽을 수 있는 컬렉션 ID로 페이지를 조회해서 응답으로 변환한다") + void getCollections_returnsPagedResponses() { + DocumentCollection collection = CollectionFixture.createCollection(); + CollectionResponse expected = CollectionFixture.createCollectionResponse(); + List readableIds = List.of(collection.getId()); + given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, null)).willReturn(readableIds); + given(collectionRepository.findAllByIdIn(org.mockito.ArgumentMatchers.eq(readableIds), org.mockito.ArgumentMatchers.any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(collection), PageRequest.of(0, 20), 1)); + given(collectionConverter.toResponse(collection)).willReturn(expected); + + PageResponse result = collectionQueryService.getCollections(CollectionFixture.USER_ID, null, 0, 20); + + assertThat(result.content()).containsExactly(expected); + assertThat(result.totalElements()).isEqualTo(1); + } + + @Test + @DisplayName("keyword를 그대로 repository에 전달한다") + void getCollections_passesKeywordToRepository() { + given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, "개발")).willReturn(List.of()); + + collectionQueryService.getCollections(CollectionFixture.USER_ID, "개발", 0, 20); + + then(collectionRepository).should().findReadableCollectionIds(CollectionFixture.USER_ID, "개발"); + } + + @Test + @DisplayName("부모 읽기 권한이 있으면 자식 컬렉션 목록을 반환한다") + void getChildren_returnsResponses_when_parentIsReadable() { + DocumentCollection parent = CollectionFixture.createCollection(); + DocumentCollection child = CollectionFixture.createChildCollection(parent.getOwner(), parent, 2L); + CollectionResponse expected = CollectionFixture.createCollectionResponse(); + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent)); + given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, parent)).willReturn(true); + given(collectionRepository.findAllByParentCollectionIdAndStatus(CollectionFixture.COLLECTION_ID, CollectionStatus.ACTIVE)) + .willReturn(List.of(child)); + given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, child)).willReturn(true); + given(collectionConverter.toResponse(child)).willReturn(expected); + + List result = collectionQueryService.getChildren(CollectionFixture.USER_ID, CollectionFixture.COLLECTION_ID); + + assertThat(result).containsExactly(expected); + } + + @Test + @DisplayName("부모 읽기 권한이 없으면 PERMISSION_DENIED 예외가 발생한다") + void getChildren_throws_when_parentReadIsDenied() { + DocumentCollection parent = CollectionFixture.createCollection(); + Long otherUserId = 99L; + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent)); + given(permissionQueryService.canReadCollection(otherUserId, parent)).willReturn(false); + + assertThatThrownBy(() -> collectionQueryService.getChildren(otherUserId, CollectionFixture.COLLECTION_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + then(collectionRepository).should(never()).findAllByParentCollectionIdAndStatus( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("부모는 읽을 수 있어도 자식은 개별 읽기 권한이 없으면 목록에서 제외된다") + void getChildren_excludesChild_when_childReadIsDenied() { + DocumentCollection parent = CollectionFixture.createCollection(); + DocumentCollection readableChild = CollectionFixture.createChildCollection(parent.getOwner(), parent, 2L); + DocumentCollection deniedChild = CollectionFixture.createChildCollection(parent.getOwner(), parent, 3L); + CollectionResponse expected = CollectionFixture.createCollectionResponse(); + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent)); + given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, parent)).willReturn(true); + given(collectionRepository.findAllByParentCollectionIdAndStatus(CollectionFixture.COLLECTION_ID, CollectionStatus.ACTIVE)) + .willReturn(List.of(readableChild, deniedChild)); + given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, readableChild)).willReturn(true); + given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, deniedChild)).willReturn(false); + given(collectionConverter.toResponse(readableChild)).willReturn(expected); + + List result = collectionQueryService.getChildren(CollectionFixture.USER_ID, CollectionFixture.COLLECTION_ID); + + assertThat(result).containsExactly(expected); + } + @Test @DisplayName("컬렉션 문서 목록은 읽기 가능한 문서만 최신 추가순으로 페이지 반환한다") void getCollectionDocuments_returnsOnlyReadableDocuments() { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java index 797c9f2f..61805712 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java @@ -22,9 +22,20 @@ 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.permission.entity.CollectionPermission; +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; +import com.opensource.docgrid.domain.permission.repository.CollectionPermissionRepository; +import com.opensource.docgrid.domain.user.entity.Department; +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.CommonStatus; import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.DepartmentRepository; +import com.opensource.docgrid.domain.user.repository.RoleRepository; import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; import jakarta.persistence.EntityManager; @@ -43,7 +54,11 @@ class DocumentReadableIdsRepositoryTest { @Autowired private DocumentRepository documentRepository; @Autowired private CollectionRepository collectionRepository; @Autowired private CollectionDocumentRepository collectionDocumentRepository; + @Autowired private CollectionPermissionRepository collectionPermissionRepository; @Autowired private UserRepository userRepository; + @Autowired private DepartmentRepository departmentRepository; + @Autowired private RoleRepository roleRepository; + @Autowired private UserRoleRepository userRoleRepository; @Autowired private EntityManager entityManager; // seed 데이터에 PUBLIC·INDEXED 문서가 있어 모든 사용자에게 조회되므로, 이 테스트가 만든 문서만 검증한다. @@ -94,7 +109,7 @@ void findReadableDocumentIdsInCollection_appliesStatusFilter() { User owner = saveOwner(); Document indexed = saveDocument(owner, DocumentStatus.INDEXED); Document indexing = saveDocument(owner, DocumentStatus.INDEXING); - DocumentCollection collection = saveCollection(owner); + DocumentCollection collection = saveCollection(owner, null); addToCollection(collection, indexed, owner); addToCollection(collection, indexing, owner); flushAndClear(); @@ -110,6 +125,152 @@ void findReadableDocumentIdsInCollection_appliesStatusFilter() { assertThat(withIndexing).containsExactlyInAnyOrder(indexed.getId(), indexing.getId()); } + @Test + @DisplayName("부모 컬렉션에 DEPARTMENT 권한이 있으면 자식 컬렉션의 문서도 목록과 컬렉션 조회에서 함께 조회된다 (상속)") + void findReadableDocumentIds_includesDocumentInChildCollection_whenParentHasDepartmentPermission() { + Department department = saveDepartment(); + User owner = saveOwner(); + User deptMember = saveUserInDepartment(department); + Document document = saveDocument(owner, DocumentStatus.INDEXED); + + DocumentCollection parent = saveCollection(owner, null); + DocumentCollection child = saveCollection(owner, parent); + addToCollection(child, document, owner); + grantDepartmentReadPermission(parent, department, owner); + flushAndClear(); + + List readableIds = documentRepository.findReadableDocumentIds(deptMember.getId(), INDEXED_ONLY); + List readableIdsInChildCollection = documentRepository.findReadableDocumentIdsInCollection( + deptMember.getId(), child.getId(), INDEXED_ONLY + ); + + assertThat(readableIds).contains(document.getId()); + assertThat(readableIdsInChildCollection).containsExactly(document.getId()); + } + + @Test + @DisplayName("컬렉션에 직접 부여된 권한만 있으면(부모 없음) 기존과 동일하게 조회된다 (회귀)") + void findReadableDocumentIds_includesDocument_whenDirectDepartmentPermission_noParent() { + Department department = saveDepartment(); + User owner = saveOwner(); + User deptMember = saveUserInDepartment(department); + Document document = saveDocument(owner, DocumentStatus.INDEXED); + + DocumentCollection collection = saveCollection(owner, null); + addToCollection(collection, document, owner); + grantDepartmentReadPermission(collection, department, owner); + flushAndClear(); + + List readableIds = documentRepository.findReadableDocumentIds(deptMember.getId(), INDEXED_ONLY); + + assertThat(readableIds).contains(document.getId()); + } + + @Test + @DisplayName("부모 컬렉션에 ROLE 권한이 있으면 자식 컬렉션의 문서도 목록과 컬렉션 조회에서 함께 조회된다 (상속)") + void findReadableDocumentIds_includesDocumentInChildCollection_whenParentHasRolePermission() { + Role role = saveRole(); + User owner = saveOwner(); + User roleMember = saveUserWithRole(role); + Document document = saveDocument(owner, DocumentStatus.INDEXED); + + DocumentCollection parent = saveCollection(owner, null); + DocumentCollection child = saveCollection(owner, parent); + addToCollection(child, document, owner); + grantRoleReadPermission(parent, role, owner); + flushAndClear(); + + List readableIds = documentRepository.findReadableDocumentIds(roleMember.getId(), INDEXED_ONLY); + List readableIdsInChildCollection = documentRepository.findReadableDocumentIdsInCollection( + roleMember.getId(), child.getId(), INDEXED_ONLY + ); + + assertThat(readableIds).contains(document.getId()); + assertThat(readableIdsInChildCollection).containsExactly(document.getId()); + } + + private Department saveDepartment() { + return departmentRepository.save( + Department.builder() + .name("읽기 가능 문서 테스트 부서") + .code("RID-DEPT-" + UUID.randomUUID()) + .status(CommonStatus.ACTIVE) + .build() + ); + } + + private User saveUserInDepartment(Department department) { + return userRepository.save( + User.builder() + .department(department) + .email("readable-ids-dept-" + UUID.randomUUID() + "@test.com") + .passwordHash("hash") + .name("읽기 가능 문서 테스트 부서원") + .status(UserStatus.ACTIVE) + .build() + ); + } + + private void grantDepartmentReadPermission(DocumentCollection collection, Department department, User grantedBy) { + collectionPermissionRepository.save( + CollectionPermission.builder() + .collection(collection) + .targetType(PermissionTargetType.DEPARTMENT) + .department(department) + .permissionType(PermissionType.READ) + .canRead(true) + .canWrite(false) + .canAdmin(false) + .grantedBy(grantedBy) + .grantedAt(LocalDateTime.now()) + .build() + ); + } + + private Role saveRole() { + return roleRepository.save( + Role.builder() + .name("읽기 가능 문서 테스트 역할") + .code("RID-ROLE-" + UUID.randomUUID()) + .build() + ); + } + + private User saveUserWithRole(Role role) { + User user = userRepository.save( + User.builder() + .email("readable-ids-role-" + UUID.randomUUID() + "@test.com") + .passwordHash("hash") + .name("읽기 가능 문서 테스트 역할 보유자") + .status(UserStatus.ACTIVE) + .build() + ); + userRoleRepository.save( + UserRole.builder() + .user(user) + .role(role) + .assignedAt(LocalDateTime.now()) + .build() + ); + return user; + } + + private void grantRoleReadPermission(DocumentCollection collection, Role role, User grantedBy) { + collectionPermissionRepository.save( + CollectionPermission.builder() + .collection(collection) + .targetType(PermissionTargetType.ROLE) + .role(role) + .permissionType(PermissionType.READ) + .canRead(true) + .canWrite(false) + .canAdmin(false) + .grantedBy(grantedBy) + .grantedAt(LocalDateTime.now()) + .build() + ); + } + private User saveOwner() { return userRepository.save( User.builder() @@ -134,10 +295,11 @@ private Document saveDocument(User owner, DocumentStatus status) { ); } - private DocumentCollection saveCollection(User owner) { + private DocumentCollection saveCollection(User owner, DocumentCollection parent) { return collectionRepository.save( DocumentCollection.builder() .owner(owner) + .parentCollection(parent) .name("읽기 가능 문서 테스트 컬렉션") .visibility(VisibilityType.PRIVATE) .build() diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java index 775c2d33..2289b9e0 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java @@ -20,17 +20,29 @@ public class PermissionFixture { public static final Long PERMISSION_ID = 30L; public static final Long ROLE_ID = 2L; + public static final Long USER_ROLE_ID = 1L; public static final Long DEPARTMENT_ID = 3L; private PermissionFixture() { } + // 권한 부여 대상으로 쓸 수 있는 role (ADMIN) — USER role은 전원이 보유해 권한 부여 대상이 될 수 없다. public static Role createRole() { + Role role = Role.builder() + .code("ADMIN") + .name("관리자") + .build(); + ReflectionTestUtils.setField(role, "id", ROLE_ID); + return role; + } + + // 권한 부여 거부 케이스 검증용 — 모든 사용자가 기본으로 가진 USER role + public static Role createUserRole() { Role role = Role.builder() .code("USER") .name("일반 사용자") .build(); - ReflectionTestUtils.setField(role, "id", ROLE_ID); + ReflectionTestUtils.setField(role, "id", USER_ROLE_ID); return role; } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java index 8ad004ca..737e0fae 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java @@ -108,6 +108,25 @@ void grantPermission_role_noCacheUpdate() { any(boolean.class), any(boolean.class), any(), any(), any()); } + @Test + @DisplayName("USER role을 대상으로 지정하면 ROLE_NOT_GRANTABLE 예외가 발생한다") + void grantPermission_throws_when_targetRoleIsUserRole() { + User owner = CollectionFixture.createOwner(); + DocumentCollection collection = CollectionFixture.createCollection(owner); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.ROLE, null, PermissionFixture.USER_ROLE_ID, null, PermissionType.READ, null); + + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + given(permissionQueryService.canAdminCollection(CollectionFixture.USER_ID, collection)).willReturn(true); + given(roleRepository.findById(PermissionFixture.USER_ROLE_ID)).willReturn(Optional.of(PermissionFixture.createUserRole())); + + assertThatThrownBy(() -> service.grantPermission( + CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROLE_NOT_GRANTABLE); + then(collectionPermissionRepository).should(never()).save(any(CollectionPermission.class)); + } + @Test @DisplayName("컬렉션이 없으면 COLLECTION_NOT_FOUND 예외가 발생한다") void grantPermission_throws_when_collectionNotFound() { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java index 6695a272..842df77a 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java @@ -93,6 +93,25 @@ void grantPermission_role_noCacheUpdate() { any(boolean.class), any(boolean.class), any(), any(), any()); } + @Test + @DisplayName("USER role을 대상으로 지정하면 ROLE_NOT_GRANTABLE 예외가 발생한다") + void grantPermission_throws_when_targetRoleIsUserRole() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.ROLE, null, PermissionFixture.USER_ROLE_ID, null, PermissionType.READ, null); + + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + given(permissionQueryService.canAdminDocument(CollectionFixture.USER_ID, CollectionFixture.DOCUMENT_ID)).willReturn(true); + given(roleRepository.findById(PermissionFixture.USER_ROLE_ID)).willReturn(Optional.of(PermissionFixture.createUserRole())); + + assertThatThrownBy(() -> service.grantPermission( + CollectionFixture.DOCUMENT_ID, CollectionFixture.USER_ID, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROLE_NOT_GRANTABLE); + then(documentPermissionRepository).should(never()).save(any(DocumentPermission.class)); + } + @Test @DisplayName("문서가 없으면 DOCUMENT_NOT_FOUND 예외가 발생한다") void grantPermission_throws_when_documentNotFound() { 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 3fad9a81..8b82b4d0 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 @@ -131,6 +131,27 @@ void canReadDocument_deptLive_returnsTrue() { assertThat(result).isTrue(); } + @Test + @DisplayName("부모 컬렉션에만 ROLE/DEPARTMENT 권한이 있어도 canReadDocument가 true다 (상속)") + void canReadDocument_inheritedFromParentCollection_returnsTrue() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + Long otherUserId = 99L; + List effectiveCollectionIds = List.of(1L, 2L); + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + given(cacheRepository.existsValidReadCache(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(documentPermissionRepository.existsRoleReadPermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionPermissionRepository.existsRoleReadPermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(documentPermissionRepository.existsDeptReadPermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionPermissionRepository.existsDeptReadPermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionRepository.findEffectiveCollectionIdsForDocument(CollectionFixture.DOCUMENT_ID)).willReturn(effectiveCollectionIds); + given(collectionPermissionRepository.existsDeptReadPermissionForCollections(otherUserId, effectiveCollectionIds)).willReturn(true); + + boolean result = service.canReadDocument(otherUserId, CollectionFixture.DOCUMENT_ID); + + assertThat(result).isTrue(); + } + @Test @DisplayName("모든 단계를 통과하지 못하면 canReadDocument가 false다") void canReadDocument_noPermission_returnsFalse() { @@ -222,6 +243,27 @@ void canWriteDocument_deptLive_returnsTrue() { assertThat(result).isTrue(); } + @Test + @DisplayName("부모 컬렉션에만 ROLE/DEPARTMENT 권한이 있어도 canWriteDocument가 true다 (상속)") + void canWriteDocument_inheritedFromParentCollection_returnsTrue() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + Long otherUserId = 99L; + List effectiveCollectionIds = List.of(1L, 2L); + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + given(cacheRepository.existsValidWriteCache(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(documentPermissionRepository.existsRoleWritePermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionPermissionRepository.existsRoleWritePermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(documentPermissionRepository.existsDeptWritePermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionPermissionRepository.existsDeptWritePermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionRepository.findEffectiveCollectionIdsForDocument(CollectionFixture.DOCUMENT_ID)).willReturn(effectiveCollectionIds); + given(collectionPermissionRepository.existsDeptWritePermissionForCollections(otherUserId, effectiveCollectionIds)).willReturn(true); + + boolean result = service.canWriteDocument(otherUserId, CollectionFixture.DOCUMENT_ID); + + assertThat(result).isTrue(); + } + @Test @DisplayName("모든 단계를 통과하지 못하면 canWriteDocument가 false다") void canWriteDocument_noPermission_returnsFalse() { @@ -303,6 +345,27 @@ void canAdminDocument_deptLive_returnsTrue() { assertThat(result).isTrue(); } + @Test + @DisplayName("부모 컬렉션에만 ROLE/DEPARTMENT 권한이 있어도 canAdminDocument가 true다 (상속)") + void canAdminDocument_inheritedFromParentCollection_returnsTrue() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + Long otherUserId = 99L; + List effectiveCollectionIds = List.of(1L, 2L); + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + given(cacheRepository.existsValidAdminCache(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(documentPermissionRepository.existsRoleAdminPermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionPermissionRepository.existsRoleAdminPermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(documentPermissionRepository.existsDeptAdminPermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionPermissionRepository.existsDeptAdminPermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false); + given(collectionRepository.findEffectiveCollectionIdsForDocument(CollectionFixture.DOCUMENT_ID)).willReturn(effectiveCollectionIds); + given(collectionPermissionRepository.existsDeptAdminPermissionForCollections(otherUserId, effectiveCollectionIds)).willReturn(true); + + boolean result = service.canAdminDocument(otherUserId, CollectionFixture.DOCUMENT_ID); + + assertThat(result).isTrue(); + } + @Test @DisplayName("모든 단계를 통과하지 못하면 canAdminDocument가 false다") void canAdminDocument_noPermission_returnsFalse() { @@ -402,6 +465,29 @@ void canReadCollection_deptLive_returnsTrue() { assertThat(result).isTrue(); } + @Test + @DisplayName("조상 컬렉션에만 DEPARTMENT 권한이 있어도 canReadCollection이 true다 (상속)") + void canReadCollection_inheritedFromAncestor_returnsTrue() { + User owner = CollectionFixture.createOwner(); + Long otherUserId = 99L; + List ancestorIds = List.of(CollectionFixture.COLLECTION_ID, 1L); + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)) + .willReturn(Optional.of(CollectionFixture.createCollection(owner))); + given(collectionPermissionRepository.existsUserReadPermission(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionPermissionRepository.existsRoleReadPermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionPermissionRepository.existsDeptReadPermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionRepository.findAncestorIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(ancestorIds); + given(collectionPermissionRepository.existsRoleReadPermissionForCollections(otherUserId, ancestorIds)).willReturn(false); + given(collectionPermissionRepository.existsDeptReadPermissionForCollections(otherUserId, ancestorIds)).willReturn(true); + + boolean result = service.canReadCollection(otherUserId, CollectionFixture.COLLECTION_ID); + + assertThat(result).isTrue(); + } + @Test @DisplayName("권한이 없으면 canReadCollection이 false다") void canReadCollection_noPermission_returnsFalse() { @@ -522,6 +608,29 @@ void canWriteCollection_deptLive_returnsTrue() { assertThat(result).isTrue(); } + @Test + @DisplayName("조상 컬렉션에만 DEPARTMENT 권한이 있어도 canWriteCollection이 true다 (상속)") + void canWriteCollection_inheritedFromAncestor_returnsTrue() { + User owner = CollectionFixture.createOwner(); + Long otherUserId = 99L; + List ancestorIds = List.of(CollectionFixture.COLLECTION_ID, 1L); + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)) + .willReturn(Optional.of(CollectionFixture.createCollection(owner))); + given(collectionPermissionRepository.existsUserWritePermission(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionPermissionRepository.existsRoleWritePermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionPermissionRepository.existsDeptWritePermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionRepository.findAncestorIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(ancestorIds); + given(collectionPermissionRepository.existsRoleWritePermissionForCollections(otherUserId, ancestorIds)).willReturn(false); + given(collectionPermissionRepository.existsDeptWritePermissionForCollections(otherUserId, ancestorIds)).willReturn(true); + + boolean result = service.canWriteCollection(otherUserId, CollectionFixture.COLLECTION_ID); + + assertThat(result).isTrue(); + } + @Test @DisplayName("권한이 없으면 canWriteCollection이 false다") void canWriteCollection_noPermission_returnsFalse() { @@ -630,6 +739,24 @@ void checkDocumentPermission_dept_returnsPermissionWithDeptSource() { assertThat(result.sources()).containsExactly(PermissionSourceType.DEPARTMENT); } + @Test + @DisplayName("부모 컬렉션에만 DEPARTMENT 권한이 있어도 canRead가 true이고 sources에 DEPARTMENT가 포함된다 (상속)") + void checkDocumentPermission_inheritedFromParentCollection_returnsReadTrueWithDeptSource() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + Long otherUserId = 99L; + List effectiveCollectionIds = List.of(1L, 2L); + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + given(collectionRepository.findEffectiveCollectionIdsForDocument(CollectionFixture.DOCUMENT_ID)).willReturn(effectiveCollectionIds); + given(collectionPermissionRepository.existsDeptReadPermissionForCollections(otherUserId, effectiveCollectionIds)).willReturn(true); + + DocumentPermissionSummaryResponse result = + service.checkDocumentPermission(otherUserId, CollectionFixture.DOCUMENT_ID); + + assertThat(result.canRead()).isTrue(); + assertThat(result.sources()).containsExactly(PermissionSourceType.DEPARTMENT); + } + @Test @DisplayName("ROLE과 USER_CACHE가 동시에 충족되면 sources에 둘 다 포함된다") void checkDocumentPermission_multipleSource_returnsBothSources() { @@ -743,6 +870,29 @@ void canAdminCollection_deptLive_returnsTrue() { assertThat(result).isTrue(); } + @Test + @DisplayName("조상 컬렉션에만 DEPARTMENT 권한이 있어도 canAdminCollection이 true다 (상속)") + void canAdminCollection_inheritedFromAncestor_returnsTrue() { + User owner = CollectionFixture.createOwner(); + Long otherUserId = 99L; + List ancestorIds = List.of(CollectionFixture.COLLECTION_ID, 1L); + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)) + .willReturn(Optional.of(CollectionFixture.createCollection(owner))); + given(collectionPermissionRepository.existsUserAdminPermission(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionPermissionRepository.existsRoleAdminPermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionPermissionRepository.existsDeptAdminPermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID)) + .willReturn(false); + given(collectionRepository.findAncestorIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(ancestorIds); + given(collectionPermissionRepository.existsRoleAdminPermissionForCollections(otherUserId, ancestorIds)).willReturn(false); + given(collectionPermissionRepository.existsDeptAdminPermissionForCollections(otherUserId, ancestorIds)).willReturn(true); + + boolean result = service.canAdminCollection(otherUserId, CollectionFixture.COLLECTION_ID); + + assertThat(result).isTrue(); + } + @Test @DisplayName("권한이 없으면 canAdminCollection이 false다") void canAdminCollection_noPermission_returnsFalse() { diff --git a/docs/design/kangcheolung-#16-collection-crud.md b/docs/design/kangcheolung-#16-collection-crud.md index ef0f7b42..9c422aab 100644 --- a/docs/design/kangcheolung-#16-collection-crud.md +++ b/docs/design/kangcheolung-#16-collection-crud.md @@ -24,7 +24,7 @@ CollectionController.createCollection() │ ▼ CollectionCommandService.createCollection() - ├─ parentCollectionId 있으면 상위 컬렉션 존재 확인 + ├─ parentCollectionId 있으면 상위 컬렉션 존재 확인 (+ #229부터 쓰기권한 확인도 추가됨) ├─ visibility 미입력 시 PRIVATE 기본값 적용 └─ DocumentCollection 저장 (status=ACTIVE, owner=요청자) │ @@ -99,7 +99,7 @@ public class DocumentCollection extends BaseEntity { ``` - `visibility`는 `document` 도메인의 `VisibilityType`(`PRIVATE`/`COLLECTION`/`DEPARTMENT`/`PUBLIC`)을 그대로 재사용한다 — 문서와 컬렉션이 같은 공개범위 개념을 공유하므로 별도 enum을 새로 만들지 않았다. -- `parentCollection`이 self-FK라 컬렉션 트리(폴더 계층) 구조를 표현할 수 있지만, 이번 이슈에서는 "생성 시 상위 컬렉션 존재 확인" 정도만 쓰고 트리 순회 API는 만들지 않았다. +- ~~`parentCollection`이 self-FK라 컬렉션 트리(폴더 계층) 구조를 표현할 수 있지만, 이번 이슈에서는 "생성 시 상위 컬렉션 존재 확인" 정도만 쓰고 트리 순회 API는 만들지 않았다.~~ → **#229에서 실제로 구현됨**: `GET /collections/{id}/children`(직계 자식 조회), 생성 시 부모 쓰기권한 체크, 부모→자식 권한 상속, cascade 삭제까지 전부 추가됨. 아래 "이후 업데이트" 절 참고. - 삭제는 `markDeleted()`로 `status`/`deletedAt`만 바꾸는 soft delete다 (`#29`에서 실제로 호출). ### 2. `domain/collection/entity/CollectionDocument.java` @@ -317,6 +317,15 @@ BUILD SUCCESSFUL - `CollectionStatus.ARCHIVED`는 정의만 되어 있고 전환 로직이 없다. - ~~`CollectionPermission`/`DocumentPermission` 엔티티의 Javadoc에 이미 명시된 TODO: `target_type`별로 단일 FK만 채워져야 한다는 규칙이 DB CHECK 제약으로 강제되지 않고 애플리케이션 검증(`validateTargetType()`, `#18`)에만 의존한다.~~ → 확인 결과 이미 해결되어 있음: `V11__create_collection_permissions.sql`/`V12__create_document_permissions.sql`에 `CHECK` 제약이 반영되어 있다(엔티티 Javadoc만 갱신되지 않은 상태였음). +## 이후 업데이트 (2026-08-18, 이슈 #229 — 컬렉션 트리) + +수동 QA(시나리오 4) 중 `parentCollectionId`가 스키마에만 있고 실제로 死코드라는 게 재발견되어, 이슈 #229에서 실제 트리 기능으로 완성했다. 상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고. 이 문서와 직접 관련된 변경만 요약: + +- `createCollection()`에 부모 컬렉션 **쓰기권한 체크**(`canWriteCollection(parent)`) 추가 — 예전엔 부모 존재 여부만 확인해서, 남의 컬렉션 밑에도 마음대로 자식을 매달 수 있는 버그였다. +- `CollectionRepository`에 `findAllByParentCollectionIdAndStatus`(직계 자식 조회) 신규. +- `CollectionQueryService`에 `getChildren()` 신규, `GET /collections/{id}/children` 엔드포인트 추가. +- 순환 참조 방지 로직은 만들지 않았다 — 컬렉션 이동/수정 API가 없어 생성 시점에만 부모를 지정할 수 있고, 존재하지 않는 컬렉션은 자기 자신의 조상이 될 수 없으므로 현재 API 구조상 순환 참조가 원천적으로 불가능하기 때문(검토 완료). + ## 다음 단계 -`#18`(권한 부여/회수), `#21`(PermissionQueryService), `#24`(문서 권한 확인 API), `#29`(컬렉션 관리 API — 목록/삭제/문서 제거)로 이어진다. +`#18`(권한 부여/회수), `#21`(PermissionQueryService), `#24`(문서 권한 확인 API), `#29`(컬렉션 관리 API — 목록/삭제/문서 제거)로 이어진다. 이후 `#229`(컬렉션 트리 — 하위 컬렉션 지원)로 이어진다. diff --git a/docs/design/kangcheolung-#18-permission-grant-revoke.md b/docs/design/kangcheolung-#18-permission-grant-revoke.md index 988715ac..0111f177 100644 --- a/docs/design/kangcheolung-#18-permission-grant-revoke.md +++ b/docs/design/kangcheolung-#18-permission-grant-revoke.md @@ -172,6 +172,18 @@ private void validateTargetType(GrantPermissionRequest request) { ``` `targetType`에 안 맞는 ID 필드 조합(예: `USER`인데 `roleId`도 같이 옴)을 400으로 걸러낸다 — 위에서 언급한 "DB CHECK 제약 대신 애플리케이션 검증"이 바로 이 메서드다. +**(2026-08-18 추가, 관련 작업)** `validateTargetType()` 통과 후 role을 조회하는 지점에 가드가 하나 더 생겼다: +```java +} else if (request.targetType() == PermissionTargetType.ROLE) { + targetRole = roleRepository.findById(request.roleId()) + .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + if ("USER".equals(targetRole.getCode())) { + throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE); + } +} +``` +`USER` role은 가입 시 전원에게 자동 부여되는 기본 role이라, 이걸 대상으로 권한을 부여하면 그룹핑 의미 없이 사실상 전체 공개(`visibility=PUBLIC`보다도 넓은 범위 — PUBLIC은 READ만 열지만 이 경로로는 WRITE/ADMIN도 전체에 열림)가 되는 위험한 함정이었다. `ErrorCode.ROLE_NOT_GRANTABLE`(`PERMISSION-004`, 400)로 차단한다. 자세한 배경은 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고. + ```java // 컬렉션 권한(USER)을 부여하면 컬렉션 소속 문서 전체에 캐시를 일괄 갱신한다 (N+1 방지) private void updateCacheForCollection(Long collectionId, User targetUser, boolean[] permissions, @@ -317,6 +329,7 @@ BUILD SUCCESSFUL | 대상 사용자/역할/부서 없음 | 404/400 | `USER-001` / `ROLE-001` / `DEPT-001` | | `targetType`-ID 필드 조합 오류 | 400 | `PERMISSION-001` | | 부여자가 ADMIN 권한 없음 | 403 | `ROLE-002`(`PERMISSION_DENIED`) | +| (2026-08-18 추가) ROLE 대상이 `USER` role임 | 400 | `PERMISSION-004`(`ROLE_NOT_GRANTABLE`) | --- @@ -342,6 +355,10 @@ BUILD SUCCESSFUL - `AccessSourceType.OWNER`가 정의만 되어 있고 실제로 생성되지 않는다(위 "확인된 불일치" 참고) — enum에서 제거하거나, 실제로 OWNER 캐시를 생성하도록 코드를 맞추거나 둘 중 하나로 정리가 필요하다. - ~~`PermissionController`의 컬렉션/문서 권한 부여·회수 4개 엔드포인트 Swagger description이 "소유자(owner)만 가능"이라고 적혀 있던 문제~~ → 코드리뷰로 발견해 실제 인가 규칙(`canAdminCollection()`/`canAdminDocument()`, ADMIN 위임자도 허용)에 맞게 4곳 모두 "ADMIN 권한 보유자(소유자 포함)"로 수정 완료. +## 이후 업데이트 (2026-08-18, 이슈 #229 관련 작업) + +이슈 #229(컬렉션 트리) QA 중 발견한 별개의 보안 개선 — 위 `validateTargetType()` 절에 이미 반영. 상세 배경은 `docs/design/kangcheolung-#229-collection-tree.md` 참고. + ## 다음 단계 -`#21`(`PermissionQueryService` — 이 이슈에서 만든 권한 데이터를 실제로 판단하는 서비스), `#24`(문서 권한 확인 API), `#29`(컬렉션 관리 API)로 이어진다. +`#21`(`PermissionQueryService` — 이 이슈에서 만든 권한 데이터를 실제로 판단하는 서비스), `#24`(문서 권한 확인 API), `#29`(컬렉션 관리 API)로 이어진다. 이후 `#229`(컬렉션 트리)로 이어진다. diff --git a/docs/design/kangcheolung-#21-permission-query-service.md b/docs/design/kangcheolung-#21-permission-query-service.md index 7fc116f5..69aeba66 100644 --- a/docs/design/kangcheolung-#21-permission-query-service.md +++ b/docs/design/kangcheolung-#21-permission-query-service.md @@ -36,7 +36,7 @@ DEPT 권한 → 매 요청마다 live 조회 (JOIN 여러 번) 이 이슈의 핵심 파일이며, 6개의 권한 판정 그룹(`canReadDocument`/`canWriteDocument`/`canAdminDocument`/`canReadCollection`/`canWriteCollection`/`canAdminCollection`, `canReadCollection`은 이후 리팩토링에서 추가됨 — 아래 참고)을 제공한다. 컬렉션 대상 3종은 각각 ID로 조회하는 버전과, 이미 조회된 `DocumentCollection` 엔티티를 받는 버전 2개씩 오버로드로 제공해서 — 문서 3종(오버로드 없음) + 컬렉션 3종(오버로드 2개씩)으로 공개 메서드 시그니처는 총 9개다. 호출부가 이미 엔티티를 들고 있으면 중복 조회 없이 엔티티 버전을 바로 쓸 수 있다. -#### `canReadDocument` (5단계) +#### `canReadDocument` (5단계 → **2026-08-18부터 6단계**, 아래 참고) ```java public boolean canReadDocument(Long userId, Long documentId) { @@ -72,9 +72,21 @@ public boolean canReadDocument(Long userId, Long documentId) { **4/5단계가 매번 OR로 문서 직접 권한과 컬렉션 경유 권한을 같이 조회하는 이유**: 기본 권한은 컬렉션 단위(`#16`)로 부여되므로, "이 문서가 속한 컬렉션에 ROLE 권한이 있는지"도 확인해야 한다. `collectionPermissionRepository.existsRoleReadPermissionForDocument()`가 `CollectionPermission JOIN CollectionDocument`로 이걸 처리한다(아래 리포지토리 절 참고). -#### `canWriteDocument` / `canAdminDocument` (각 4단계) +**(2026-08-18 추가, 이슈 #229) 6단계: 부모 컬렉션 체인 상속**. 5단계까지 통과 못 하면, 이 문서가 속한 컬렉션(들)의 **조상 컬렉션**에 ROLE/DEPARTMENT 권한이 있는지까지 확인한다: +```java +// 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) +List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); +if (!effectiveCollectionIds.isEmpty() + && (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds) + || collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds))) { + return true; +} +``` +`findEffectiveCollectionIdsForDocument()`는 `WITH RECURSIVE`로 "이 문서가 속한 컬렉션 전체(N:M) + 그 각각의 조상 전체"를 한 번에 구하는 native 쿼리다. 상세 설계는 `docs/design/kangcheolung-#229-collection-tree.md` 참고. `canWriteDocument`/`canAdminDocument`/`checkDocumentPermission`(`#24`)에도 동일하게 6단계가 추가됐다 — 기존 5단계 로직은 한 줄도 안 건드리고 끝에 새 블록만 이어붙이는 방식으로 넣었다(기존 `PermissionQueryServiceTest` 무변경 통과 확인됨). -`canReadDocument`와 거의 같은 구조이지만 **PUBLIC 단계가 없다** — PUBLIC은 읽기만 허용하는 개념이라 쓰기/관리 권한 판단에는 끼어들 자리가 없다. 그래서 1단계(소유자) → 2단계(USER 캐시) → 3단계(ROLE) → 4단계(DEPT), 총 4단계다. +#### `canWriteDocument` / `canAdminDocument` (각 4단계 → **2026-08-18부터 5단계**) + +`canReadDocument`와 거의 같은 구조이지만 **PUBLIC 단계가 없다** — PUBLIC은 읽기만 허용하는 개념이라 쓰기/관리 권한 판단에는 끼어들 자리가 없다. 그래서 1단계(소유자) → 2단계(USER 캐시) → 3단계(ROLE) → 4단계(DEPT), 총 4단계였다. 여기에도 위와 동일한 "5단계: 부모 컬렉션 체인 상속"이 이슈 #229에서 추가됐다(`existsRoleWrite/AdminPermissionForCollections`, `existsDeptWrite/AdminPermissionForCollections` 사용). #### `canReadCollection` / `canWriteCollection` / `canAdminCollection` @@ -90,7 +102,12 @@ public boolean canWriteCollection(Long userId, DocumentCollection collection) { Long collectionId = collection.getId(); if (collectionPermissionRepository.existsUserWritePermission(userId, collectionId)) return true; if (collectionPermissionRepository.existsRoleWritePermissionForCollection(userId, collectionId)) return true; - return collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId); + if (collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId)) return true; + + // (2026-08-18 추가, 이슈 #229) 부모 컬렉션 체인 상속 — 조상 ID 전체를 대상으로 재검사 + List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId); + if (collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, ancestorIds)) return true; + return collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, ancestorIds); } private DocumentCollection getActiveCollection(Long collectionId) { @@ -101,7 +118,7 @@ private DocumentCollection getActiveCollection(Long collectionId) { ``` `canReadCollection`은 위와 동일한 구조에 **PUBLIC 단계**만 추가된다(`canReadDocument`의 2단계와 동일하게, `collection.getVisibility() == VisibilityType.PUBLIC`이면 소유자/권한 여부와 무관하게 허용). `canAdminCollection`도 같은 뼈대(대상 권한 종류만 다름)다. -컬렉션 판단에는 **캐시가 없다** — `user_document_access_cache`는 문서 단위 캐시라 컬렉션 자체에 대한 캐시 개념이 없다. 그래서 OWNER, (READ의 경우 PUBLIC), USER 직접 권한, ROLE, DEPT를 매번 순서대로 live 조회한다. `#16`의 `addDocument()`/`getCollection()`, `#18`의 `grantPermission()`이 엔티티 버전을 호출해 중복 조회 없이 이 메서드들을 재사용한다. +컬렉션 판단에는 **캐시가 없다** — `user_document_access_cache`는 문서 단위 캐시라 컬렉션 자체에 대한 캐시 개념이 없다. 그래서 OWNER, (READ의 경우 PUBLIC), USER 직접 권한, ROLE, DEPT, (2026-08-18부터) 부모 컬렉션 상속을 매번 순서대로 live 조회한다. `#16`의 `addDocument()`/`getCollection()`, `#18`의 `grantPermission()`이 엔티티 버전을 호출해 중복 조회 없이 이 메서드들을 재사용한다. ### `domain/permission/repository/CollectionPermissionRepository.java` — 문서→컬렉션 경유 JOIN 쿼리 @@ -121,6 +138,8 @@ boolean existsRoleReadPermissionForDocument(@Param("userId") Long userId, @Param ``` `CollectionPermission → CollectionDocument → 대상 문서` 경로를 JOIN 하나로 처리한다 — "이 문서가 속한 어떤 컬렉션에, 이 사용자가 속한 역할에 대한 READ 권한이 있는가"를 SQL 레벨에서 한 번에 판단한다. 문서 판단(`*ForDocument` 접미사가 붙은 메서드)과 컬렉션 자체 판단(접미사 없는 `*ForCollection` 메서드) 총 12개의 `existsXxx` 메서드가 이 리포지토리에 있다. +**(2026-08-18 추가, 이슈 #229)** 여기에 `existsRole/DeptRead/Write/AdminPermissionFor**Collections**`(복수형, `List collectionIds`를 받는 버전) 6개가 더 생겨서 총 18개가 됐다. 단일 ID(`Long`)를 받는 기존 12개는 시그니처를 그대로 유지했고(치환하지 않음), 신규 6개를 "조상 ID 리스트"를 받는 용도로 추가만 했다 — 기존 12개를 치환했다면 이 메서드들을 스텁하는 기존 테스트(`PermissionQueryServiceTest` 등)가 대량으로 깨졌을 것이라, 추가(append) 방식으로 리스크를 낮췄다. + ### `domain/permission/repository/DocumentPermissionRepository.java` — 문서 직접 권한 JOIN `CollectionPermissionRepository`의 `*ForDocument` 메서드들과 대응하지만, 컬렉션 경유 없이 `document_permissions`를 바로 조회한다(ROLE/DEPT 조합 6개). @@ -172,7 +191,7 @@ if (cacheRepository.existsValidReadCache(...)) { ... } $ ./gradlew test --tests "*PermissionQueryServiceTest*" BUILD SUCCESSFUL ``` -`PermissionQueryServiceTest` 44개 모두 통과(현재 기준 재검증) — 이 서비스의 권한 판정 그룹 6개(`canReadDocument`, `canWriteDocument`, `canAdminDocument`, `canReadCollection`, `canWriteCollection`, `canAdminCollection`, 오버로드 포함 공개 메서드 시그니처 9개) 각각의 단계별 분기를 검증하는 테스트가 다수 포함되어 있다. +`PermissionQueryServiceTest` 54개 모두 통과(2026-08-18 기준 재검증, 이슈 #229에서 부모 컬렉션 상속 케이스 7개 추가돼 47→54) — 이 서비스의 권한 판정 그룹 6개(`canReadDocument`, `canWriteDocument`, `canAdminDocument`, `canReadCollection`, `canWriteCollection`, `canAdminCollection`, 오버로드 포함 공개 메서드 시그니처 9개) 각각의 단계별 분기를 검증하는 테스트가 다수 포함되어 있다. --- @@ -204,6 +223,17 @@ BUILD SUCCESSFUL - 나노초 기반 성능 로그가 프로덕션에서도 항상 `log.info()`로 남는다 — 트래픽이 많아지면 로그량 자체가 부담일 수 있어 로그 레벨 조정이나 샘플링이 필요할 수 있다. - `canReadDocument`류 메서드들 사이에 문서 조회(`documentRepository.findById`)가 메서드마다 중복된다 — 셋 다 필요하면(예: `checkDocumentPermission`처럼) 문서 조회를 한 번만 하고 넘기는 내부 메서드로 리팩터링할 여지가 있다. **(참고)** 컬렉션 쪽(`canReadCollection`/`canWriteCollection`/`canAdminCollection`)은 이미 "ID 버전 + 엔티티 버전" 오버로드로 이 문제를 해결했다 — 문서 쪽도 같은 패턴을 그대로 적용하면 된다(별도 후속 작업으로 분리, 이번 라운드는 컬렉션만 처리). +## 이후 업데이트 (2026-08-18, 이슈 #229 — 컬렉션 트리) + +컬렉션 트리 기능이 추가되면서 6개 권한 판정 그룹 전부에 "부모 컬렉션 체인 상속" 단계가 하나씩 더 생겼다(위 각 절에 인라인으로 반영). 핵심 요약: + +- 문서 판단 4종(`canReadDocument`/`canWriteDocument`/`canAdminDocument`/`checkDocumentPermission`)은 `CollectionRepository.findEffectiveCollectionIdsForDocument()`(문서가 속한 컬렉션+그 조상 전체)를 마지막 단계로 추가. +- 컬렉션 판단 3종(`canReadCollection`/`canWriteCollection`/`canAdminCollection`)은 `CollectionRepository.findAncestorIdsInclusive()`(자기 자신+조상 전체)를 마지막 단계로 추가. +- 전부 **기존 로직은 안 건드리고 끝에 새 단계만 이어붙이는 방식**으로 넣었다 — 대규모 기존 테스트(`PermissionQueryServiceTest` 817줄, 원래 47개 케이스)를 한 줄도 안 고치고 그대로 통과시키기 위한 선택. +- 컬렉션 목록(`GET /collections`)도 이번에 owner-only에서 "읽을 수 있는 전체"로 넓어졌는데, 그건 `PermissionQueryService`가 아니라 `CollectionRepository.findReadableCollectionIds()`라는 별도 native 쿼리로 구현했다 — 6개 판정 그룹과는 별개 경로다. + +상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고. + ## 다음 단계 -`#24`(문서 권한 확인 API — 이 서비스의 `checkDocumentPermission()`을 노출), `#29`(컬렉션 관리 API)로 이어진다. 이후 RAG 블록의 `AccessibleDocumentQueryService`(권한 pre-filter)와 `SearchFacade`(live check)가 이 서비스의 `canReadDocument()`를 그대로 재사용한다. +`#24`(문서 권한 확인 API — 이 서비스의 `checkDocumentPermission()`을 노출), `#29`(컬렉션 관리 API)로 이어진다. 이후 RAG 블록의 `AccessibleDocumentQueryService`(권한 pre-filter)와 `SearchFacade`(live check)가 이 서비스의 `canReadDocument()`를 그대로 재사용한다. 이후 `#229`(컬렉션 트리)로 이어진다. diff --git a/docs/design/kangcheolung-#229-collection-tree.md b/docs/design/kangcheolung-#229-collection-tree.md new file mode 100644 index 00000000..9514c383 --- /dev/null +++ b/docs/design/kangcheolung-#229-collection-tree.md @@ -0,0 +1,383 @@ +# #229 컬렉션 트리(하위 컬렉션) 지원 + +closes #229 + +--- + +## 배경 + +`DocumentCollection.parentCollection`(self-FK)은 `#16`(컬렉션 CRUD) 때부터 스키마·엔티티·DTO에 존재했지만, `#16` 설계 문서에 이미 명시된 대로 "생성 시 상위 컬렉션 존재 확인" 정도만 쓰고 트리 순회 API는 만들지 않은 상태였다(Simplicity First — 당장 필요 없는 API를 미리 만들지 않음). + +시나리오 5(권한 부여·회수) 수동 QA를 시작하려면 먼저 컬렉션/문서가 있어야 해서 시나리오 4(컬렉션 CRUD)부터 다시 훑다가, `parentCollectionId`가 실질적으로 死코드라는 걸 재발견했다: + +- 자식 컬렉션 조회 API가 없어서, 생성 시 부모를 지정해도 나중에 그 관계를 확인할 방법이 없었다. +- `createCollection()`이 부모 컬렉션의 **존재 여부만** 확인하고 **권한은 확인하지 않아서**, 남의 컬렉션 밑에도 마음대로 자식을 매달 수 있는 버그가 있었다. +- 부모 컬렉션에 준 권한이 자식 컬렉션·문서에 상속되지 않았다. +- 컬렉션 삭제가 자기 자신만 지우고 하위 컬렉션은 고아로 남겼다. +- 프론트(`CollectionsPage.tsx`)도 생성 시 `parentCollectionId`를 항상 `null`로 고정 전송하고 있어 UI로는 절대 하위 컬렉션을 만들 수 없었다. + +`#226`(역할 회수 API)과 같은 패턴 — "QA 중 발견 → 근본 원인 파악 → 실제로 고침" — 으로 이 이슈를 만들어 실제 트리 기능을 완성했다. 목표는 파인더/구글드라이브 폴더와 동일한 동작: + +- **탐색**: 파인더 방식 — 클릭(하위 조회 API 호출)해야 그 안이 보임 (전체 트리를 한 번에 안 내려줌) +- **권한**: 구글드라이브 공유폴더 방식 — 부모 컬렉션에 준 DEPARTMENT/ROLE 권한이 자식 컬렉션·문서까지 자동 상속. **문서 단건 조회뿐 아니라 문서 목록·검색 결과에도 동일하게 반영**되도록 스코프를 넓혔다(아래 "왜 목록/검색까지 포함했나" 참고). +- **삭제**: 실제 폴더 방식 — 부모를 지우면 하위 전체가 cascade soft delete + +--- + +## 왜 목록/검색까지 포함했나 (스코프 결정) + +권한 상속을 문서 단건 조회(`PermissionQueryService`)에만 넣으면, "권한은 있는데 폴더를 열거나 검색하면 안 보이는" 모순이 생긴다 — 문서 ID를 직접 알아야만 접근 가능하고, 사람들이 실제로 문서를 찾는 방식(폴더 탐색, 검색)으로는 못 찾는 상태가 된다. 그래서 `DocumentRepository`의 목록/검색 pre-filter native 쿼리 2개까지 이번 스코프에 포함시켰다(사용자가 명시적으로 선택). + +--- + +## 전체 흐름 + +```text +POST /collections (parentCollectionId 지정) + │ + ▼ +CollectionCommandService.createCollection() + ├─ 부모 컬렉션 존재 확인 (기존과 동일) + └─ (신규) 부모 컬렉션 쓰기권한 확인 → 없으면 403 + │ + ▼ +GET /collections/{id}/children (신규) + │ + ▼ +CollectionQueryService.getChildren() + ├─ 부모 읽기권한 확인 + ├─ 직계 자식만 조회 (손자는 안 섞임) + └─ 자식마다 개별 읽기권한 재확인 (자식 owner/visibility가 부모와 다를 수 있음) + +DELETE /collections/{id} (cascade로 확장) + │ + ▼ +CollectionCommandService.deleteCollection() + ├─ root owner 1회만 확인 (하위는 재확인 안 함) + ├─ 자기 자신 + 모든 후손 ID 재귀 조회 + ├─ 대상 전체의 권한 삭제 + USER 캐시 무효화 + ├─ 대상 전체의 문서 매핑(collection_documents) 삭제 (신규 — 예전엔 안 지웠음) + └─ 대상 전체 soft delete + +문서/컬렉션 권한 판단 (PermissionQueryService, DocumentRepository) + │ + ▼ +기존 단계 전부 통과 못하면 + └─ (신규) 마지막 단계: 부모 컬렉션 체인 상속 확인 +``` + +--- + +## 신규/변경 파일 + +### 1. `domain/collection/repository/CollectionRepository.java` — 재귀 쿼리 4개 + 자식 조회 + +```java +// 직계 자식 컬렉션 목록 조회 (GET /collections/{id}/children) +List findAllByParentCollectionIdAndStatus(Long parentCollectionId, CollectionStatus status); + +/** + * 자기 자신 + 모든 조상 컬렉션 ID (권한 상속 판단용). + * 삭제된 조상도 결과에 포함한다 — 삭제된 컬렉션은 권한이 비어있어 무해하고, + * status 필터를 넣으면 중간 조상이 삭제됐을 때 그 위 조상으로 체인이 끊기는 문제가 생긴다. + */ +@Query(value = """ + WITH RECURSIVE ancestors AS ( + SELECT id, parent_collection_id FROM collections WHERE id = :collectionId + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN ancestors a ON c.id = a.parent_collection_id + ) + SELECT id FROM ancestors + """, nativeQuery = true) +List findAncestorIdsInclusive(@Param("collectionId") Long collectionId); + +// 자기 자신 + 모든 후손 컬렉션 ID (cascade 삭제 대상 판단용) — 위와 대칭 구조, parent_collection_id로 아래로 내려감 +List findDescendantIdsInclusive(@Param("collectionId") Long collectionId); + +/** + * 문서가 속한 모든 컬렉션(N:M) + 그 컬렉션들 각각의 조상 전체 ID (문서 권한 상속 판단용). + */ +@Query(value = """ + WITH RECURSIVE ancestors AS ( + SELECT c.id, c.parent_collection_id + FROM collections c + WHERE c.id IN ( + SELECT DISTINCT cd.collection_id FROM collection_documents cd WHERE cd.document_id = :documentId + ) + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN ancestors a ON c.id = a.parent_collection_id + ) + SELECT DISTINCT id FROM ancestors + """, nativeQuery = true) +List findEffectiveCollectionIdsForDocument(@Param("documentId") Long documentId); +``` + +네이티브 재귀 쿼리(`WITH RECURSIVE`)가 코드베이스에 이번이 처음이라, 기존 컨벤션(`DocumentRepository.findReadableDocumentIds*` — `@Query(nativeQuery=true)`, snake_case, `List` 반환 후 2차 필터링에 사용)을 그대로 따랐다. + +**순환 참조 방지 로직은 만들지 않았다.** 컬렉션 이동/수정 API가 없어서 생성 시점에만 부모를 지정할 수 있고, 아직 존재하지 않는 컬렉션은 자기 자신의 조상이 될 수 없으므로 현재 API 구조상 순환 참조가 원천적으로 불가능하다(검토 완료). 나중에 컬렉션 이동 API가 생기면 그때 재검토해야 한다. + +### 2. `domain/collection/service/command/CollectionCommandService.java` — 부모 권한 체크 + cascade 삭제 + +```java +// 폴더 생성 +public CollectionResponse createCollection(Long userId, CreateCollectionRequest request) { + User owner = userRepository.getReferenceById(userId); + + DocumentCollection parentCollection = null; + if (request.parentCollectionId() != null) { + parentCollection = collectionRepository.findById(request.parentCollectionId()) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + if (!permissionQueryService.canWriteCollection(userId, parentCollection)) { // 신규 + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + } + // ... 이하 기존과 동일 +} + +// 컬렉션 soft delete — 소유자만 가능. 하위 컬렉션 전체와 그 안의 문서 매핑까지 cascade로 함께 삭제한다. +// owner 체크는 삭제 대상 최상위(root)에서만 하고 하위 각각은 재확인하지 않는다 +// (구글드라이브 공유폴더 삭제와 동일한 멘탈모델 — root에 대한 권한으로 하위 전체가 지워짐). +public void deleteCollection(Long collectionId, Long userId) { + DocumentCollection root = collectionRepository.findById(collectionId) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + + if (!root.getOwner().getId().equals(userId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + List targetIds = collectionRepository.findDescendantIdsInclusive(collectionId); // 자기 자신 포함 + + List permissions = collectionPermissionRepository.findAllByCollectionIdIn(targetIds); + permissions.stream() + .filter(p -> p.getTargetType() == PermissionTargetType.USER) + .forEach(p -> cacheService.bulkRevokeBySource(AccessSourceType.DIRECT_COLLECTION_PERMISSION, p.getId())); + collectionPermissionRepository.deleteAll(permissions); + + List mappings = collectionDocumentRepository.findAllByCollectionIdIn(targetIds); + collectionDocumentRepository.deleteAll(mappings); // 신규 — #29 원래 버전은 문서 매핑을 안 지웠음 + + LocalDateTime now = LocalDateTime.now(); + collectionRepository.findAllById(targetIds).forEach(c -> c.markDeleted(now)); +} +``` + +`createCollection()`에서 부모의 **엔티티 오버로드**(`canWriteCollection(userId, DocumentCollection)`)를 쓰는 부수 효과로, 내부 `validateActiveCollection()`이 "삭제된 부모 아래 생성 금지"도 자동으로 막아준다(별도 코드 없이 잠재 버그 하나 더 해결). + +`deleteCollection()`의 owner 체크가 root 1회뿐이라는 트레이드오프: 부모에 쓰기권한만 있는 사람도 자식 컬렉션을 만들 수 있으므로(위 `createCollection()` 변경), 이론상 자식의 owner가 root owner와 다를 수 있다. 그래도 root owner가 cascade 삭제를 실행할 수 있다 — 구글드라이브에서 상위 폴더 소유자가 하위 폴더(다른 사람이 만든 것 포함)까지 지울 수 있는 것과 같은 모델. + +### 3. `domain/collection/service/query/CollectionQueryService.java` / `controller` — `GET /collections/{id}/children` + +```java +// 직계 자식 컬렉션 목록 조회 — 부모 읽기 권한 확인 후, 자식 각각의 읽기 권한도 확인 +// (자식 owner/visibility가 부모와 다를 수 있으므로 부모 권한만으로 자식을 노출하면 안 됨) +public List getChildren(Long userId, Long collectionId) { + DocumentCollection parent = collectionRepository.findById(collectionId) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + if (!permissionQueryService.canReadCollection(userId, parent)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + return collectionRepository.findAllByParentCollectionIdAndStatus(collectionId, CollectionStatus.ACTIVE) + .stream() + .filter(child -> permissionQueryService.canReadCollection(userId, child)) + .map(collectionConverter::toResponse) + .toList(); +} +``` +자식 개수가 적을 것으로 예상해 N+1(자식마다 `canReadCollection` 호출)을 허용했다 — 문서 목록처럼 대량+SQL prefilter로 짜는 건 지금 스코프에서 오버엔지니어링으로 판단. 페이지네이션도 없이 `List` 반환(`getMyCollections()`와 동일 컨벤션, 자식 수가 적을 거라는 전제). + +### 4. `domain/permission/service/query/PermissionQueryService.java` — 6개 판정 그룹 전부에 상속 단계 추가 + +문서 판단 4종(`canReadDocument`/`canWriteDocument`/`canAdminDocument`/`checkDocumentPermission`)과 컬렉션 판단 3종(`canReadCollection`/`canWriteCollection`/`canAdminCollection`, 엔티티 오버로드) 전부에 마지막 단계로 상속 체크가 추가됐다. 예시(`canReadDocument`): + +```java +// 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) +List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); +if (!effectiveCollectionIds.isEmpty() + && (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds) + || collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds))) { + return true; +} +``` +컬렉션 판단(예: `canReadCollection` 엔티티 오버로드)은 `findAncestorIdsInclusive`를 쓴다는 것만 다르고 구조는 동일: +```java +List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId); +if (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, ancestorIds)) return true; +return collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, ancestorIds); +``` + +**전부 기존 로직 뒤에 append만 했다** (기존 줄은 한 글자도 안 고침) — 대안으로 기존 12개 `existsXxxForCollection`/`existsXxxForDocument` 메서드 시그니처를 `List` 받게 바꾸는 방법도 검토했지만, 그러면 `PermissionQueryServiceTest`(817줄, 기존 47개 케이스가 그 메서드들을 개별 스텁)가 대량으로 깨진다. 대신 `List` 버전 6개(`existsRole/DeptRead/Write/AdminPermissionForCollections`, 복수형)를 **신규 추가**해서 기존 스텁 안 된 메서드는 Mockito가 기본값(`false`/`[]`)을 반환 → 기존 테스트 47개 전부 무변경 통과. + +`checkDocumentPermission()`의 6단계는 새 `PermissionSourceType` 값을 만들지 않고 기존 `ROLE`/`DEPARTMENT`를 재사용한다 — API 응답 스키마 불변(프론트 영향 없음), 트레이드오프는 "직접 부여 vs 조상 상속"을 이 응답만으로 구분 못 한다는 것(감사 시 조상까지 직접 추적 필요). + +### 5. `domain/document/repository/DocumentRepository.java` — 목록/검색 pre-filter에도 상속 반영 + +`findReadableDocumentIds`(전체 목록/검색), `findReadableDocumentIdsInCollection`(컬렉션 내 목록) 두 native UNION 쿼리 최상단에 컬렉션 조상 closure CTE를 추가하고, 컬렉션 ROLE/DEPARTMENT 브랜치의 JOIN 조건을 이 closure를 거치도록 바꿨다: + +```sql +WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL +) +SELECT d.id FROM documents d + ... +UNION +SELECT d.id FROM documents d + JOIN collection_documents cd ON cd.document_id = d.id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id -- 변경 지점 + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id -- 변경 지점 (기존: cp.collection_id = cd.collection_id) + JOIN user_roles ur ON ur.role_id = cp.role_id +WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true + ... +``` +`ancestor_id`가 자기 자신(distance 0)을 포함하므로 기존 "직접 권한" 케이스도 이 브랜치 하나로 그대로 커버된다 — 별도 브랜치 추가가 아니라 순수 대체. `findReadableDocumentIdsInCollection`의 바깥쪽 `WHERE sub.id IN (SELECT document_id FROM collection_documents WHERE collection_id = :collectionId)`는 그대로 유지 — "이 컬렉션에 직접 속한 문서만 나열"이라는 폴더 탐색 시맨틱(하위 폴더 문서가 상위 목록에 안 섞임)은 안 바뀌어야 한다. + +호출부(`CollectionQueryService.getCollectionDocuments`, `DocumentQueryService`, `AccessibleDocumentQueryService`/`SearchFacade`)는 수정 불필요 — 쿼리 결과가 정확해지면 자동 반영된다. + +### 6. 프론트 — `CollectionsPage.tsx` + +- 생성 모달에 "상위 폴더" 드롭다운 추가 (선택 안 하면 최상위) +- 컬렉션 상세 페이지에 "하위 컬렉션" 섹션 추가 — `GET /collections/{id}/children` 조회, 클릭하면 그 컬렉션 상세로 이동(파인더처럼 한 단계씩 열람, 트리를 한 번에 펼치지 않음) +- 삭제 확인 문구를 하위 컬렉션이 있을 때만 cascade 경고로 분기: +```ts +const warning = children.length + ? "이 컬렉션을 삭제할까요? 하위 컬렉션과 그 안의 문서도 전부 함께 삭제됩니다. 복구 API는 제공되지 않습니다." + : "이 컬렉션을 삭제할까요? 복구 API는 제공되지 않습니다."; +``` + +--- + +## 관련 작업 (같은 세션에서 QA 중 발견해서 이어서 구현, #229 본편은 아님) + +컬렉션 트리 자체는 아니지만 같은 QA 흐름에서 발견해서 같이 처리한 3건. GitHub 이슈 #229 body에도 "🔗 QA 중 함께 발견·구현한 관련 작업" 섹션으로 반영해뒀다. + +### A. ROLE=USER 권한부여 시 전체공개되는 위험 차단 + +`USER` role은 가입 시 전원에게 자동 부여되는 기본 role이다. `targetType=ROLE`로 권한을 부여할 때 이 role을 대상으로 지정하면, ROLE 판단 쿼리(`JOIN user_roles ur ON ur.role_id = cp.role_id`)가 "이 role_id를 가진 모든 사용자"를 매칭하므로 사실상 전 직원한테 뚫린다 — `visibility=PUBLIC`보다도 넓은 범위(PUBLIC은 READ만 열지만 이 경로는 WRITE/ADMIN도 전체 공개 가능). + +```java +// ErrorCode.java +ROLE_NOT_GRANTABLE(HttpStatus.BAD_REQUEST, "PERMISSION-004", + "USER role은 모든 사용자가 보유하고 있어 권한 부여 대상으로 지정할 수 없습니다. 전체 공개가 목적이면 visibility를 PUBLIC으로 설정하세요."), +``` +```java +// DocumentPermissionCommandService / CollectionPermissionCommandService — grantPermission() +} else if (request.targetType() == PermissionTargetType.ROLE) { + targetRole = roleRepository.findById(request.roleId()) + .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + if ("USER".equals(targetRole.getCode())) { + throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE); + } +} +``` +ADMIN role은 문서/컬렉션 접근에 아무 특별 취급이 없다(`/admin/**` API만 열어줌 — `SecurityConfig`)는 것도 이번에 재확인했다. "ADMIN이면 다 보이겠지"는 착각이라, ROLE=USER 위험을 막을 별도 안전망이 없다는 근거로 이 가드가 더 중요해졌다. + +### B. `GET /roles` 신규 + 권한 부여 폼 이름 드롭다운 + +권한 부여 폼에서 ROLE/DEPARTMENT 대상 ID를 숫자로 외워서 입력해야 하는 게 비현실적이었다. `DepartmentController`(`GET /departments`)와 동일한 패턴으로 `RoleController`/`RoleQueryService`/`RoleResponse` 신규 추가(`GET /roles`, 인증 필요 — `/departments`는 회원가입 화면용이라 `permitAll`이지만 역할 선택은 로그인 후에만 쓰이므로 기본 인증 유지). + +프론트 `PermissionsPage.tsx`의 "권한 부여" 폼에서 `targetType`을 controlled state로 바꾸고, "대상 ID" 필드를 분기: +- USER: 숫자 input 유지 (전체 사용자 목록 조회는 관리자 전용이라 이름 드롭다운으로 못 바꿈) +- ROLE: `roles.filter(role => role.code !== "USER")` 이름 드롭다운 — 위 A번 가드와 자연스럽게 맞물려 USER는 UI에서부터 선택 불가 +- DEPARTMENT: `departments` 이름 드롭다운 + +### C. 컬렉션 목록(`GET /collections`) 권한 반영 + 페이지네이션 + 검색 + +`CollectionQueryService.getMyCollections()`(owner만, `List` 반환)가 문서 목록(`GET /api/documents`, owner+PUBLIC+권한부여 전부 포함)과 비대칭이라는 게 QA 중 재발견됐다. `getCollections(userId, keyword, page, size)`로 완전히 교체: + +```java +// CollectionRepository — findReadableDocumentIds와 동일한 UNION 패턴, keyword는 nullable +@Query(value = """ + WITH RECURSIVE collection_ancestors AS ( ... ) -- 위 5번과 동일한 closure + SELECT c.id FROM collections c + WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE' AND (:keyword IS NULL OR ...) + UNION -- USER 직접 권한 + UNION -- ROLE (조상 상속 포함) + UNION -- DEPARTMENT (조상 상속 포함) + """, nativeQuery = true) +List findReadableCollectionIds(@Param("userId") Long userId, @Param("keyword") String keyword); + +@Query("SELECT c FROM DocumentCollection c JOIN FETCH c.owner WHERE c.id IN :ids") +Page findAllByIdIn(@Param("ids") List ids, Pageable pageable); +``` +`GET /collections?keyword=&page=&size=`. 검색은 문서처럼 임베딩 기반이 아니라 이름/설명 단순 `ILIKE` 필터 — 컬렉션은 구조화된 메타데이터뿐이라 무거운 semantic search가 필요 없다고 판단. 안 쓰이게 된 `findAllByOwnerIdAndStatus()`는 삭제. + +프론트 `CollectionsPage.tsx` 메인 목록에 페이지네이션 컨트롤 추가(`CollectionDetailPage`의 기존 패턴 재사용), 검색창은 `AdminPages.tsx`의 기존 `toolbar`/`toolbar-search`+`toQuery` 패턴 재사용. `SearchPage.tsx`/`PermissionsPage.tsx`의 컬렉션 드롭다운도 응답이 `PageResponse`로 바뀐 데 맞춰 `.content` 사용하도록 수정(각각 `size=100`으로 넉넉히 조회). + +--- + +## 로컬 검증 + +Swagger/프론트 수동 QA는 진행했으나, 항목별 pass/fail 세부 기록은 별도 정리 전. 아래는 자동 테스트 기준. + +### 자동 테스트 + +```bash +$ ./backend/gradlew -p backend test --tests "com.opensource.docgrid.domain.document.*" \ + --tests "com.opensource.docgrid.domain.collection.*" --tests "com.opensource.docgrid.domain.permission.*" \ + --tests "com.opensource.docgrid.domain.search.*" --tests "com.opensource.docgrid.domain.user.*" +``` +document/collection/permission/search/user 도메인 전체 통과(354개 중 354개, 2026-08-18 재검증분 기준). 실패 9개(`DocumentUploadIntegrationTest`/`DocumentVersionUploadIntegrationTest`/`DocumentStatusRepositoryTest`)는 로컬 임베딩 모델 미설정이라는 **기존 환경 문제** — 이번 변경 전 원본 코드로도 동일하게 재현되는 것을 `git stash`로 직접 확인해 무관함을 검증했다. + +신규 `@DataJpaTest`(`CollectionTreeRepositoryTest`)로 실제 로컬 Postgres에 재귀 쿼리·keyword 필터를 직접 실행해 검증: +- `findAncestorIdsInclusive`/`findDescendantIdsInclusive`/`findEffectiveCollectionIdsForDocument`/`findAllByParentCollectionIdAndStatus` — 3단 트리(root→child→grandchild) 구성해서 검증 +- `findReadableCollectionIds` — owner/PUBLIC 노출 + DEPARTMENT 권한이 부모에서 자식까지 상속되는지 + keyword 필터 + +기존 `DocumentReadableIdsRepositoryTest`도 확장 — 부모 컬렉션에만 권한 있고 자식 컬렉션 문서를 조회하는 케이스(상속 정상 동작) + 기존 "직접 권한만" 케이스가 회귀 없이 통과하는지 같이 검증. + +`PermissionQueryServiceTest`는 47→54개(상속 케이스 7개 신규 + 관련 없음 3개는 없음, 정확히는 6개 판정그룹 각 1개 상속 테스트 + `checkDocumentPermission` 상속 테스트 1개 = 7개)로 늘었고, **기존 47개는 단 한 줄도 안 고쳤는데 그대로 통과**했다(추가 방식 설계가 의도대로 작동함을 증명). + +--- + +## 에러 케이스 정리 + +| 상황 | HTTP | 코드 | +|---|---:|---| +| 상위 컬렉션 없음(생성 시) | 404 | `COLLECTION-001` | +| 상위 컬렉션에 쓰기권한 없음(생성 시, 신규) | 403 | `ROLE-002`(`PERMISSION_DENIED`) | +| 자식 컬렉션 조회 시 부모 없음/삭제됨 | 404 | `COLLECTION-001` | +| 자식 컬렉션 조회 시 부모 읽기권한 없음 | 403 | `ROLE-002`(`PERMISSION_DENIED`) | +| ROLE 대상이 USER role임(관련 작업 A) | 400 | `PERMISSION-004`(`ROLE_NOT_GRANTABLE`) | + +--- + +## 설계 결정 요약 + +**기존 권한 판정 메서드는 손대지 않고 전부 append 방식으로 확장**: `PermissionQueryService`의 12개 기존 `existsXxxFor...` 메서드를 시그니처 변경(치환)하면 817줄짜리 기존 테스트가 대량으로 깨진다. 대신 `List` 버전을 신규 추가하고 기존 5단계 로직 끝에 6단계로 이어붙이는 방식을 택해, 인증/인가 critical path의 기존 검증된 로직을 안 건드리면서 상속을 추가했다. + +**순환 참조 방지 로직 생략**: 컬렉션 이동 API가 없어 생성 시점에만 부모 지정이 가능하므로, 현재 API 구조상 순환 참조가 원천적으로 불가능하다(검토 완료). 이동 API를 나중에 추가하게 되면 그때 반드시 재검토해야 한다. + +**목록/검색 쿼리(`DocumentRepository`)까지 상속 반영 범위에 포함**: 단건 조회만 상속되면 "권한은 있는데 못 찾는" 상태가 되므로, 원래 계획보다 범위를 넓혀 native 쿼리 2개를 같이 고쳤다(사용자가 명시적으로 이 범위까지 선택). + +**컬렉션 삭제 cascade는 root owner 1회 체크**: 구글드라이브 공유폴더 삭제와 동일한 멘탈모델. 하위 컬렉션 owner가 root와 다를 수 있다는 트레이드오프를 감수했다(위 신규 파일 2번 참고). + +**`checkDocumentPermission()`의 상속 출처는 새 enum 값 없이 기존 `ROLE`/`DEPARTMENT` 재사용**: API 응답 스키마 불변으로 프론트 영향 없음. "직접 부여 vs 상속"을 구분 못 한다는 정보 손실은 감수. + +**ROLE=USER 차단은 프론트가 아니라 백엔드에서**: `PermissionsPage.tsx`의 대상 타입 드롭다운을 아무리 잘 막아도 Swagger/curl로 API를 직접 호출하면 우회된다 — 실제 안전장치는 서버 검증(`ROLE_NOT_GRANTABLE`)이고, 프론트 드롭다운 필터링은 UX 보조일 뿐이다. + +--- + +## 남은 이슈 / TODO (백로그, 이번 스코프 아님) + +- **컬렉션 상속용 재귀 CTE(`collection_ancestors`)가 매 호출마다 컬렉션 테이블 전체를 스캔한다** — `findAncestorIdsInclusive(collectionId)`처럼 `WHERE id = :collectionId`로 범위를 좁힌 쿼리는 문제없지만, `findReadableDocumentIds`/`findReadableDocumentIdsInCollection`/`findReadableCollectionIds` 안의 closure는 범위 제한이 없다. 검색·문서목록·컬렉션목록처럼 호출 빈도가 높은 화면에 다 걸려있어서, 컬렉션 수가 많아지면 병목 후보 1순위다. 지금 규모(수십~수백 개 추정)에선 무해. +- `DOCUMENT_MANAGER` role 관련 작업은 이번에도 스코프 제외 (별도 논의 필요). +- 프론트 트리 탐색 UI는 "클릭해서 한 단계씩 열람"만 구현 — 여러 단계를 한 번에 펼쳐 보여주는 UI는 안 만듦(파인더 방식 그대로). + +## 다음 단계 + +컬렉션/권한 블록(`#16`, `#18`, `#21`, `#24`, `#29`)에 이어지는 후속 이슈로, 이 다섯 문서에도 "이후 업데이트" 절로 교차 반영해뒀다. 커밋은 아직 안 한 상태 — 이 이슈에 관련 작업 A/B/C를 같이 넣을지 별도로 분리할지는 미정. diff --git a/docs/design/kangcheolung-#24-document-permission-check.md b/docs/design/kangcheolung-#24-document-permission-check.md index c4c409e5..852fd440 100644 --- a/docs/design/kangcheolung-#24-document-permission-check.md +++ b/docs/design/kangcheolung-#24-document-permission-check.md @@ -17,7 +17,7 @@ canReadDocument() → 읽기 권한 여부만, 첫 true에서 즉시 반환(단 canWriteDocument() → 쓰기 권한 여부만, 첫 true에서 즉시 반환 canAdminDocument() → 관리 권한 여부만, 첫 true에서 즉시 반환 ``` -이 셋을 그냥 세 번 호출하면 될 것 같지만 안 된다 — **단락 평가가 서로 다른 결과를 감춘다.** 예를 들어 캐시(3단계)에서 `canRead=true`가 나와서 `canReadDocument()`가 거기서 멈춰버리면, 그 아래 ROLE 단계(4단계)에 `canWrite=true`가 있어도 그건 절대 확인되지 않는다(단락 평가는 애초에 "이후 단계를 볼 필요 없음"을 전제로 하니까). 그래서 `checkDocumentPermission()`은 **OWNER가 아닌 이상 단락 평가를 하지 않고 5단계를 전부 끝까지 탐색**한다. +이 셋을 그냥 세 번 호출하면 될 것 같지만 안 된다 — **단락 평가가 서로 다른 결과를 감춘다.** 예를 들어 캐시(3단계)에서 `canRead=true`가 나와서 `canReadDocument()`가 거기서 멈춰버리면, 그 아래 ROLE 단계(4단계)에 `canWrite=true`가 있어도 그건 절대 확인되지 않는다(단락 평가는 애초에 "이후 단계를 볼 필요 없음"을 전제로 하니까). 그래서 `checkDocumentPermission()`은 **OWNER가 아닌 이상 단락 평가를 하지 않고 전체 단계를 끝까지 탐색**한다(작성 당시 5단계 → 2026-08-18 이슈 #229부터 6단계, 아래 "이후 업데이트" 참고). ```text checkDocumentPermission() @@ -91,6 +91,17 @@ public DocumentPermissionSummaryResponse checkDocumentPermission(Long userId, Lo // 5단계: DEPARTMENT live — 4단계와 동일 패턴 + // (2026-08-18 추가, 이슈 #229) 6단계: 부모 컬렉션 체인 상속 — ROLE/DEPARTMENT 값을 그대로 재사용(신규 enum 값 안 만듦) + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty()) { + boolean inheritedRoleRead = collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds); + // ... inheritedRoleWrite, inheritedRoleAdmin, inheritedDeptRead/Write/Admin도 동일 패턴 + if (inheritedRoleRead /* || ...Write || ...Admin */) sources.add(PermissionSourceType.ROLE); + // DEPARTMENT도 동일하게 sources.add(PermissionSourceType.DEPARTMENT) + if (inheritedRoleRead /* || inheritedDeptRead */) canRead = true; + // canWrite/canAdmin도 동일 패턴 + } + return new DocumentPermissionSummaryResponse(documentId, canRead, canWrite, canAdmin, sources); } ``` @@ -125,7 +136,7 @@ public ResponseEntity> getMyDocum ## 로컬 검증 (Swagger 수동 테스트 — 실제 수행 기록) -`docs/test-results/kangcheolung-#21-permission-query-service.md`에 이 API(`GET /permissions/documents/{id}/me`)로 5단계 전체가 실제 시나리오로 확인되어 있다. 발췌: +`docs/test-results/kangcheolung-#21-permission-query-service.md`에 이 API(`GET /permissions/documents/{id}/me`)로 (당시 기준) 5단계 전체가 실제 시나리오로 확인되어 있다. 발췌: **OWNER (4.1절)** ```json @@ -184,6 +195,10 @@ BUILD SUCCESSFUL - `checkDocumentPermission()`이 `canReadDocument()` 등과 별개로 캐시/ROLE/DEPT 쿼리를 다시 전부 실행한다 — 두 메서드가 같은 문서에 대해 동시에 호출될 일은 지금 없지만, 쿼리 로직 자체가 중복되어 있어(`#21`의 TODO와 동일한 지점) 유지보수 시 두 곳을 같이 고쳐야 하는 부담이 있다. +## 이후 업데이트 (2026-08-18, 이슈 #229 — 컬렉션 트리) + +`checkDocumentPermission()`에 "6단계: 부모 컬렉션 체인 상속"이 추가됐다(위 코드 블록에 인라인 반영). 새 `PermissionSourceType` 값을 만들지 않고 기존 `ROLE`/`DEPARTMENT`를 그대로 재사용하기로 결정했다 — 장점은 API 응답 스키마가 안 바뀌어 프론트 영향이 없다는 것, 트레이드오프는 "직접 부여된 권한인지 조상 컬렉션에서 상속된 것인지"를 이 API 응답만으로는 구분할 수 없다는 것(감사/디버깅 시 조상 컬렉션까지 직접 추적해야 함). 상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고. + ## 다음 단계 -`#29`(컬렉션 관리 API)로 이어진다. 권한 블록(`#16`~`#29`) 전체가 완료된 뒤 RAG 블록(F-SEARCH, F-RAG)이 `PermissionQueryService.canReadDocument()`를 재사용해서 검색 권한 필터링을 구현한다. +`#29`(컬렉션 관리 API)로 이어진다. 권한 블록(`#16`~`#29`) 전체가 완료된 뒤 RAG 블록(F-SEARCH, F-RAG)이 `PermissionQueryService.canReadDocument()`를 재사용해서 검색 권한 필터링을 구현한다. 이후 `#229`(컬렉션 트리)로 이어진다. diff --git a/docs/design/kangcheolung-#29-collection-management.md b/docs/design/kangcheolung-#29-collection-management.md index e96b7a5e..43641e9a 100644 --- a/docs/design/kangcheolung-#29-collection-management.md +++ b/docs/design/kangcheolung-#29-collection-management.md @@ -14,7 +14,10 @@ closes #29 ```text GET /collections → 내 컬렉션 목록 (ACTIVE만) + (2026-08-18, 이슈 #229부터: owner+PUBLIC+권한부여+상속 전체, + 페이지네이션·keyword 검색 포함 — 아래 "이후 업데이트" 참고) DELETE /collections/{id} → 컬렉션 소유자만, 권한 전부 정리 후 소프트 삭제 + (2026-08-18부터: 하위 컬렉션 전체까지 cascade) DELETE /collections/{id}/documents/{docId} → 컬렉션 소유자만, 그 문서에 대한 캐시만 정리 후 링크 삭제 ``` @@ -54,6 +57,8 @@ public ResponseEntity> removeDocument( ### `domain/collection/service/query/CollectionQueryService.java` — `getMyCollections()` +**(2026-08-18, 이슈 #229 관련 작업으로 이 메서드는 `getCollections(userId, keyword, page, size)`로 완전히 교체됨. 아래는 작성 당시 원본 코드 — "이후 업데이트" 절 참고.)** + ```java // 내 컬렉션 목록 조회 (ACTIVE 상태만) public List getMyCollections(Long userId) { @@ -63,10 +68,12 @@ public List getMyCollections(Long userId) { .toList(); } ``` -`#16`에 이미 있는 `getCollection(id)`(단건 조회, 권한 체크가 없다는 게 이미 TODO로 기록됨)와 다르게, 이건 `ownerId` 기준으로 리포지토리 쿼리 자체가 필터링한다 — "내가 만든 컬렉션 목록"이라 소유자 필터가 곧 접근 제어라서 서비스 레이어에 별도 권한 체크 코드가 필요 없다. +`#16`에 이미 있는 `getCollection(id)`(단건 조회, 권한 체크가 없다는 게 이미 TODO로 기록됨)와 다르게, 이건 `ownerId` 기준으로 리포지토리 쿼리 자체가 필터링한다 — "내가 만든 컬렉션 목록"이라 소유자 필터가 곧 접근 제어라서 서비스 레이어에 별도 권한 체크 코드가 필요 없다(작성 당시 기준. 지금은 owner 외에도 권한부여자를 포함하므로 이 필터링 논리 자체가 바뀌었다). ### `domain/collection/service/command/CollectionCommandService.java` — `deleteCollection()` +**(2026-08-18, 이슈 #229부터 하위 컬렉션 전체까지 cascade하도록 확장됨. 아래는 작성 당시 원본 — "이후 업데이트" 절 참고.)** + ```java // 컬렉션 soft delete — 소유자만 가능 public void deleteCollection(Long collectionId, Long userId) { @@ -87,7 +94,7 @@ public void deleteCollection(Long collectionId, Long userId) { collection.markDeleted(LocalDateTime.now()); } ``` -순서가 중요하다 — ① 캐시 무효화(`forEach`) → ② 권한 레코드 삭제(`deleteAll`) → ③ 컬렉션 소프트 삭제(`markDeleted`). 반대로 권한 레코드를 먼저 지워버리면 `p.getId()`로 캐시를 찾아 무효화할 근거(`DIRECT_COLLECTION_PERMISSION` + `sourceId`)가 사라진다. ROLE/DEPARTMENT 대상 권한은 캐시에 애초에 안 들어가 있으므로(`#18`의 비대칭 캐싱 설계) `filter(USER)`로 걸러지고, `deleteAll()`에서는 캐시 무효화 없이 같이 삭제되는 것만으로 충분하다. +순서가 중요하다 — ① 캐시 무효화(`forEach`) → ② 권한 레코드 삭제(`deleteAll`) → ③ 컬렉션 소프트 삭제(`markDeleted`). 반대로 권한 레코드를 먼저 지워버리면 `p.getId()`로 캐시를 찾아 무효화할 근거(`DIRECT_COLLECTION_PERMISSION` + `sourceId`)가 사라진다. ROLE/DEPARTMENT 대상 권한은 캐시에 애초에 안 들어가 있으므로(`#18`의 비대칭 캐싱 설계) `filter(USER)`로 걸러지고, `deleteAll()`에서는 캐시 무효화 없이 같이 삭제되는 것만으로 충분하다. 이 순서 원칙(캐시 무효화 → 권한 삭제 → soft delete)은 cascade로 확장된 뒤에도 그대로 유지된다. ### `domain/collection/service/command/CollectionCommandService.java` — `removeDocument()` @@ -203,7 +210,7 @@ GET /collections → 200 OK [] ``` -소프트 삭제된 컬렉션이 `getMyCollections()`(ACTIVE 필터)에서 제외됨을 확인. +소프트 삭제된 컬렉션이 `getMyCollections()`(ACTIVE 필터)에서 제외됨을 확인. (당시 응답이 배열이었던 건 `getMyCollections()` 기준 — 2026-08-18 `getCollections()`로 교체된 뒤로는 `PageResponse` 형태로 바뀌었다. 삭제된 컬렉션이 목록에서 빠진다는 결론 자체는 그대로 유효.) ### 자동 테스트 @@ -239,9 +246,24 @@ BUILD SUCCESSFUL ## 남은 이슈 / TODO -- `getMyCollections()`가 페이지네이션 없이 전체 목록을 반환한다 — 컬렉션 수가 많아지는 시나리오는 아직 없어 이슈로 등록하지 않음. +- ~~`getMyCollections()`가 페이지네이션 없이 전체 목록을 반환한다 — 컬렉션 수가 많아지는 시나리오는 아직 없어 이슈로 등록하지 않음.~~ → **2026-08-18 해결됨**: `getCollections(userId, keyword, page, size)`로 교체, `PageResponse` 반환. 아래 "이후 업데이트" 참고. - ~~`deleteCollection()`/`removeDocument()` 둘 다 `collectionRepository.findById()`로만 컬렉션을 조회한다 — `#16`에서 이미 지적된 것과 같은 이유로, 이미 `status=DELETED`인 컬렉션에 대해서도 (멱등하게) 재호출이 가능하다.~~ → 해결됨: 두 메서드 모두 `findById(...).filter(c -> c.getStatus() != CollectionStatus.DELETED)`로 변경(`#16`과 동일 패턴, `PermissionQueryService`의 `getActiveCollection()` 헬퍼와 동일한 관용구). +## 이후 업데이트 (2026-08-18, 이슈 #229 및 관련 작업) + +수동 QA(시나리오 4) 중 이 문서가 만든 `getMyCollections()`/`deleteCollection()` 둘 다 실질적으로 다시 손보게 됐다. 상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고. + +**`deleteCollection()` — cascade 삭제로 확장 (이슈 #229 본편)** +- `CollectionRepository.findDescendantIdsInclusive(collectionId)`(자기 자신+모든 후손, `WITH RECURSIVE`)로 대상 전체 ID를 구한 뒤, 권한 삭제·캐시 무효화·soft delete를 전부 그 목록 전체에 대해 수행하도록 확장. +- **문서 매핑(`collection_documents`)도 이번에 같이 지우도록 범위가 넓어졌다** — 원래는 컬렉션만 지우고 매핑은 안 건드렸는데(위 "DB 변화 예시" 참고), cascade 대상 전체의 `CollectionDocument`도 함께 삭제한다. +- owner 체크는 **삭제 대상 root 1회만** 하고 하위 각각은 재확인하지 않는다 — 구글드라이브 공유폴더 삭제와 같은 멘탈모델("최상위에 대한 권한으로 하위 전체가 지워짐"). 트레이드오프: 하위 컬렉션의 owner가 root owner와 다를 수 있는데(부모에 쓰기권한만 있으면 자식을 만들 수 있으므로), 그 경우도 root owner가 삭제할 수 있다. + +**`getMyCollections()` → `getCollections()` — 권한 반영 + 페이지네이션 + 검색 (관련 작업)** +- 이름 그대로 "owner 것만"이라 문서 목록(`GET /api/documents`, owner+PUBLIC+권한부여 전부 포함)과 비대칭이었던 게 QA 중 재발견됨. +- `CollectionRepository.findReadableCollectionIds(userId, keyword)` 신규 — owner+PUBLIC+USER직접권한+ROLE+DEPARTMENT(+부모 컬렉션 상속)를 전부 포함하는 native 쿼리. `DocumentRepository.findReadableDocumentIds`와 동일한 UNION 패턴. +- `GET /collections?keyword=&page=&size=`로 페이지네이션과 이름/설명 검색까지 같이 추가. +- 안 쓰이게 된 `findAllByOwnerIdAndStatus()`는 삭제. + ## 다음 단계 -권한 블록(`#16`, `#18`, `#21`, `#24`, `#29`) 전체 완료. RAG 블록(F-SEARCH, F-RAG)이 이 블록의 `PermissionQueryService.canReadDocument()`를 검색 단계 권한 필터링에 그대로 재사용한다(이미 완료·문서화됨: `docs/design/kangcheolung-#65-*.md` 이하 RAG 문서 시리즈). +권한 블록(`#16`, `#18`, `#21`, `#24`, `#29`) 전체 완료. RAG 블록(F-SEARCH, F-RAG)이 이 블록의 `PermissionQueryService.canReadDocument()`를 검색 단계 권한 필터링에 그대로 재사용한다(이미 완료·문서화됨: `docs/design/kangcheolung-#65-*.md` 이하 RAG 문서 시리즈). 이후 `#229`(컬렉션 트리)로 이어진다. diff --git a/frontend/app/features/CollectionsPage.tsx b/frontend/app/features/CollectionsPage.tsx index 0beb238c..858f5597 100644 --- a/frontend/app/features/CollectionsPage.tsx +++ b/frontend/app/features/CollectionsPage.tsx @@ -3,37 +3,68 @@ // vinext production navigation uses full requests because its client router does not complete these catch-all route transitions. /* eslint-disable @next/next/no-html-link-for-pages */ -import { FormEvent, useCallback, useEffect, useState } from "react"; -import { apiRequest, errorMessage } from "../lib/api"; +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import { apiRequest, errorMessage, toQuery } from "../lib/api"; import type { Collection, CollectionDocument, DocumentSummary, PageResponse } from "../lib/api-types"; import { EmptyState, ErrorState, LoadingState, PageHeading, StatusPill, formatDate } from "../components/ui"; export function CollectionsPage({ notify }: { notify: (message: string) => void }) { - const [collections, setCollections] = useState([]); + const [collections, setCollections] = useState | null>(null); + const [parentCandidates, setParentCandidates] = useState([]); + const [keywordInput, setKeywordInput] = useState(""); + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(0); const [creating, setCreating] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + // 검색어/페이지를 빠르게 바꾸면 먼저 보낸 요청이 나중에 도착해 최신 화면을 덮어쓸 수 있어, 가장 최근 호출의 결과만 반영한다. + const loadSeqRef = useRef(0); const load = useCallback(async () => { + const seq = ++loadSeqRef.current; setLoading(true); setError(""); - try { setCollections(await apiRequest("/collections")); } - catch (reason) { setError(errorMessage(reason)); } - finally { setLoading(false); } - }, []); + try { + const result = await apiRequest>(`/collections${toQuery({ keyword, page, size: 20 })}`); + if (seq !== loadSeqRef.current) return; + setCollections(result); + } catch (reason) { + if (seq !== loadSeqRef.current) return; + setError(errorMessage(reason)); + } finally { + if (seq === loadSeqRef.current) setLoading(false); + } + }, [keyword, page]); + + function search(event: FormEvent) { + event.preventDefault(); + const nextKeyword = keywordInput.trim(); + setPage(0); + if (nextKeyword === keyword && page === 0) void load(); + else setKeyword(nextKeyword); + } useEffect(() => { const timer = window.setTimeout(() => void load(), 0); return () => window.clearTimeout(timer); }, [load]); + async function openCreateModal() { + try { + // 상위 폴더 후보는 페이지네이션과 무관하게 넉넉히 한 번에 가져온다. + setParentCandidates((await apiRequest>("/collections?page=0&size=100")).content); + } catch (reason) { setError(errorMessage(reason)); } + setModalOpen(true); + } + async function create(event: FormEvent) { event.preventDefault(); setCreating(true); const form = new FormData(event.currentTarget); + const parentCollectionId = form.get("parentCollectionId"); try { - await apiRequest("/collections", { method: "POST", body: { name: String(form.get("name")), description: String(form.get("description") || ""), parentCollectionId: null, visibility: String(form.get("visibility")) } }); + await apiRequest("/collections", { method: "POST", body: { name: String(form.get("name")), description: String(form.get("description") || ""), parentCollectionId: parentCollectionId ? Number(parentCollectionId) : null, visibility: String(form.get("visibility")) } }); setModalOpen(false); notify("새 컬렉션을 만들었습니다."); await load(); @@ -42,17 +73,22 @@ export function CollectionsPage({ notify }: { notify: (message: string) => void } return

- setModalOpen(true)}>+ 새 컬렉션} /> + void openCreateModal()}>+ 새 컬렉션} /> +
{error ? void load()} /> : null} {loading ? : null} - {!loading && !error && !collections.length ? : null} - {!loading && collections.length ? : null} - {modalOpen ?

새 컬렉션

관련 문서를 하나의 검색 범위로 묶습니다.