diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/KnowledgeGraphServiceConfiguration.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/KnowledgeGraphServiceConfiguration.java index 5b5adb0..8cfdf24 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/KnowledgeGraphServiceConfiguration.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/KnowledgeGraphServiceConfiguration.java @@ -12,7 +12,7 @@ import org.springframework.web.client.RestTemplate; import java.time.Duration; @Configuration -@ComponentScan(basePackages = "com.datamate.knowledgegraph") +@ComponentScan(basePackages = {"com.datamate.knowledgegraph", "com.datamate.common.auth"}) @EnableNeo4jRepositories(basePackages = "com.datamate.knowledgegraph.domain.repository") @EnableScheduling public class KnowledgeGraphServiceConfiguration { diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java index 6f97dac..5a450f3 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java @@ -1,5 +1,6 @@ package com.datamate.knowledgegraph.application; +import com.datamate.common.auth.application.ResourceAccessService; import com.datamate.common.infrastructure.exception.BusinessException; import com.datamate.common.infrastructure.exception.SystemErrorCode; import com.datamate.common.interfaces.PagedResponse; @@ -23,6 +24,13 @@ import java.util.regex.Pattern; *
* 提供图遍历(N 跳邻居、最短路径、子图提取)和全文搜索功能。 * 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。 + *
+ * 查询结果根据用户权限进行过滤: + *
+ * 管理员返回空字符串(不过滤); + * 普通用户返回 AND 子句:仅保留结构型实体(User、Org、Field) + * 和 {@code created_by} 等于当前用户的业务实体。 + * 若无保密数据权限,额外过滤 sensitivity=CONFIDENTIAL。 + */ + private static String buildPermissionPredicate(String nodeAlias, String filterUserId, boolean excludeConfidential) { + StringBuilder sb = new StringBuilder(); + if (filterUserId != null) { + sb.append("AND (").append(nodeAlias).append(".type IN ['User', 'Org', 'Field'] OR ") + .append(nodeAlias).append(".`properties.created_by` = $filterUserId) "); + } + if (excludeConfidential) { + sb.append("AND (toUpper(trim(").append(nodeAlias).append(".`properties.sensitivity`)) IS NULL OR ") + .append("toUpper(trim(").append(nodeAlias).append(".`properties.sensitivity`)) <> 'CONFIDENTIAL') "); + } + return sb.toString(); + } + + /** + * 校验非管理员用户对实体的访问权限。 + * 保密数据需要 canViewConfidential 权限; + * 结构型实体(User、Org、Field)对所有用户可见; + * 业务实体必须匹配 created_by。 + */ + private static void assertEntityAccess(GraphEntity entity, String filterUserId, boolean excludeConfidential) { + // 保密数据检查(大小写不敏感,与 data-management 一致) + if (excludeConfidential) { + Object sensitivity = entity.getProperties() != null + ? entity.getProperties().get("sensitivity") : null; + if (sensitivity != null && sensitivity.toString().trim().equalsIgnoreCase("CONFIDENTIAL")) { + throw BusinessException.of(SystemErrorCode.INSUFFICIENT_PERMISSIONS, "无权访问保密数据"); + } + } + // 结构型实体按类型白名单放行 + if (STRUCTURAL_ENTITY_TYPES.contains(entity.getType())) { + return; + } + // 业务实体必须匹配 owner + Object createdBy = entity.getProperties() != null + ? entity.getProperties().get("created_by") : null; + if (createdBy == null || !filterUserId.equals(createdBy.toString())) { + throw BusinessException.of(SystemErrorCode.INSUFFICIENT_PERMISSIONS, "无权访问该实体"); + } + } + + /** + * 判断实体是否对指定用户可访问。 + * 保密数据需要 canViewConfidential 权限; + * 结构型实体(User、Org、Field)对所有用户可见; + * 业务实体必须匹配 created_by。 + */ + private static boolean isEntityAccessible(GraphEntity entity, String filterUserId, boolean excludeConfidential) { + // 保密数据检查(大小写不敏感,与 data-management 一致) + if (excludeConfidential) { + Object sensitivity = entity.getProperties() != null + ? entity.getProperties().get("sensitivity") : null; + if (sensitivity != null && sensitivity.toString().trim().equalsIgnoreCase("CONFIDENTIAL")) { + return false; + } + } + // 结构型实体按类型白名单放行 + if (STRUCTURAL_ENTITY_TYPES.contains(entity.getType())) { + return true; + } + // 业务实体必须匹配 owner + Object createdBy = entity.getProperties() != null + ? entity.getProperties().get("created_by") : null; + return createdBy != null && filterUserId.equals(createdBy.toString()); + } + // ----------------------------------------------------------------------- // 内部方法 // ----------------------------------------------------------------------- diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java index f99c322..2f62f3b 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java @@ -72,6 +72,9 @@ public class GraphSyncStepService { .map(TagDTO::getName).toList(); props.put("tags", tagNames); } + if (dto.getCreatedBy() != null) { + props.put("created_by", dto.getCreatedBy()); + } upsertEntity(graphId, dto.getId(), "Dataset", dto.getName(), dto.getDescription(), props, result); @@ -184,6 +187,9 @@ public class GraphSyncStepService { if (dto.getInputDatasetIds() != null) { props.put("input_dataset_ids", dto.getInputDatasetIds()); } + if (dto.getCreatedBy() != null) { + props.put("created_by", dto.getCreatedBy()); + } upsertEntity(graphId, dto.getId(), "Workflow", dto.getName(), dto.getDescription(), props, result); @@ -348,6 +354,9 @@ public class GraphSyncStepService { if (dto.getSourceDatasetIds() != null) { props.put("source_dataset_ids", dto.getSourceDatasetIds()); } + if (dto.getCreatedBy() != null) { + props.put("created_by", dto.getCreatedBy()); + } upsertEntity(graphId, dto.getId(), "KnowledgeSet", dto.getName(), dto.getDescription(), props, result); diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java index 3660237..6fb1efa 100644 --- a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java @@ -1,11 +1,13 @@ package com.datamate.knowledgegraph.application; +import com.datamate.common.auth.application.ResourceAccessService; import com.datamate.common.infrastructure.exception.BusinessException; import com.datamate.knowledgegraph.domain.model.GraphEntity; import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository; import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties; import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -13,7 +15,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.neo4j.core.Neo4jClient; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -38,6 +42,9 @@ class GraphQueryServiceTest { @Mock private KnowledgeGraphProperties properties; + @Mock + private ResourceAccessService resourceAccessService; + @InjectMocks private GraphQueryService queryService; @@ -174,4 +181,417 @@ class GraphQueryServiceTest { assertThat(result.getContent()).isEmpty(); } + + // ----------------------------------------------------------------------- + // 权限过滤 + // ----------------------------------------------------------------------- + + @Nested + class PermissionFilteringTest { + + private static final String CURRENT_USER_ID = "user-123"; + private static final String OTHER_USER_ID = "other-user"; + + // -- getNeighborGraph 权限 -- + + @Test + void getNeighborGraph_nonAdmin_otherEntity_throwsInsufficientPermissions() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + + GraphEntity entity = GraphEntity.builder() + .id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(entity)); + + assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void getNeighborGraph_admin_otherEntity_noPermissionDenied() { + // 管理员返回 null → 不过滤 + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(null); + + GraphEntity entity = GraphEntity.builder() + .id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(entity)); + when(properties.getMaxDepth()).thenReturn(3); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + // 管理员不会被权限拦截,会继续到 Neo4jClient 调用 + // 由于 Neo4jClient 未完全 mock,会抛出其他异常,不是 BusinessException + try { + queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50); + } catch (BusinessException e) { + throw new AssertionError("Admin should not be blocked by permission check", e); + } catch (Exception ignored) { + // Neo4jClient mock chain 未完成,预期其他异常 + } + } + + // -- getShortestPath 权限 -- + + @Test + void getShortestPath_nonAdmin_sourceNotAccessible_throws() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + + GraphEntity sourceEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + + assertThatThrownBy(() -> queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void getShortestPath_nonAdmin_targetNotAccessible_throws() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + + GraphEntity sourceEntity = GraphEntity.builder() + .id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID))) + .build(); + GraphEntity targetEntity = GraphEntity.builder() + .id(ENTITY_ID_2).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + when(entityRepository.findByIdAndGraphId(ENTITY_ID_2, GRAPH_ID)) + .thenReturn(Optional.of(targetEntity)); + + assertThatThrownBy(() -> queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void getShortestPath_nonAdmin_sameOwnEntity_returnsSingleNode() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + + GraphEntity entity = GraphEntity.builder() + .id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(entity)); + + var result = queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3); + + assertThat(result.getPathLength()).isEqualTo(0); + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getName()).isEqualTo("My Dataset"); + } + + @Test + void getShortestPath_nonAdmin_structuralEntity_noPermissionDenied() { + // 结构型实体(无 created_by)对所有用户可见 + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + + GraphEntity structuralEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Admin User").type("User").graphId(GRAPH_ID) + .properties(new HashMap<>()) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(structuralEntity)); + + // 起止相同 → 返回单节点路径,不需要 Neo4jClient + var result = queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3); + + assertThat(result.getPathLength()).isEqualTo(0); + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getType()).isEqualTo("User"); + } + + // -- getSubgraph 权限过滤 -- + + @Test + void getSubgraph_nonAdmin_filtersInaccessibleEntities() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + GraphEntity ownEntity = GraphEntity.builder() + .id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID))) + .build(); + GraphEntity otherEntity = GraphEntity.builder() + .id(ENTITY_ID_2).name("Other Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID))) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2))) + .thenReturn(List.of(ownEntity, otherEntity)); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2)); + + // 只返回自己创建的实体(另一个被过滤),单节点无边 + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getName()).isEqualTo("My Dataset"); + assertThat(result.getEdges()).isEmpty(); + assertThat(result.getNodeCount()).isEqualTo(1); + } + + @Test + void getSubgraph_nonAdmin_allFiltered_returnsEmptySubgraph() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + GraphEntity otherEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Other Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", OTHER_USER_ID))) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID))) + .thenReturn(List.of(otherEntity)); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID)); + + assertThat(result.getNodes()).isEmpty(); + assertThat(result.getEdges()).isEmpty(); + assertThat(result.getNodeCount()).isEqualTo(0); + } + + @Test + void getSubgraph_nonAdmin_structuralEntitiesVisible() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + // 结构型实体没有 created_by → 对所有用户可见 + GraphEntity structuralEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Default Org").type("Org").graphId(GRAPH_ID) + .properties(new HashMap<>()) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID))) + .thenReturn(List.of(structuralEntity)); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID)); + + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getType()).isEqualTo("Org"); + } + + @Test + void getSubgraph_admin_seesAllEntities() { + // 管理员返回 null → 不过滤 + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(null); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + GraphEntity otherUserEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", "user-1"))) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID))) + .thenReturn(List.of(otherUserEntity)); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID)); + + // 管理员看到其他用户的实体(不被过滤) + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getName()).isEqualTo("Other's Dataset"); + } + + // -- P1-2: 业务实体缺失 created_by(脏数据)被正确拦截 -- + + @Test + void getNeighborGraph_nonAdmin_businessEntityWithoutCreatedBy_throws() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + + // 业务实体缺失 created_by → 应被拦截 + GraphEntity dirtyEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Dirty Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>()) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(dirtyEntity)); + + assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void getSubgraph_nonAdmin_businessEntityWithoutCreatedBy_filtered() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + // 业务实体缺失 created_by → 应被过滤 + GraphEntity dirtyEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Dirty Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>()) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID))) + .thenReturn(List.of(dirtyEntity)); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID)); + + assertThat(result.getNodes()).isEmpty(); + assertThat(result.getNodeCount()).isEqualTo(0); + } + + // -- P1-1: CONFIDENTIAL 敏感度过滤 -- + + @Test + void getNeighborGraph_nonAdmin_confidentialEntity_throwsWithoutPermission() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + // canViewConfidential() 默认返回 false(mock 默认值)→ 无保密权限 + + GraphEntity confidentialEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "CONFIDENTIAL"))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(confidentialEntity)); + + assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void getNeighborGraph_nonAdmin_confidentialEntity_allowedWithPermission() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + when(resourceAccessService.canViewConfidential()).thenReturn(true); + + GraphEntity confidentialEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "CONFIDENTIAL"))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(confidentialEntity)); + when(properties.getMaxDepth()).thenReturn(3); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + // 有保密权限 → 通过安全检查,继续到 Neo4jClient 调用 + try { + queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50); + } catch (BusinessException e) { + throw new AssertionError("Should not be blocked by permission check", e); + } catch (Exception ignored) { + // Neo4jClient mock chain 未完成,预期其他异常 + } + } + + @Test + void getSubgraph_nonAdmin_confidentialEntity_filteredWithoutPermission() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + // canViewConfidential() 默认返回 false → 无保密权限 + + GraphEntity ownNonConfidential = GraphEntity.builder() + .id(ENTITY_ID).name("Normal KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID))) + .build(); + GraphEntity ownConfidential = GraphEntity.builder() + .id(ENTITY_ID_2).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "CONFIDENTIAL"))) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2))) + .thenReturn(List.of(ownNonConfidential, ownConfidential)); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2)); + + // CONFIDENTIAL 实体被过滤,只剩普通实体 + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getName()).isEqualTo("Normal KS"); + } + + @Test + void getSubgraph_nonAdmin_confidentialEntity_visibleWithPermission() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + when(resourceAccessService.canViewConfidential()).thenReturn(true); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + GraphEntity ownConfidential = GraphEntity.builder() + .id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "CONFIDENTIAL"))) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID))) + .thenReturn(List.of(ownConfidential)); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID)); + + // 有保密权限 → 看到 CONFIDENTIAL 实体 + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getName()).isEqualTo("Secret KS"); + } + + // -- P2-2: CONFIDENTIAL 大小写不敏感 -- + + @Test + void getNeighborGraph_nonAdmin_lowercaseConfidential_throwsWithoutPermission() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + + GraphEntity entity = GraphEntity.builder() + .id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "confidential"))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(entity)); + + assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void getNeighborGraph_nonAdmin_mixedCaseConfidentialWithSpaces_throwsWithoutPermission() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + + GraphEntity entity = GraphEntity.builder() + .id(ENTITY_ID).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", " Confidential "))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(entity)); + + assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void getSubgraph_nonAdmin_lowercaseConfidential_filteredWithoutPermission() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn(CURRENT_USER_ID); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + GraphEntity normalKs = GraphEntity.builder() + .id(ENTITY_ID).name("Normal KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID))) + .build(); + GraphEntity lowercaseConfidential = GraphEntity.builder() + .id(ENTITY_ID_2).name("Secret KS").type("KnowledgeSet").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", CURRENT_USER_ID, "sensitivity", "confidential"))) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2))) + .thenReturn(List.of(normalKs, lowercaseConfidential)); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2)); + + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getName()).isEqualTo("Normal KS"); + } + } }