From 37b478a0524f06b96ac4929254e68c2f69b9669c Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 18 Feb 2026 09:25:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(kg):=20=E4=BF=AE=E5=A4=8D=20Codex=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=20P1/P2=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=B9=B6=E8=A1=A5=E5=85=A8=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复内容: P1 级别(关键): 1. 数据隔离漏洞:邻居查询添加 graph_id 路径约束,防止跨图谱数据泄漏 2. 空快照误删风险:添加 allowPurgeOnEmptySnapshot 保护开关(默认 false) 3. 弱默认凭据:启动自检,生产环境检测到默认密码直接拒绝启动 P2 级别(重要): 4. 配置校验:importBatchSize 添加 @Min(1) 验证,启动时 fail-fast 5. N+1 性能:重写 upsertEntity 为单条 Cypher 查询(从 3 条优化到 1 条) 6. 服务认证:添加 mTLS/JWT 文档说明 7. 错误处理:改进 Schema 初始化和序列化错误处理 测试覆盖: - 新增 69 个单元测试,全部通过 - GraphEntityServiceTest: 13 个测试(CRUD、验证、分页) - GraphRelationServiceTest: 13 个测试(CRUD、方向验证) - GraphSyncServiceTest: 5 个测试(验证、全量同步) - GraphSyncStepServiceTest: 14 个测试(空快照保护、N+1 验证) - GraphQueryServiceTest: 13 个测试(邻居/路径/子图/搜索) - GraphInitializerTest: 11 个测试(凭据验证、Schema 初始化) 技术细节: - 数据隔离:使用 ALL() 函数约束路径中所有节点和关系的 graph_id - 空快照保护:新增配置项 allow-purge-on-empty-snapshot 和错误码 EMPTY_SNAPSHOT_PURGE_BLOCKED - 凭据检查:Java 和 Python 双端实现,根据环境(dev/test/prod)采取不同策略 - 性能优化:使用 SDN 复合属性格式(properties.key)在 MERGE 中直接设置属性 - 属性安全:使用白名单 [a-zA-Z0-9_] 防止 Cypher 注入 代码变更:+210 行,-29 行 --- .../services/knowledge-graph-service/pom.xml | 5 + .../application/GraphSyncStepService.java | 80 ++++- .../repository/GraphEntityRepository.java | 5 +- .../repository/GraphRelationRepository.java | 7 +- .../exception/KnowledgeGraphErrorCode.java | 5 +- .../neo4j/GraphInitializer.java | 75 ++++- .../neo4j/KnowledgeGraphProperties.java | 14 +- .../interfaces/rest/GraphSyncController.java | 5 + .../resources/application-knowledgegraph.yml | 2 + .../application/GraphEntityServiceTest.java | 233 +++++++++++++ .../application/GraphQueryServiceTest.java | 177 ++++++++++ .../application/GraphRelationServiceTest.java | 270 +++++++++++++++ .../application/GraphSyncServiceTest.java | 129 +++++++ .../application/GraphSyncStepServiceTest.java | 318 ++++++++++++++++++ .../neo4j/GraphInitializerTest.java | 157 +++++++++ runtime/datamate-python/app/core/config.py | 41 +++ 16 files changed, 1494 insertions(+), 29 deletions(-) create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphEntityServiceTest.java create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphRelationServiceTest.java create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncServiceTest.java create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncStepServiceTest.java create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializerTest.java diff --git a/backend/services/knowledge-graph-service/pom.xml b/backend/services/knowledge-graph-service/pom.xml index 276d770..0667f76 100644 --- a/backend/services/knowledge-graph-service/pom.xml +++ b/backend/services/knowledge-graph-service/pom.xml @@ -104,6 +104,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + 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 1619981..178a10e 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 @@ -149,18 +149,25 @@ public class GraphSyncStepService { * 删除 Neo4j 中 source_type=SYNC 但 source_id 不在活跃集合中的实体。 * 使用 DETACH DELETE 同时清理关联关系。 *

- * 当 activeSourceIds 为空时,说明数据源中该类型实体已全部移除, - * 将清除图中所有对应的 SYNC 实体。调用方需确保空集是数据拉取成功后的 - * 真实结果(fetchDatasetsWithRetry 失败时会抛异常,不会到达此处)。 + * 空快照保护:当 activeSourceIds 为空时,默认拒绝 purge 以防误删全部同步实体。 + * 仅当配置 {@code allowPurgeOnEmptySnapshot=true} 时才允许空集触发 purge。 */ @Transactional public int purgeStaleEntities(String graphId, String type, Set activeSourceIds, String syncId) { + if (activeSourceIds.isEmpty()) { + if (!properties.getSync().isAllowPurgeOnEmptySnapshot()) { + log.warn("[{}] Empty snapshot protection: active source IDs empty for type={}, " + + "purge BLOCKED (set allowPurgeOnEmptySnapshot=true to override)", syncId, type); + return 0; + } + log.warn("[{}] Active source IDs empty for type={}, purging ALL SYNC entities " + + "(allowPurgeOnEmptySnapshot=true)", syncId, type); + } + String cypher; Map params; if (activeSourceIds.isEmpty()) { - log.warn("[{}] Active source IDs empty for type={}, purging ALL SYNC entities of this type", - syncId, type); cypher = "MATCH (e:Entity {graph_id: $graphId, type: $type, source_type: 'SYNC'}) " + "DETACH DELETE e " + "RETURN count(*) AS deleted"; @@ -309,10 +316,13 @@ public class GraphSyncStepService { // ----------------------------------------------------------------------- /** - * 使用 Cypher MERGE 原子创建或匹配实体,再通过 SDN 更新扩展属性。 + * 使用单条 Cypher MERGE 原子创建或匹配实体,同时写入扩展属性。 *

- * MERGE 基于 (graph_id, source_id, type) 复合唯一约束, - * 保证多实例并发写入时不会产生重复节点。 + * 相比之前的 MERGE + find + save(3 次 DB 调用), + * 现在合并为单条 Cypher(1 次 DB 调用),消除 N+1 性能问题。 + *

+ * 扩展属性通过 SDN composite property 格式存储({@code properties.key}), + * 属性键经过字符白名单过滤,防止 Cypher 注入。 */ private void upsertEntity(String graphId, String sourceId, String type, String name, String description, @@ -327,14 +337,31 @@ public class GraphSyncStepService { params.put("name", name != null ? name : ""); params.put("description", description != null ? description : ""); - // Phase 1: Atomic MERGE — 创建或匹配实体骨架 + // 构建扩展属性的 SET 子句,使用 SDN composite property 格式(properties.key) + StringBuilder propSetClauses = new StringBuilder(); + if (props != null) { + int idx = 0; + for (Map.Entry entry : props.entrySet()) { + if (entry.getValue() != null) { + String sanitizedKey = sanitizePropertyKey(entry.getKey()); + if (sanitizedKey.isEmpty()) { + continue; + } + String paramName = "prop" + idx++; + propSetClauses.append(", e.`properties.").append(sanitizedKey).append("` = $").append(paramName); + params.put(paramName, toNeo4jValue(entry.getValue())); + } + } + } + String extraSet = propSetClauses.toString(); + Boolean isNew = neo4jClient.query( "MERGE (e:Entity {graph_id: $graphId, source_id: $sourceId, type: $type}) " + "ON CREATE SET e.id = $newId, e.source_type = 'SYNC', e.confidence = 1.0, " + " e.name = $name, e.description = $description, " + - " e.created_at = datetime(), e.updated_at = datetime() " + + " e.created_at = datetime(), e.updated_at = datetime()" + extraSet + " " + "ON MATCH SET e.name = $name, e.description = $description, " + - " e.updated_at = datetime() " + + " e.updated_at = datetime()" + extraSet + " " + "RETURN e.id = $newId AS isNew" ) .bindAll(params) @@ -343,13 +370,6 @@ public class GraphSyncStepService { .one() .orElse(false); - // Phase 2: 通过 SDN 更新扩展属性(Map 序列化由 SDN 处理) - entityRepository.findByGraphIdAndSourceIdAndType(graphId, sourceId, type) - .ifPresent(entity -> { - entity.setProperties(props != null ? props : new HashMap<>()); - entityRepository.save(entity); - }); - if (isNew) { result.incrementCreated(); } else { @@ -357,6 +377,30 @@ public class GraphSyncStepService { } } + /** + * 清理属性键,仅允许字母、数字和下划线,防止 Cypher 注入。 + */ + private static String sanitizePropertyKey(String key) { + return key.replaceAll("[^a-zA-Z0-9_]", ""); + } + + /** + * 将 Java 值转换为 Neo4j 兼容的属性值。 + *

+ * Neo4j 属性值必须为原始类型或同类型列表, + * 不支持嵌套 Map 或异构列表。 + */ + private static Object toNeo4jValue(Object value) { + if (value instanceof List list) { + if (list.isEmpty()) { + return List.of(); + } + // Neo4j 要求列表元素类型一致,统一转为 String 列表 + return list.stream().map(Object::toString).toList(); + } + return value; + } + /** * 使用 Cypher MERGE 创建或匹配关系,保证幂等性。 * diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java index 52b88b9..f2120dd 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java @@ -31,7 +31,10 @@ public interface GraphEntityRepository extends Neo4jRepository neighbor " + + " AND ALL(n IN nodes(p) WHERE n.graph_id = $graphId) " + + " AND ALL(r IN relationships(p) WHERE r.graph_id = $graphId) " + "RETURN DISTINCT neighbor LIMIT $limit") List findNeighbors( @Param("graphId") String graphId, diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java index 0c29dd8..8b9578b 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java @@ -463,8 +463,8 @@ public class GraphRelationRepository { try { return MAPPER.writeValueAsString(properties); } catch (JsonProcessingException e) { - log.warn("Failed to serialize properties, falling back to empty: {}", e.getMessage()); - return "{}"; + // 序列化失败不应静默吞掉,向上抛出以暴露数据问题 + throw new IllegalArgumentException("Failed to serialize relation properties to JSON", e); } } @@ -475,7 +475,8 @@ public class GraphRelationRepository { try { return MAPPER.readValue(json, MAP_TYPE); } catch (JsonProcessingException e) { - log.warn("Failed to deserialize properties_json, returning empty: {}", e.getMessage()); + log.warn("Failed to deserialize properties_json (returning empty map): json='{}', error={}", + json.length() > 100 ? json.substring(0, 100) + "..." : json, e.getMessage()); return new HashMap<>(); } } diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java index ec76994..7585062 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java @@ -19,7 +19,10 @@ public enum KnowledgeGraphErrorCode implements ErrorCode { IMPORT_FAILED("knowledge_graph.0006", "图谱导入失败"), QUERY_DEPTH_EXCEEDED("knowledge_graph.0007", "查询深度超出限制"), MAX_NODES_EXCEEDED("knowledge_graph.0008", "查询结果节点数超出限制"), - SYNC_FAILED("knowledge_graph.0009", "数据同步失败"); + SYNC_FAILED("knowledge_graph.0009", "数据同步失败"), + EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"), + SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"), + INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码"); private final String code; private final String message; diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializer.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializer.java index b3f588f..ea81ad2 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializer.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializer.java @@ -2,6 +2,7 @@ package com.datamate.knowledgegraph.infrastructure.neo4j; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; @@ -9,6 +10,7 @@ import org.springframework.data.neo4j.core.Neo4jClient; import org.springframework.stereotype.Component; import java.util.List; +import java.util.Set; /** * 图谱 Schema 初始化器。 @@ -17,6 +19,8 @@ import java.util.List; * 所有语句使用 {@code IF NOT EXISTS},保证幂等性。 *

* 对应 {@code docs/knowledge-graph/schema/schema.cypher} 中的第 1-3 部分。 + *

+ * 安全自检:在非开发环境中,检测到默认 Neo4j 密码时拒绝启动。 */ @Component @Slf4j @@ -24,9 +28,25 @@ import java.util.List; @Order(1) public class GraphInitializer implements ApplicationRunner { + /** 已知的弱默认密码,启动时拒绝。 */ + private static final Set BLOCKED_DEFAULT_PASSWORDS = Set.of( + "datamate123", "neo4j", "password", "123456", "admin" + ); + + /** 仅识别「已存在」类错误消息的关键词,其余错误不应吞掉。 */ + private static final Set ALREADY_EXISTS_KEYWORDS = Set.of( + "already exists", "already exist", "EquivalentSchemaRuleAlreadyExists" + ); + private final Neo4jClient neo4jClient; private final KnowledgeGraphProperties properties; + @Value("${spring.neo4j.authentication.password:}") + private String neo4jPassword; + + @Value("${spring.profiles.active:default}") + private String activeProfile; + /** * 需要在启动时执行的 Cypher 语句。 * 每条语句必须独立执行(Neo4j 不支持多条 DDL 在同一事务中)。 @@ -57,6 +77,9 @@ public class GraphInitializer implements ApplicationRunner { @Override public void run(ApplicationArguments args) { + // ── 安全自检:默认凭据检测 ── + validateCredentials(); + if (!properties.getSync().isAutoInitSchema()) { log.info("Schema auto-init is disabled, skipping"); return; @@ -73,17 +96,59 @@ public class GraphInitializer implements ApplicationRunner { succeeded++; log.debug("Schema statement executed: {}", truncate(statement)); } catch (Exception e) { - failed++; - // 约束/索引可能已存在(不同于 IF NOT EXISTS 的实现版本), - // 记录警告但不中断启动。 - log.warn("Schema statement failed (may already exist): {} — {}", - truncate(statement), e.getMessage()); + if (isAlreadyExistsError(e)) { + // 约束/索引已存在,安全跳过 + succeeded++; + log.debug("Schema element already exists (safe to skip): {}", truncate(statement)); + } else { + // 非「已存在」错误:记录并抛出,阻止启动 + failed++; + log.error("Schema statement FAILED: {} — {}", truncate(statement), e.getMessage()); + throw new IllegalStateException( + "Neo4j schema initialization failed: " + truncate(statement), e); + } } } log.info("Neo4j schema initialization completed: succeeded={}, failed={}", succeeded, failed); } + /** + * 检测是否使用了默认凭据。 + *

+ * 在 dev/test 环境中仅发出警告,在其他环境(prod、staging 等)中直接拒绝启动。 + */ + private void validateCredentials() { + if (neo4jPassword == null || neo4jPassword.isBlank()) { + return; + } + if (BLOCKED_DEFAULT_PASSWORDS.contains(neo4jPassword)) { + boolean isDev = activeProfile.contains("dev") || activeProfile.contains("test") + || activeProfile.contains("local"); + if (isDev) { + log.warn("⚠ Neo4j is using a WEAK DEFAULT password. " + + "This is acceptable in dev/test but MUST be changed for production."); + } else { + throw new IllegalStateException( + "SECURITY: Neo4j password is set to a known default ('" + neo4jPassword + "'). " + + "Production environments MUST use a strong, unique password. " + + "Set the NEO4J_PASSWORD environment variable to a secure value."); + } + } + } + + /** + * 判断异常是否仅因为 Schema 元素已存在(安全可忽略)。 + */ + private static boolean isAlreadyExistsError(Exception e) { + String msg = e.getMessage(); + if (msg == null) { + return false; + } + String lowerMsg = msg.toLowerCase(); + return ALREADY_EXISTS_KEYWORDS.stream().anyMatch(kw -> lowerMsg.contains(kw.toLowerCase())); + } + private static String truncate(String s) { return s.length() <= 100 ? s : s.substring(0, 97) + "..."; } diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java index 48a3c90..82d7cdc 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java @@ -1,11 +1,14 @@ package com.datamate.knowledgegraph.infrastructure.neo4j; +import jakarta.validation.constraints.Min; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; @Data @Component +@Validated @ConfigurationProperties(prefix = "datamate.knowledge-graph") public class KnowledgeGraphProperties { @@ -15,7 +18,8 @@ public class KnowledgeGraphProperties { /** 子图返回最大节点数 */ private int maxNodesPerQuery = 500; - /** 批量导入批次大小 */ + /** 批量导入批次大小(必须 >= 1,否则取模运算会抛异常) */ + @Min(value = 1, message = "importBatchSize 必须 >= 1") private int importBatchSize = 100; /** 同步相关配置 */ @@ -44,5 +48,13 @@ public class KnowledgeGraphProperties { /** 是否在启动时自动初始化 Schema */ private boolean autoInitSchema = true; + + /** + * 是否允许空快照触发 purge(默认 false)。 + *

+ * 当上游返回空列表时,如果该开关为 false,purge 将被跳过以防误删全部同步实体。 + * 仅在确认数据源确实为空时才应开启此开关。 + */ + private boolean allowPurgeOnEmptySnapshot = false; } } diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java index 8b788e3..bd9bb07 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java @@ -15,6 +15,11 @@ import java.util.List; *

* 提供手动触发 MySQL → Neo4j 同步的 REST 端点。 * 生产环境中也可通过定时任务自动触发。 + *

+ * 安全说明:本接口仅供内部服务调用(API Gateway / 定时任务), + * 外部请求必须经 API Gateway 鉴权后转发。 + * 生产环境建议通过 mTLS 或内部 JWT 进一步加固服务间认证。 + * 当前通过 {@code X-Internal-Token} 请求头进行简单的内部调用校验。 */ @RestController @RequestMapping("/knowledge-graph/{graphId}/sync") diff --git a/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml b/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml index 7a59563..3dd3b01 100644 --- a/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml +++ b/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml @@ -39,3 +39,5 @@ datamate: retry-interval: ${KG_SYNC_RETRY_INTERVAL:1000} # 是否在启动时自动初始化 Schema auto-init-schema: ${KG_AUTO_INIT_SCHEMA:true} + # 是否允许空快照触发 purge(默认 false,防止上游返回空列表时误删全部同步实体) + allow-purge-on-empty-snapshot: ${KG_ALLOW_PURGE_ON_EMPTY_SNAPSHOT:false} diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphEntityServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphEntityServiceTest.java new file mode 100644 index 0000000..624c6f4 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphEntityServiceTest.java @@ -0,0 +1,233 @@ +package com.datamate.knowledgegraph.application; + +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.CreateEntityRequest; +import com.datamate.knowledgegraph.interfaces.dto.UpdateEntityRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GraphEntityServiceTest { + + private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001"; + private static final String INVALID_GRAPH_ID = "not-a-uuid"; + + @Mock + private GraphEntityRepository entityRepository; + + @Mock + private KnowledgeGraphProperties properties; + + @InjectMocks + private GraphEntityService entityService; + + private GraphEntity sampleEntity; + + @BeforeEach + void setUp() { + sampleEntity = GraphEntity.builder() + .id(ENTITY_ID) + .name("TestDataset") + .type("Dataset") + .description("A test dataset") + .graphId(GRAPH_ID) + .confidence(1.0) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + // ----------------------------------------------------------------------- + // graphId 校验 + // ----------------------------------------------------------------------- + + @Test + void getEntity_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> entityService.getEntity(INVALID_GRAPH_ID, ENTITY_ID)) + .isInstanceOf(BusinessException.class); + } + + @Test + void getEntity_nullGraphId_throwsBusinessException() { + assertThatThrownBy(() -> entityService.getEntity(null, ENTITY_ID)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // createEntity + // ----------------------------------------------------------------------- + + @Test + void createEntity_success() { + CreateEntityRequest request = new CreateEntityRequest(); + request.setName("NewEntity"); + request.setType("Dataset"); + request.setDescription("Desc"); + + when(entityRepository.save(any(GraphEntity.class))).thenReturn(sampleEntity); + + GraphEntity result = entityService.createEntity(GRAPH_ID, request); + + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("TestDataset"); + verify(entityRepository).save(any(GraphEntity.class)); + } + + // ----------------------------------------------------------------------- + // getEntity + // ----------------------------------------------------------------------- + + @Test + void getEntity_found() { + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sampleEntity)); + + GraphEntity result = entityService.getEntity(GRAPH_ID, ENTITY_ID); + + assertThat(result.getId()).isEqualTo(ENTITY_ID); + assertThat(result.getName()).isEqualTo("TestDataset"); + } + + @Test + void getEntity_notFound_throwsBusinessException() { + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> entityService.getEntity(GRAPH_ID, ENTITY_ID)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // listEntities + // ----------------------------------------------------------------------- + + @Test + void listEntities_returnsAll() { + when(entityRepository.findByGraphId(GRAPH_ID)) + .thenReturn(List.of(sampleEntity)); + + List results = entityService.listEntities(GRAPH_ID); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getName()).isEqualTo("TestDataset"); + } + + // ----------------------------------------------------------------------- + // updateEntity + // ----------------------------------------------------------------------- + + @Test + void updateEntity_partialUpdate_onlyChangesProvidedFields() { + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sampleEntity)); + when(entityRepository.save(any(GraphEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + UpdateEntityRequest request = new UpdateEntityRequest(); + request.setName("UpdatedName"); + // description not set — should remain unchanged + + GraphEntity result = entityService.updateEntity(GRAPH_ID, ENTITY_ID, request); + + assertThat(result.getName()).isEqualTo("UpdatedName"); + assertThat(result.getDescription()).isEqualTo("A test dataset"); + } + + // ----------------------------------------------------------------------- + // deleteEntity + // ----------------------------------------------------------------------- + + @Test + void deleteEntity_success() { + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sampleEntity)); + + entityService.deleteEntity(GRAPH_ID, ENTITY_ID); + + verify(entityRepository).delete(sampleEntity); + } + + @Test + void deleteEntity_notFound_throwsBusinessException() { + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> entityService.deleteEntity(GRAPH_ID, ENTITY_ID)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // getNeighbors — 深度/限制 clamping + // ----------------------------------------------------------------------- + + @Test + void getNeighbors_clampsDepthAndLimit() { + when(properties.getMaxDepth()).thenReturn(3); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + when(entityRepository.findNeighbors(eq(GRAPH_ID), eq(ENTITY_ID), eq(3), eq(500))) + .thenReturn(List.of()); + + List result = entityService.getNeighbors(GRAPH_ID, ENTITY_ID, 100, 99999); + + assertThat(result).isEmpty(); + // depth clamped to maxDepth=3, limit clamped to maxNodesPerQuery=500 + verify(entityRepository).findNeighbors(GRAPH_ID, ENTITY_ID, 3, 500); + } + + // ----------------------------------------------------------------------- + // 分页 + // ----------------------------------------------------------------------- + + @Test + void listEntitiesPaged_normalPage() { + when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 20)) + .thenReturn(List.of(sampleEntity)); + when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(1L); + + var result = entityService.listEntitiesPaged(GRAPH_ID, 0, 20); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + void listEntitiesPaged_negativePage_clampedToZero() { + when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 20)) + .thenReturn(List.of()); + when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(0L); + + var result = entityService.listEntitiesPaged(GRAPH_ID, -1, 20); + + assertThat(result.getPage()).isEqualTo(0); + } + + @Test + void listEntitiesPaged_oversizedPage_clampedTo200() { + when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 200)) + .thenReturn(List.of()); + when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(0L); + + entityService.listEntitiesPaged(GRAPH_ID, 0, 999); + + verify(entityRepository).findByGraphIdPaged(GRAPH_ID, 0L, 200); + } +} 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 new file mode 100644 index 0000000..3660237 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java @@ -0,0 +1,177 @@ +package com.datamate.knowledgegraph.application; + +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.neo4j.core.Neo4jClient; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GraphQueryServiceTest { + + private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001"; + private static final String ENTITY_ID_2 = "660e8400-e29b-41d4-a716-446655440002"; + private static final String INVALID_GRAPH_ID = "bad-id"; + + @Mock + private Neo4jClient neo4jClient; + + @Mock + private GraphEntityRepository entityRepository; + + @Mock + private KnowledgeGraphProperties properties; + + @InjectMocks + private GraphQueryService queryService; + + @BeforeEach + void setUp() { + } + + // ----------------------------------------------------------------------- + // graphId 校验 + // ----------------------------------------------------------------------- + + @Test + void getNeighborGraph_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> queryService.getNeighborGraph(INVALID_GRAPH_ID, ENTITY_ID, 2, 50)) + .isInstanceOf(BusinessException.class); + } + + @Test + void getShortestPath_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> queryService.getShortestPath(INVALID_GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3)) + .isInstanceOf(BusinessException.class); + } + + @Test + void getSubgraph_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> queryService.getSubgraph(INVALID_GRAPH_ID, List.of(ENTITY_ID))) + .isInstanceOf(BusinessException.class); + } + + @Test + void fulltextSearch_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> queryService.fulltextSearch(INVALID_GRAPH_ID, "test", 0, 20)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // getNeighborGraph — 实体不存在 + // ----------------------------------------------------------------------- + + @Test + void getNeighborGraph_entityNotFound_throwsBusinessException() { + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // getShortestPath — 起止相同 + // ----------------------------------------------------------------------- + + @Test + void getShortestPath_sameSourceAndTarget_returnsSingleNode() { + GraphEntity entity = GraphEntity.builder() + .id(ENTITY_ID).name("Node").type("Dataset").graphId(GRAPH_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.getEdges()).isEmpty(); + } + + @Test + void getShortestPath_sourceNotFound_throwsBusinessException() { + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // getSubgraph — 空输入 + // ----------------------------------------------------------------------- + + @Test + void getSubgraph_nullEntityIds_returnsEmptySubgraph() { + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, null); + + assertThat(result.getNodes()).isEmpty(); + assertThat(result.getEdges()).isEmpty(); + assertThat(result.getNodeCount()).isEqualTo(0); + } + + @Test + void getSubgraph_emptyEntityIds_returnsEmptySubgraph() { + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of()); + + assertThat(result.getNodes()).isEmpty(); + assertThat(result.getEdges()).isEmpty(); + } + + @Test + void getSubgraph_exceedsMaxNodes_throwsBusinessException() { + when(properties.getMaxNodesPerQuery()).thenReturn(5); + + List tooManyIds = List.of("1", "2", "3", "4", "5", "6"); + + assertThatThrownBy(() -> queryService.getSubgraph(GRAPH_ID, tooManyIds)) + .isInstanceOf(BusinessException.class); + } + + @Test + void getSubgraph_noExistingEntities_returnsEmptySubgraph() { + when(properties.getMaxNodesPerQuery()).thenReturn(500); + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID))) + .thenReturn(List.of()); + + SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID)); + + assertThat(result.getNodes()).isEmpty(); + } + + // ----------------------------------------------------------------------- + // fulltextSearch — 空查询 + // ----------------------------------------------------------------------- + + @Test + void fulltextSearch_blankQuery_returnsEmpty() { + var result = queryService.fulltextSearch(GRAPH_ID, "", 0, 20); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + verifyNoInteractions(neo4jClient); + } + + @Test + void fulltextSearch_nullQuery_returnsEmpty() { + var result = queryService.fulltextSearch(GRAPH_ID, null, 0, 20); + + assertThat(result.getContent()).isEmpty(); + } +} diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphRelationServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphRelationServiceTest.java new file mode 100644 index 0000000..f193298 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphRelationServiceTest.java @@ -0,0 +1,270 @@ +package com.datamate.knowledgegraph.application; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.knowledgegraph.domain.model.GraphEntity; +import com.datamate.knowledgegraph.domain.model.RelationDetail; +import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository; +import com.datamate.knowledgegraph.domain.repository.GraphRelationRepository; +import com.datamate.knowledgegraph.interfaces.dto.CreateRelationRequest; +import com.datamate.knowledgegraph.interfaces.dto.RelationVO; +import com.datamate.knowledgegraph.interfaces.dto.UpdateRelationRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GraphRelationServiceTest { + + private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String RELATION_ID = "770e8400-e29b-41d4-a716-446655440002"; + private static final String SOURCE_ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001"; + private static final String TARGET_ENTITY_ID = "660e8400-e29b-41d4-a716-446655440003"; + private static final String INVALID_GRAPH_ID = "not-a-uuid"; + + @Mock + private GraphRelationRepository relationRepository; + + @Mock + private GraphEntityRepository entityRepository; + + @InjectMocks + private GraphRelationService relationService; + + private RelationDetail sampleDetail; + private GraphEntity sourceEntity; + private GraphEntity targetEntity; + + @BeforeEach + void setUp() { + sampleDetail = RelationDetail.builder() + .id(RELATION_ID) + .sourceEntityId(SOURCE_ENTITY_ID) + .sourceEntityName("Source") + .sourceEntityType("Dataset") + .targetEntityId(TARGET_ENTITY_ID) + .targetEntityName("Target") + .targetEntityType("Field") + .relationType("HAS_FIELD") + .properties(Map.of()) + .weight(1.0) + .confidence(1.0) + .graphId(GRAPH_ID) + .createdAt(LocalDateTime.now()) + .build(); + + sourceEntity = GraphEntity.builder() + .id(SOURCE_ENTITY_ID).name("Source").type("Dataset").graphId(GRAPH_ID).build(); + targetEntity = GraphEntity.builder() + .id(TARGET_ENTITY_ID).name("Target").type("Field").graphId(GRAPH_ID).build(); + } + + // ----------------------------------------------------------------------- + // graphId 校验 + // ----------------------------------------------------------------------- + + @Test + void getRelation_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> relationService.getRelation(INVALID_GRAPH_ID, RELATION_ID)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // createRelation + // ----------------------------------------------------------------------- + + @Test + void createRelation_success() { + when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + when(entityRepository.findByIdAndGraphId(TARGET_ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(targetEntity)); + when(relationRepository.create(eq(GRAPH_ID), eq(SOURCE_ENTITY_ID), eq(TARGET_ENTITY_ID), + eq("HAS_FIELD"), anyMap(), isNull(), isNull(), isNull())) + .thenReturn(Optional.of(sampleDetail)); + + CreateRelationRequest request = new CreateRelationRequest(); + request.setSourceEntityId(SOURCE_ENTITY_ID); + request.setTargetEntityId(TARGET_ENTITY_ID); + request.setRelationType("HAS_FIELD"); + + RelationVO result = relationService.createRelation(GRAPH_ID, request); + + assertThat(result.getId()).isEqualTo(RELATION_ID); + assertThat(result.getRelationType()).isEqualTo("HAS_FIELD"); + assertThat(result.getSourceEntityId()).isEqualTo(SOURCE_ENTITY_ID); + assertThat(result.getTargetEntityId()).isEqualTo(TARGET_ENTITY_ID); + } + + @Test + void createRelation_sourceNotFound_throwsBusinessException() { + when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + CreateRelationRequest request = new CreateRelationRequest(); + request.setSourceEntityId(SOURCE_ENTITY_ID); + request.setTargetEntityId(TARGET_ENTITY_ID); + request.setRelationType("HAS_FIELD"); + + assertThatThrownBy(() -> relationService.createRelation(GRAPH_ID, request)) + .isInstanceOf(BusinessException.class); + } + + @Test + void createRelation_targetNotFound_throwsBusinessException() { + when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + when(entityRepository.findByIdAndGraphId(TARGET_ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + CreateRelationRequest request = new CreateRelationRequest(); + request.setSourceEntityId(SOURCE_ENTITY_ID); + request.setTargetEntityId(TARGET_ENTITY_ID); + request.setRelationType("HAS_FIELD"); + + assertThatThrownBy(() -> relationService.createRelation(GRAPH_ID, request)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // getRelation + // ----------------------------------------------------------------------- + + @Test + void getRelation_found() { + when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID)) + .thenReturn(Optional.of(sampleDetail)); + + RelationVO result = relationService.getRelation(GRAPH_ID, RELATION_ID); + + assertThat(result.getId()).isEqualTo(RELATION_ID); + } + + @Test + void getRelation_notFound_throwsBusinessException() { + when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> relationService.getRelation(GRAPH_ID, RELATION_ID)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // listRelations (分页) + // ----------------------------------------------------------------------- + + @Test + void listRelations_returnsPaged() { + when(relationRepository.findByGraphId(GRAPH_ID, null, 0L, 20)) + .thenReturn(List.of(sampleDetail)); + when(relationRepository.countByGraphId(GRAPH_ID, null)) + .thenReturn(1L); + + var result = relationService.listRelations(GRAPH_ID, null, 0, 20); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + void listRelations_oversizedPage_clampedTo200() { + when(relationRepository.findByGraphId(GRAPH_ID, null, 0L, 200)) + .thenReturn(List.of()); + when(relationRepository.countByGraphId(GRAPH_ID, null)) + .thenReturn(0L); + + relationService.listRelations(GRAPH_ID, null, 0, 999); + + verify(relationRepository).findByGraphId(GRAPH_ID, null, 0L, 200); + } + + // ----------------------------------------------------------------------- + // listEntityRelations — direction 校验 + // ----------------------------------------------------------------------- + + @Test + void listEntityRelations_invalidDirection_throwsBusinessException() { + when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + + assertThatThrownBy(() -> + relationService.listEntityRelations(GRAPH_ID, SOURCE_ENTITY_ID, "invalid", null, 0, 20)) + .isInstanceOf(BusinessException.class); + } + + @Test + void listEntityRelations_inDirection() { + when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + when(relationRepository.findInboundByEntityId(GRAPH_ID, SOURCE_ENTITY_ID, null, 0L, 20)) + .thenReturn(List.of(sampleDetail)); + when(relationRepository.countByEntityId(GRAPH_ID, SOURCE_ENTITY_ID, null, "in")) + .thenReturn(1L); + + var result = relationService.listEntityRelations( + GRAPH_ID, SOURCE_ENTITY_ID, "in", null, 0, 20); + + assertThat(result.getContent()).hasSize(1); + } + + // ----------------------------------------------------------------------- + // updateRelation + // ----------------------------------------------------------------------- + + @Test + void updateRelation_success() { + when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID)) + .thenReturn(Optional.of(sampleDetail)); + RelationDetail updated = RelationDetail.builder() + .id(RELATION_ID).relationType("USES").weight(0.8) + .sourceEntityId(SOURCE_ENTITY_ID).targetEntityId(TARGET_ENTITY_ID) + .graphId(GRAPH_ID).build(); + when(relationRepository.update(eq(RELATION_ID), eq(GRAPH_ID), eq("USES"), isNull(), eq(0.8), isNull())) + .thenReturn(Optional.of(updated)); + + UpdateRelationRequest request = new UpdateRelationRequest(); + request.setRelationType("USES"); + request.setWeight(0.8); + + RelationVO result = relationService.updateRelation(GRAPH_ID, RELATION_ID, request); + + assertThat(result.getRelationType()).isEqualTo("USES"); + } + + // ----------------------------------------------------------------------- + // deleteRelation + // ----------------------------------------------------------------------- + + @Test + void deleteRelation_success() { + when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID)) + .thenReturn(Optional.of(sampleDetail)); + when(relationRepository.deleteByIdAndGraphId(RELATION_ID, GRAPH_ID)) + .thenReturn(1L); + + relationService.deleteRelation(GRAPH_ID, RELATION_ID); + + verify(relationRepository).deleteByIdAndGraphId(RELATION_ID, GRAPH_ID); + } + + @Test + void deleteRelation_notFound_throwsBusinessException() { + when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> relationService.deleteRelation(GRAPH_ID, RELATION_ID)) + .isInstanceOf(BusinessException.class); + } +} diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncServiceTest.java new file mode 100644 index 0000000..2ff794c --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncServiceTest.java @@ -0,0 +1,129 @@ +package com.datamate.knowledgegraph.application; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.knowledgegraph.domain.model.SyncResult; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.DatasetDTO; +import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GraphSyncServiceTest { + + private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String INVALID_GRAPH_ID = "bad-id"; + + @Mock + private GraphSyncStepService stepService; + + @Mock + private DataManagementClient dataManagementClient; + + @Mock + private KnowledgeGraphProperties properties; + + @InjectMocks + private GraphSyncService syncService; + + private KnowledgeGraphProperties.Sync syncConfig; + + @BeforeEach + void setUp() { + syncConfig = new KnowledgeGraphProperties.Sync(); + syncConfig.setMaxRetries(1); + syncConfig.setRetryInterval(10); + } + + // ----------------------------------------------------------------------- + // graphId 校验 + // ----------------------------------------------------------------------- + + @Test + void syncAll_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> syncService.syncAll(INVALID_GRAPH_ID)) + .isInstanceOf(BusinessException.class); + } + + @Test + void syncAll_nullGraphId_throwsBusinessException() { + assertThatThrownBy(() -> syncService.syncAll(null)) + .isInstanceOf(BusinessException.class); + } + + @Test + void syncDatasets_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> syncService.syncDatasets(INVALID_GRAPH_ID)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // syncAll — 正常流程 + // ----------------------------------------------------------------------- + + @Test + void syncAll_success_returnsResultList() { + when(properties.getSync()).thenReturn(syncConfig); + + DatasetDTO dto = new DatasetDTO(); + dto.setId("ds-001"); + dto.setName("Test"); + dto.setCreatedBy("admin"); + when(dataManagementClient.listAllDatasets()).thenReturn(List.of(dto)); + + SyncResult entityResult = SyncResult.builder().syncType("Dataset").build(); + SyncResult fieldResult = SyncResult.builder().syncType("Field").build(); + SyncResult userResult = SyncResult.builder().syncType("User").build(); + SyncResult orgResult = SyncResult.builder().syncType("Org").build(); + SyncResult hasFieldResult = SyncResult.builder().syncType("HAS_FIELD").build(); + SyncResult derivedFromResult = SyncResult.builder().syncType("DERIVED_FROM").build(); + SyncResult belongsToResult = SyncResult.builder().syncType("BELONGS_TO").build(); + + when(stepService.upsertDatasetEntities(eq(GRAPH_ID), anyList(), anyString())) + .thenReturn(entityResult); + when(stepService.upsertFieldEntities(eq(GRAPH_ID), anyList(), anyString())) + .thenReturn(fieldResult); + when(stepService.upsertUserEntities(eq(GRAPH_ID), anySet(), anyString())) + .thenReturn(userResult); + when(stepService.upsertOrgEntities(eq(GRAPH_ID), anyString())) + .thenReturn(orgResult); + when(stepService.purgeStaleEntities(eq(GRAPH_ID), anyString(), anySet(), anyString())) + .thenReturn(0); + when(stepService.mergeHasFieldRelations(eq(GRAPH_ID), anyString())) + .thenReturn(hasFieldResult); + when(stepService.mergeDerivedFromRelations(eq(GRAPH_ID), anyString())) + .thenReturn(derivedFromResult); + when(stepService.mergeBelongsToRelations(eq(GRAPH_ID), anyString())) + .thenReturn(belongsToResult); + + List results = syncService.syncAll(GRAPH_ID); + + assertThat(results).hasSize(7); + assertThat(results.get(0).getSyncType()).isEqualTo("Dataset"); + } + + // ----------------------------------------------------------------------- + // syncAll — 正常流程 + // ----------------------------------------------------------------------- + + @Test + void syncDatasets_fetchRetryExhausted_throwsBusinessException() { + when(properties.getSync()).thenReturn(syncConfig); + when(dataManagementClient.listAllDatasets()).thenThrow(new RuntimeException("connection refused")); + + assertThatThrownBy(() -> syncService.syncDatasets(GRAPH_ID)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("拉取数据集失败"); + } +} diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncStepServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncStepServiceTest.java new file mode 100644 index 0000000..7ea4bc1 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncStepServiceTest.java @@ -0,0 +1,318 @@ +package com.datamate.knowledgegraph.application; + +import com.datamate.knowledgegraph.domain.model.GraphEntity; +import com.datamate.knowledgegraph.domain.model.SyncResult; +import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.DatasetDTO; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.TagDTO; +import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties; +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.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.neo4j.core.Neo4jClient; +import org.springframework.data.neo4j.core.Neo4jClient.UnboundRunnableSpec; +import org.springframework.data.neo4j.core.Neo4jClient.RunnableSpec; +import org.springframework.data.neo4j.core.Neo4jClient.RecordFetchSpec; +import org.springframework.data.neo4j.core.Neo4jClient.MappingSpec; + +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GraphSyncStepServiceTest { + + private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String SYNC_ID = "test-sync"; + + @Mock + private GraphEntityRepository entityRepository; + + @Mock + private Neo4jClient neo4jClient; + + @Mock + private KnowledgeGraphProperties properties; + + @InjectMocks + private GraphSyncStepService stepService; + + @Captor + private ArgumentCaptor cypherCaptor; + + @BeforeEach + void setUp() { + } + + // ----------------------------------------------------------------------- + // Neo4jClient mock chain helper + // ----------------------------------------------------------------------- + + /** + * Build a full mock chain for neo4jClient.query(...).bindAll(...).fetchAs(Long).mappedBy(...).one() + */ + @SuppressWarnings("unchecked") + private void setupNeo4jQueryChain(Class fetchType, Object returnValue) { + UnboundRunnableSpec unboundSpec = mock(UnboundRunnableSpec.class); + RunnableSpec runnableSpec = mock(RunnableSpec.class); + MappingSpec mappingSpec = mock(MappingSpec.class); + RecordFetchSpec fetchSpec = mock(RecordFetchSpec.class); + + when(neo4jClient.query(anyString())).thenReturn(unboundSpec); + when(unboundSpec.bindAll(anyMap())).thenReturn(runnableSpec); + when(runnableSpec.fetchAs(any(Class.class))).thenReturn(mappingSpec); + when(mappingSpec.mappedBy(any())).thenReturn(fetchSpec); + when(fetchSpec.one()).thenReturn(Optional.ofNullable(returnValue)); + } + + // ----------------------------------------------------------------------- + // purgeStaleEntities — P1-2 空快照保护 + // ----------------------------------------------------------------------- + + @Nested + class PurgeStaleEntitiesTest { + + @Test + void emptySnapshot_defaultConfig_blocksPurge() { + KnowledgeGraphProperties.Sync syncConfig = new KnowledgeGraphProperties.Sync(); + syncConfig.setAllowPurgeOnEmptySnapshot(false); + when(properties.getSync()).thenReturn(syncConfig); + + int deleted = stepService.purgeStaleEntities( + GRAPH_ID, "Dataset", Collections.emptySet(), SYNC_ID); + + assertThat(deleted).isEqualTo(0); + // Should NOT execute any Cypher query + verifyNoInteractions(neo4jClient); + } + + @Test + void emptySnapshot_explicitAllow_executesPurge() { + KnowledgeGraphProperties.Sync syncConfig = new KnowledgeGraphProperties.Sync(); + syncConfig.setAllowPurgeOnEmptySnapshot(true); + when(properties.getSync()).thenReturn(syncConfig); + setupNeo4jQueryChain(Long.class, 5L); + + int deleted = stepService.purgeStaleEntities( + GRAPH_ID, "Dataset", Collections.emptySet(), SYNC_ID); + + assertThat(deleted).isEqualTo(5); + verify(neo4jClient).query(anyString()); + } + + @Test + void nonEmptySnapshot_purgesStaleEntities() { + setupNeo4jQueryChain(Long.class, 2L); + + Set activeIds = Set.of("ds-001", "ds-002"); + int deleted = stepService.purgeStaleEntities( + GRAPH_ID, "Dataset", activeIds, SYNC_ID); + + assertThat(deleted).isEqualTo(2); + verify(neo4jClient).query(contains("NOT e.source_id IN $activeSourceIds")); + } + + @Test + void nonEmptySnapshot_nothingToDelete_returnsZero() { + setupNeo4jQueryChain(Long.class, 0L); + + Set activeIds = Set.of("ds-001"); + int deleted = stepService.purgeStaleEntities( + GRAPH_ID, "Dataset", activeIds, SYNC_ID); + + assertThat(deleted).isEqualTo(0); + } + } + + // ----------------------------------------------------------------------- + // upsertDatasetEntities — P2-5 单条 Cypher 优化 + // ----------------------------------------------------------------------- + + @Nested + class UpsertDatasetEntitiesTest { + + @Test + void upsert_newEntity_incrementsCreated() { + when(properties.getImportBatchSize()).thenReturn(100); + setupNeo4jQueryChain(Boolean.class, true); + + DatasetDTO dto = new DatasetDTO(); + dto.setId("ds-001"); + dto.setName("Test Dataset"); + dto.setDescription("Desc"); + dto.setDatasetType("TEXT"); + dto.setStatus("ACTIVE"); + + SyncResult result = stepService.upsertDatasetEntities( + GRAPH_ID, List.of(dto), SYNC_ID); + + assertThat(result.getCreated()).isEqualTo(1); + assertThat(result.getUpdated()).isEqualTo(0); + assertThat(result.getSyncType()).isEqualTo("Dataset"); + } + + @Test + void upsert_existingEntity_incrementsUpdated() { + when(properties.getImportBatchSize()).thenReturn(100); + setupNeo4jQueryChain(Boolean.class, false); + + DatasetDTO dto = new DatasetDTO(); + dto.setId("ds-001"); + dto.setName("Updated"); + + SyncResult result = stepService.upsertDatasetEntities( + GRAPH_ID, List.of(dto), SYNC_ID); + + assertThat(result.getCreated()).isEqualTo(0); + assertThat(result.getUpdated()).isEqualTo(1); + } + + @Test + void upsert_emptyList_returnsZeroCounts() { + when(properties.getImportBatchSize()).thenReturn(100); + + SyncResult result = stepService.upsertDatasetEntities( + GRAPH_ID, List.of(), SYNC_ID); + + assertThat(result.getCreated()).isEqualTo(0); + assertThat(result.getUpdated()).isEqualTo(0); + verifyNoInteractions(neo4jClient); + } + + @Test + void upsert_cypher_containsPropertiesSetClauses() { + when(properties.getImportBatchSize()).thenReturn(100); + setupNeo4jQueryChain(Boolean.class, true); + + DatasetDTO dto = new DatasetDTO(); + dto.setId("ds-001"); + dto.setName("Dataset"); + dto.setDatasetType("TEXT"); + dto.setStatus("ACTIVE"); + + stepService.upsertDatasetEntities(GRAPH_ID, List.of(dto), SYNC_ID); + + // Verify the MERGE Cypher includes property SET clauses (N+1 fix) + verify(neo4jClient).query(cypherCaptor.capture()); + String cypher = cypherCaptor.getValue(); + assertThat(cypher).contains("MERGE"); + assertThat(cypher).contains("properties."); + // Verify NO separate find+save (N+1 eliminated) + verifyNoInteractions(entityRepository); + } + + @Test + void upsert_multipleEntities_eachGetsSeparateMerge() { + when(properties.getImportBatchSize()).thenReturn(100); + setupNeo4jQueryChain(Boolean.class, true); + + DatasetDTO dto1 = new DatasetDTO(); + dto1.setId("ds-001"); + dto1.setName("DS1"); + DatasetDTO dto2 = new DatasetDTO(); + dto2.setId("ds-002"); + dto2.setName("DS2"); + + SyncResult result = stepService.upsertDatasetEntities( + GRAPH_ID, List.of(dto1, dto2), SYNC_ID); + + assertThat(result.getCreated()).isEqualTo(2); + verify(neo4jClient, times(2)).query(anyString()); + } + } + + // ----------------------------------------------------------------------- + // upsertFieldEntities + // ----------------------------------------------------------------------- + + @Nested + class UpsertFieldEntitiesTest { + + @Test + void upsertFields_datasetsWithNoTags_returnsZero() { + DatasetDTO dto = new DatasetDTO(); + dto.setId("ds-001"); + dto.setTags(null); + + SyncResult result = stepService.upsertFieldEntities( + GRAPH_ID, List.of(dto), SYNC_ID); + + assertThat(result.getCreated()).isEqualTo(0); + assertThat(result.getUpdated()).isEqualTo(0); + } + + @Test + void upsertFields_datasetsWithTags_createsFieldPerTag() { + setupNeo4jQueryChain(Boolean.class, true); + + DatasetDTO dto = new DatasetDTO(); + dto.setId("ds-001"); + dto.setName("Dataset1"); + TagDTO tag1 = new TagDTO(); + tag1.setName("tag_a"); + TagDTO tag2 = new TagDTO(); + tag2.setName("tag_b"); + dto.setTags(List.of(tag1, tag2)); + + SyncResult result = stepService.upsertFieldEntities( + GRAPH_ID, List.of(dto), SYNC_ID); + + assertThat(result.getCreated()).isEqualTo(2); + } + } + + // ----------------------------------------------------------------------- + // 关系构建 + // ----------------------------------------------------------------------- + + @Nested + class MergeRelationsTest { + + @Test + void mergeHasField_noFields_returnsEmptyResult() { + when(entityRepository.findByGraphIdAndType(GRAPH_ID, "Field")) + .thenReturn(List.of()); + + SyncResult result = stepService.mergeHasFieldRelations(GRAPH_ID, SYNC_ID); + + assertThat(result.getSyncType()).isEqualTo("HAS_FIELD"); + assertThat(result.getCreated()).isEqualTo(0); + } + + @Test + void mergeDerivedFrom_noParent_skipsRelation() { + GraphEntity dataset = GraphEntity.builder() + .id("entity-1") + .type("Dataset") + .graphId(GRAPH_ID) + .properties(new HashMap<>()) // no parent_dataset_id + .build(); + + when(entityRepository.findByGraphIdAndType(GRAPH_ID, "Dataset")) + .thenReturn(List.of(dataset)); + + SyncResult result = stepService.mergeDerivedFromRelations(GRAPH_ID, SYNC_ID); + + assertThat(result.getCreated()).isEqualTo(0); + } + + @Test + void mergeBelongsTo_noDefaultOrg_returnsError() { + when(entityRepository.findByGraphIdAndSourceIdAndType(GRAPH_ID, "org:default", "Org")) + .thenReturn(Optional.empty()); + + SyncResult result = stepService.mergeBelongsToRelations(GRAPH_ID, SYNC_ID); + + assertThat(result.getFailed()).isGreaterThan(0); + assertThat(result.getErrors()).contains("belongs_to:org_missing"); + } + } +} diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializerTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializerTest.java new file mode 100644 index 0000000..2c9a450 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializerTest.java @@ -0,0 +1,157 @@ +package com.datamate.knowledgegraph.infrastructure.neo4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.data.neo4j.core.Neo4jClient; +import org.springframework.data.neo4j.core.Neo4jClient.UnboundRunnableSpec; +import org.springframework.data.neo4j.core.Neo4jClient.RunnableSpec; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GraphInitializerTest { + + @Mock + private Neo4jClient neo4jClient; + + private GraphInitializer createInitializer(String password, String profile, boolean autoInit) { + KnowledgeGraphProperties properties = new KnowledgeGraphProperties(); + properties.getSync().setAutoInitSchema(autoInit); + + GraphInitializer initializer = new GraphInitializer(neo4jClient, properties); + ReflectionTestUtils.setField(initializer, "neo4jPassword", password); + ReflectionTestUtils.setField(initializer, "activeProfile", profile); + return initializer; + } + + // ----------------------------------------------------------------------- + // P1-3: 默认凭据检测 + // ----------------------------------------------------------------------- + + @Test + void run_defaultPassword_prodProfile_throwsException() { + GraphInitializer initializer = createInitializer("datamate123", "prod", false); + + assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SECURITY") + .hasMessageContaining("default"); + } + + @Test + void run_defaultPassword_stagingProfile_throwsException() { + GraphInitializer initializer = createInitializer("neo4j", "staging", false); + + assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SECURITY"); + } + + @Test + void run_defaultPassword_devProfile_warnsButContinues() { + GraphInitializer initializer = createInitializer("datamate123", "dev", false); + + // Should not throw — just warn + assertThatCode(() -> initializer.run(new DefaultApplicationArguments())) + .doesNotThrowAnyException(); + } + + @Test + void run_defaultPassword_testProfile_warnsButContinues() { + GraphInitializer initializer = createInitializer("datamate123", "test", false); + + assertThatCode(() -> initializer.run(new DefaultApplicationArguments())) + .doesNotThrowAnyException(); + } + + @Test + void run_defaultPassword_localProfile_warnsButContinues() { + GraphInitializer initializer = createInitializer("password", "local", false); + + assertThatCode(() -> initializer.run(new DefaultApplicationArguments())) + .doesNotThrowAnyException(); + } + + @Test + void run_securePassword_prodProfile_succeeds() { + GraphInitializer initializer = createInitializer("s3cure!P@ssw0rd", "prod", false); + + // Schema init disabled, so no queries. Should succeed. + assertThatCode(() -> initializer.run(new DefaultApplicationArguments())) + .doesNotThrowAnyException(); + } + + @Test + void run_blankPassword_skipsValidation() { + GraphInitializer initializer = createInitializer("", "prod", false); + + assertThatCode(() -> initializer.run(new DefaultApplicationArguments())) + .doesNotThrowAnyException(); + } + + // ----------------------------------------------------------------------- + // Schema 初始化 — 成功 + // ----------------------------------------------------------------------- + + @Test + void run_autoInitEnabled_executesAllStatements() { + GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true); + + UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class); + when(neo4jClient.query(anyString())).thenReturn(spec); + + initializer.run(new DefaultApplicationArguments()); + + // Should execute all schema statements (constraints + indexes + fulltext) + verify(neo4jClient, atLeast(10)).query(anyString()); + } + + @Test + void run_autoInitDisabled_skipsSchemaInit() { + GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", false); + + initializer.run(new DefaultApplicationArguments()); + + verifyNoInteractions(neo4jClient); + } + + // ----------------------------------------------------------------------- + // P2-7: Schema 初始化错误处理 + // ----------------------------------------------------------------------- + + @Test + void run_alreadyExistsError_safelyIgnored() { + GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true); + + UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class); + when(neo4jClient.query(anyString())).thenReturn(spec); + doThrow(new RuntimeException("Constraint already exists")) + .when(spec).run(); + + // Should not throw — "already exists" errors are safely ignored + assertThatCode(() -> initializer.run(new DefaultApplicationArguments())) + .doesNotThrowAnyException(); + } + + @Test + void run_nonExistenceError_throwsException() { + GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true); + + UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class); + when(neo4jClient.query(anyString())).thenReturn(spec); + doThrow(new RuntimeException("Connection refused to Neo4j")) + .when(spec).run(); + + // Non-"already exists" errors should propagate + assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("schema initialization failed"); + } +} diff --git a/runtime/datamate-python/app/core/config.py b/runtime/datamate-python/app/core/config.py index 3730e0f..82bd65a 100644 --- a/runtime/datamate-python/app/core/config.py +++ b/runtime/datamate-python/app/core/config.py @@ -1,6 +1,15 @@ from pydantic_settings import BaseSettings from pydantic import SecretStr, model_validator from typing import Optional +import logging +import os + +_logger = logging.getLogger(__name__) + +# 已知的弱默认凭据,生产环境禁止使用 +_BLOCKED_DEFAULT_PASSWORDS = {"password", "123456", "admin", "root", "datamate123"} +_BLOCKED_DEFAULT_TOKENS = {"abc123abc123", "EMPTY"} + class Settings(BaseSettings): """应用程序配置""" @@ -76,5 +85,37 @@ class Settings(BaseSettings): # 标注编辑器(Label Studio Editor)相关 editor_max_text_bytes: int = 0 # <=0 表示不限制,正数为最大字节数 + @model_validator(mode='after') + def check_default_credentials(self): + """生产环境下检测弱默认凭据,拒绝启动。 + + 通过环境变量 DATAMATE_ENV 判断环境: + - dev/test/local: 仅发出警告 + - 其他(prod/staging 等): 抛出异常阻止启动 + """ + env = os.environ.get("DATAMATE_ENV", "dev").lower() + is_dev = env in ("dev", "test", "local", "development") + issues: list[str] = [] + + if self.mysql_password in _BLOCKED_DEFAULT_PASSWORDS: + issues.append(f"mysql_password is set to a weak default ('{self.mysql_password}')") + + if self.label_studio_password and self.label_studio_password in _BLOCKED_DEFAULT_PASSWORDS: + issues.append("label_studio_password is set to a weak default") + + if self.label_studio_user_token and self.label_studio_user_token in _BLOCKED_DEFAULT_TOKENS: + issues.append("label_studio_user_token is set to a weak default") + + if issues: + msg = "SECURITY: Weak default credentials detected: " + "; ".join(issues) + if is_dev: + _logger.warning(msg + " (acceptable in dev/test, MUST change for production)") + else: + raise ValueError( + msg + ". Set proper credentials via environment variables " + "before deploying to production." + ) + return self + # 全局设置实例 settings = Settings()