feat: 重构标准 Skill 管理与发布链路
- 统一标准 ZIP 导入导出与通用资源模型 - 收口分类范围权限和创建人查询 - 完善发布快照、审批幂等与数据库清理迁移
This commit is contained in:
@@ -1,730 +0,0 @@
|
||||
package tech.easyflow.skill.capability;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper;
|
||||
import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.validation.SkillValidationIssue;
|
||||
import tech.easyflow.skill.validation.SkillValidationResult;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillCapabilityBindingServiceImpl} 能力命名、MCP 选择、安全配置和权限测试。
|
||||
*/
|
||||
public class SkillCapabilityBindingServiceImplTest {
|
||||
|
||||
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
|
||||
|
||||
private SkillMapper skillMapper;
|
||||
private SkillCapabilityTargetAccessService targetAccessService;
|
||||
private McpAccessPermissionChecker mcpAccessPermissionChecker;
|
||||
private ResourceAccessService resourceAccessService;
|
||||
private SkillCapabilityBindingServiceImpl service;
|
||||
private Skill skill;
|
||||
private MockedStatic<SaTokenUtil> saToken;
|
||||
|
||||
/**
|
||||
* 初始化能力绑定服务及默认可用目标。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
skillMapper = mock(SkillMapper.class);
|
||||
targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
mcpAccessPermissionChecker = mock(McpAccessPermissionChecker.class);
|
||||
resourceAccessService = mock(ResourceAccessService.class);
|
||||
service = new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mcpAccessPermissionChecker,
|
||||
resourceAccessService, new ObjectMapper());
|
||||
skill = new Skill();
|
||||
skill.setId(SKILL_ID);
|
||||
skill.setTenantId(BigInteger.ONE);
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken = mockStatic(SaTokenUtil.class);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill);
|
||||
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean()))
|
||||
.thenReturn(target(List.of("alpha", "beta")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放静态登录态 Mock。
|
||||
*/
|
||||
@After
|
||||
public void tearDown() {
|
||||
saToken.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非 MCP 能力的 runtimeName 按大小写不敏感规则判重。
|
||||
*/
|
||||
@Test
|
||||
public void duplicateRuntimeNamesAreRejectedCaseInsensitively() {
|
||||
SkillCapabilityBinding first = binding("WORKFLOW", 1, "RunFlow");
|
||||
SkillCapabilityBinding second = binding("PLUGIN_ITEM", 2, "runflow");
|
||||
|
||||
SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(first, second), false);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(hasIssue(result, "RUNTIME_NAME_DUPLICATE"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MCP SELECTED 工具会确定性去重、排序并固化最终工具名。
|
||||
*/
|
||||
@Test
|
||||
public void selectedMcpToolsAreDeduplicatedAndSorted() {
|
||||
SkillCapabilityBinding binding = mcpBinding("demo", List.of("beta", "alpha", "alpha"));
|
||||
|
||||
SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), true);
|
||||
|
||||
assertTrue(result.getIssues().toString(), result.isValid());
|
||||
assertEquals(List.of("alpha", "beta"), binding.getSelectedToolNamesJson());
|
||||
assertEquals(List.of("alpha", "beta"), binding.getResolvedToolNames());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证保存 MCP 绑定会重新校验目标权限,并在任何删除或写入发生前拒绝无权用户。
|
||||
*/
|
||||
@Test
|
||||
public void replacingMcpBindingsRejectsMissingTargetPermissionBeforePersistence() {
|
||||
SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha"));
|
||||
when(targetAccessService.requireUsableTarget(binding, false))
|
||||
.thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.replaceBindings(SKILL_ID, List.of(binding)));
|
||||
|
||||
assertEquals(403, exception.getHttpStatus());
|
||||
verify(mcpAccessPermissionChecker).assertCanUseMcp();
|
||||
verify(skillMapper, never()).updateByQuery(any(Skill.class), any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证禁用且尚未映射的 MCP 绑定也不能绕过保存时的 MCP 模块权限。
|
||||
*/
|
||||
@Test
|
||||
public void disabledUnmappedMcpStillRequiresPermissionOnSave() {
|
||||
SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha"));
|
||||
binding.setTargetId(null);
|
||||
binding.setTargetLogicalRef("mcp:unmapped");
|
||||
binding.setEnabled(false);
|
||||
doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"))
|
||||
.when(mcpAccessPermissionChecker).assertCanUseMcp();
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.replaceBindings(SKILL_ID, List.of(binding)));
|
||||
|
||||
assertEquals(403, exception.getHttpStatus());
|
||||
verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean());
|
||||
verify(skillMapper, never()).updateByQuery(any(Skill.class), any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证发布快照会重新校验 MCP 权限,不能沿用保存时或前端传入的授权状态。
|
||||
*/
|
||||
@Test
|
||||
public void publishingMcpBindingRevalidatesTargetPermission() {
|
||||
SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha"));
|
||||
SkillCapabilityBindingServiceImpl publishService = spy(service);
|
||||
doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class));
|
||||
doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"))
|
||||
.when(mcpAccessPermissionChecker).assertCanUseMcp();
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> publishService.buildPublishSnapshot(SKILL_ID));
|
||||
|
||||
assertEquals(403, exception.getHttpStatus());
|
||||
verify(mcpAccessPermissionChecker).assertCanUseMcp();
|
||||
verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证草稿能力 hash 只覆盖持久化配置,不受发布时解析工具清单影响。
|
||||
*/
|
||||
@Test
|
||||
public void draftCapabilityHashIgnoresTransientResolvedTools() {
|
||||
SkillCapabilityBinding saved = mcpBinding("demo", List.of("beta", "alpha"));
|
||||
saved.setSortNo(0);
|
||||
SkillValidationResult validation = service.validateBindings(SKILL_ID, List.of(saved), false);
|
||||
assertTrue(validation.getIssues().toString(), validation.isValid());
|
||||
String responseHash = service.calculateHash(List.of(saved));
|
||||
|
||||
SkillCapabilityBinding reloaded = mcpBinding("demo", List.of("alpha", "beta"));
|
||||
reloaded.setSortNo(0);
|
||||
// targetLogicalRef 是校验后持久化的稳定配置,模拟数据库回读时应与已保存值一致。
|
||||
reloaded.setTargetLogicalRef(saved.getTargetLogicalRef());
|
||||
reloaded.setHitlEnabled(saved.getHitlEnabled());
|
||||
reloaded.setResolvedToolNames(List.of());
|
||||
String persistedHash = service.calculateHash(List.of(reloaded));
|
||||
|
||||
assertEquals(responseHash, persistedHash);
|
||||
reloaded.setResolvedToolNames(List.of("changed-after-publish"));
|
||||
assertEquals(persistedHash, service.calculateHash(List.of(reloaded)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力批量写入失败属于服务端持久化故障,应返回 5xx。
|
||||
*/
|
||||
@Test
|
||||
public void replacePersistenceFailureUsesServerErrorStatus() {
|
||||
SkillCapabilityBindingServiceImpl failingService = spy(service);
|
||||
doReturn(0L).when(failingService).count(any(QueryWrapper.class));
|
||||
doReturn(false).when(failingService).saveBatch(any(List.class));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> failingService.replaceBindings(
|
||||
SKILL_ID, List.of(binding("WORKFLOW", 1, "runFlow"))));
|
||||
|
||||
assertEquals(500, exception.getHttpStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空能力绑定时必须删除全部旧记录,并将能力摘要归零为确定性的空列表 hash。
|
||||
*/
|
||||
@Test
|
||||
public void clearingBindingsDeletesAllRowsAndResetsSummary() {
|
||||
SkillCapabilityBindingMapper bindingMapper = mock(SkillCapabilityBindingMapper.class);
|
||||
SkillCapabilityBindingServiceImpl clearingService = spy(service);
|
||||
String emptyCapabilityHash = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
|
||||
doReturn(bindingMapper).when(clearingService).getMapper();
|
||||
doReturn(2L).when(clearingService).count(any(QueryWrapper.class));
|
||||
doReturn(List.of()).when(clearingService).list(any(QueryWrapper.class));
|
||||
when(bindingMapper.deleteByQuery(any(QueryWrapper.class))).thenReturn(2);
|
||||
when(skillMapper.updateByQuery(any(Skill.class), any(QueryWrapper.class))).thenReturn(1);
|
||||
|
||||
List<SkillCapabilityBinding> result = clearingService.replaceBindings(SKILL_ID, List.of());
|
||||
|
||||
ArgumentCaptor<Skill> updateCaptor = ArgumentCaptor.forClass(Skill.class);
|
||||
verify(clearingService, times(1)).count(any(QueryWrapper.class));
|
||||
verify(bindingMapper, times(1)).deleteByQuery(any(QueryWrapper.class));
|
||||
verify(skillMapper).updateByQuery(updateCaptor.capture(), any(QueryWrapper.class));
|
||||
assertTrue(result.isEmpty());
|
||||
assertEquals(Integer.valueOf(0), updateCaptor.getValue().getCapabilityCount());
|
||||
assertEquals(emptyCapabilityHash, updateCaptor.getValue().getCapabilityHash());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MCP SELECTED 空选择和已消失工具都会返回明确结构化错误。
|
||||
*/
|
||||
@Test
|
||||
public void selectedMcpRequiresToolsAndRejectsMissingToolsOnPublish() {
|
||||
SkillCapabilityBinding empty = mcpBinding("empty", List.of());
|
||||
SkillValidationResult emptyResult = service.validateBindings(SKILL_ID, List.of(empty), false);
|
||||
assertTrue(hasIssue(emptyResult, "MCP_TOOL_SELECTION_EMPTY"));
|
||||
|
||||
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), eq(true)))
|
||||
.thenReturn(target(List.of("alpha")));
|
||||
SkillCapabilityBinding missing = mcpBinding("missing", List.of("alpha", "removed"));
|
||||
SkillValidationResult missingResult = service.validateBindings(SKILL_ID, List.of(missing), true);
|
||||
|
||||
assertFalse(missingResult.isValid());
|
||||
assertTrue(hasIssue(missingResult, "MCP_TOOL_MISSING"));
|
||||
assertFalse(issue(missingResult, "MCP_TOOL_MISSING").getMessage().contains("removed"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证客户端提交的敏感或未知 options 被拒绝,并只留下安全白名单字段。
|
||||
*/
|
||||
@Test
|
||||
public void sensitiveAndUnknownClientOptionsAreRejected() {
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW", 1, "safeFlow");
|
||||
Map<String, Object> options = new LinkedHashMap<>();
|
||||
options.put("timeoutMs", 2_000);
|
||||
options.put("token", "secret");
|
||||
options.put("customOption", true);
|
||||
options.put("readOnly", Map.of("nested", "unsafe"));
|
||||
binding.setOptionsJson(options);
|
||||
|
||||
SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), false);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(hasIssue(result, "CAPABILITY_OPTIONS_UNSAFE"));
|
||||
assertEquals(Map.of("timeoutMs", 2_000), binding.getOptionsJson());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证导入预览会报告 manifest 静态配置问题,同时不把待映射目标本身视为错误。
|
||||
*/
|
||||
@Test
|
||||
public void importPreviewReportsStaticErrorsWithoutBlockingUnresolvedTargets() {
|
||||
SkillCapabilityBinding first = unresolvedBinding("WORKFLOW", "workflow:first", "sharedName");
|
||||
first.setSelectionMode("SELECTED");
|
||||
first.setSelectedToolNamesJson(List.of("search"));
|
||||
first.setOptionsJson(Map.of("timeoutMs", 99, "retryCount", 11));
|
||||
SkillCapabilityBinding second = unresolvedBinding("PLUGIN_ITEM", "plugin-item:demo/tool", "sharedName");
|
||||
SkillCapabilityBinding mcp = unresolvedBinding("MCP", "mcp:demo", "mcpTools");
|
||||
mcp.setSelectionMode("SELECTED");
|
||||
mcp.setSelectedToolNamesJson(List.of());
|
||||
mcp.setExecutionMode("SYNC");
|
||||
|
||||
SkillValidationResult result = service.validateImportBindings(List.of(first, second, mcp));
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(hasIssue(result, "CAPABILITY_OPTION_VALUE_INVALID"));
|
||||
assertTrue(hasIssue(result, "RUNTIME_NAME_DUPLICATE"));
|
||||
assertTrue(hasIssue(result, "MCP_SELECTION_MODE_NOT_ALLOWED"));
|
||||
assertTrue(hasIssue(result, "MCP_TOOL_SELECTION_NOT_ALLOWED"));
|
||||
assertTrue(hasIssue(result, "MCP_EXECUTION_MODE_NOT_ALLOWED"));
|
||||
assertTrue(hasIssue(result, "MCP_TOOL_SELECTION_EMPTY"));
|
||||
assertFalse(hasIssue(result, "TARGET_UNRESOLVED"));
|
||||
verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean());
|
||||
verifyNoInteractions(skillMapper, resourceAccessService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证有效的未映射能力可通过导入静态校验,留待映射步骤处理。
|
||||
*/
|
||||
@Test
|
||||
public void importPreviewAcceptsValidUnresolvedBinding() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding(
|
||||
"WORKFLOW", "workflow:portable-flow", "portableFlow");
|
||||
|
||||
SkillValidationResult result = service.validateImportBindings(List.of(binding));
|
||||
|
||||
assertTrue(result.getIssues().toString(), result.isValid());
|
||||
assertTrue(result.getIssues().isEmpty());
|
||||
verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean());
|
||||
verifyNoInteractions(skillMapper, resourceAccessService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证自动映射成功的目标仍执行可用性和当前操作者授权校验。
|
||||
*/
|
||||
@Test
|
||||
public void importPreviewRevalidatesResolvedTargetPermission() {
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW", 92, "securedFlow");
|
||||
binding.setTargetLogicalRef("workflow:secured-flow");
|
||||
when(targetAccessService.requireUsableTarget(binding, false))
|
||||
.thenThrow(new BusinessException(403, 403, "无权限使用绑定工作流"));
|
||||
|
||||
SkillValidationResult result = service.validateImportBindings(List.of(binding));
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(hasIssue(result, "TARGET_NO_PERMISSION"));
|
||||
verify(targetAccessService).requireUsableTarget(binding, false);
|
||||
verifyNoInteractions(skillMapper, resourceAccessService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证发布快照复用发布校验得到的目标摘要,并缓存同一目标的重复绑定查询。
|
||||
*/
|
||||
@Test
|
||||
public void publishSnapshotReusesValidatedTargetWithinRequest() {
|
||||
SkillCapabilityBinding first = binding("WORKFLOW", 93, "firstFlow");
|
||||
SkillCapabilityBinding second = binding("WORKFLOW", 93, "secondFlow");
|
||||
SkillCapabilityBindingServiceImpl publishService = spy(new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mcpAccessPermissionChecker,
|
||||
resourceAccessService, new ObjectMapper()));
|
||||
doReturn(List.of(first, second)).when(publishService).list(any(QueryWrapper.class));
|
||||
|
||||
List<Map<String, Object>> snapshots = publishService.buildPublishSnapshot(SKILL_ID);
|
||||
|
||||
assertEquals(2, snapshots.size());
|
||||
assertEquals("target", snapshots.get(0).get("targetName"));
|
||||
assertEquals("target", snapshots.get(1).get("targetName"));
|
||||
verify(targetAccessService, times(1)).requireUsableTarget(any(SkillCapabilityBinding.class), eq(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布快照必须移除凭据式目标元数据,并将非法逻辑引用降级为不可解析引用。
|
||||
*
|
||||
* @throws Exception JSON 序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void publishSnapshotSanitizesPortableTargetMetadata() throws Exception {
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW", 94, "secureFlow");
|
||||
SkillCapabilityTarget unsafeTarget = target(List.of());
|
||||
unsafeTarget.setName("https://user:password@example.test/flow");
|
||||
unsafeTarget.setLogicalRef("workflow:../../private");
|
||||
unsafeTarget.setRevision("/Users/admin/.config/secret");
|
||||
when(targetAccessService.requireUsableTarget(binding, false)).thenReturn(unsafeTarget);
|
||||
SkillCapabilityBindingServiceImpl publishService = spy(new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mcpAccessPermissionChecker,
|
||||
resourceAccessService, new ObjectMapper()));
|
||||
doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class));
|
||||
|
||||
Map<String, Object> snapshot = publishService.buildPublishSnapshot(SKILL_ID).get(0);
|
||||
String json = new ObjectMapper().writeValueAsString(snapshot);
|
||||
|
||||
assertNull(snapshot.get("targetName"));
|
||||
assertNull(snapshot.get("targetRevision"));
|
||||
assertEquals("unresolved:workflow", snapshot.get("targetLogicalRef"));
|
||||
assertFalse(json.contains("password"));
|
||||
assertFalse(json.contains("/Users/admin"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证单项配置 4 KiB 和 MCP 选择 200 项的计数限额。
|
||||
*/
|
||||
@Test
|
||||
public void capabilityConfigAndSelectedToolLimitsAreReported() {
|
||||
SkillCapabilityBinding oversizedConfig = binding("WORKFLOW", 1, "largeConfig");
|
||||
oversizedConfig.setOptionsJson(Map.of("timeoutMs", "x".repeat(5_000)));
|
||||
SkillValidationResult configResult = service.validateBindings(
|
||||
SKILL_ID, List.of(oversizedConfig), false);
|
||||
assertTrue(hasIssue(configResult, "CAPABILITY_CONFIG_TOO_LARGE"));
|
||||
|
||||
List<String> tools = IntStream.range(0, 201)
|
||||
.mapToObj(index -> String.format("tool%03d", index))
|
||||
.toList();
|
||||
SkillCapabilityBinding oversizedSelection = mcpBinding("many", tools);
|
||||
SkillValidationResult selectionResult = service.validateBindings(
|
||||
SKILL_ID, List.of(oversizedSelection), false);
|
||||
|
||||
assertTrue(hasIssue(selectionResult, "MCP_TOOL_SELECTION_LIMIT"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证保存、导入和发布共用的能力校验会精确报告 HITL 字符串中的凭据。
|
||||
*/
|
||||
@Test
|
||||
public void sensitiveHitlValueIsRejectedWithExactPath() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding(
|
||||
"WORKFLOW", "workflow:portable-flow", "portableFlow");
|
||||
binding.setHitlConfigJson(Map.of(
|
||||
"title", "人工确认",
|
||||
"prompt", "Authorization: Bearer actual-secret-value"));
|
||||
|
||||
SkillValidationResult result = service.validateImportBindings(List.of(binding));
|
||||
|
||||
assertFalse(result.isValid());
|
||||
SkillValidationIssue issue = result.getIssues().stream()
|
||||
.filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals("capabilities[0].hitlConfigJson.prompt", issue.getPath());
|
||||
assertFalse(issue.getMessage().contains("actual-secret-value"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证保存、导入和发布共用校验覆盖运行时名称、目标引用和工具名。
|
||||
*/
|
||||
@Test
|
||||
public void sensitiveBindingStringsAreRejectedWithExactPaths() {
|
||||
SkillCapabilityBinding runtimeBinding = unresolvedBinding(
|
||||
"WORKFLOW", "workflow:portable-flow", "sk-proj-abcdefghijklmnopqrstuvwxyz123456");
|
||||
SkillCapabilityBinding targetBinding = unresolvedBinding(
|
||||
"WORKFLOW", "workflow:sk-proj-abcdefghijklmnopqrstuvwxyz123456", "portableFlow");
|
||||
SkillCapabilityBinding toolBinding = unresolvedBinding("MCP", "mcp:portable", "portableMcp");
|
||||
toolBinding.setEnabled(false);
|
||||
toolBinding.setSelectionMode("SELECTED");
|
||||
toolBinding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
|
||||
|
||||
SkillValidationResult result = service.validateImportBindings(
|
||||
List.of(runtimeBinding, targetBinding, toolBinding));
|
||||
|
||||
assertFalse(result.isValid());
|
||||
List<String> sensitivePaths = result.getIssues().stream()
|
||||
.filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode()))
|
||||
.map(SkillValidationIssue::getPath)
|
||||
.toList();
|
||||
assertTrue(sensitivePaths.contains("capabilities[0].runtimeName"));
|
||||
assertTrue(sensitivePaths.contains("capabilities[1].targetLogicalRef"));
|
||||
assertTrue(sensitivePaths.contains("capabilities[2].selectedToolNamesJson[0]"));
|
||||
assertTrue(result.getIssues().stream().noneMatch(
|
||||
item -> item.getMessage().contains("sk-proj-")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证禁用能力也不能将凭据式工具名写入发布快照。
|
||||
*/
|
||||
@Test
|
||||
public void publishSnapshotRejectsCredentialInDisabledBinding() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:portable", "portableMcp");
|
||||
binding.setEnabled(false);
|
||||
binding.setSelectionMode("SELECTED");
|
||||
binding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
|
||||
SkillCapabilityBindingServiceImpl publishService = spy(service);
|
||||
doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class));
|
||||
|
||||
BusinessException exception = assertThrows(
|
||||
BusinessException.class, () -> publishService.buildPublishSnapshot(SKILL_ID));
|
||||
|
||||
assertFalse(exception.getMessage().contains("sk-proj-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证目标解析阶段返回的凭据式 MCP 工具名不能进入发布快照。
|
||||
*/
|
||||
@Test
|
||||
public void publishValidationRejectsCredentialFromResolvedMcpTools() {
|
||||
SkillCapabilityBinding binding = mcpBinding("portableMcp", List.of("alpha"));
|
||||
when(targetAccessService.requireUsableTarget(binding, true)).thenReturn(
|
||||
target(List.of("alpha", "sk-proj-abcdefghijklmnopqrstuvwxyz123456")));
|
||||
|
||||
SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), true);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
SkillValidationIssue issue = result.getIssues().stream()
|
||||
.filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals("capabilities[0].resolvedToolNames[1]", issue.getPath());
|
||||
assertFalse(issue.getMessage().contains("sk-proj-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证列表和详情读取边界会移除历史数据库中的凭据式展示值。
|
||||
*/
|
||||
@Test
|
||||
public void listBindingsRedactsLegacyCredentialValues() {
|
||||
SkillCapabilityBinding binding = binding(
|
||||
"WORKFLOW", 95, "sk-proj-abcdefghijklmnopqrstuvwxyz123456");
|
||||
binding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
|
||||
binding.setResolvedToolNames(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
|
||||
binding.setHitlConfigJson(Map.of("prompt", "Bearer actual-secret-value"));
|
||||
binding.setOptionsJson(Map.of("timeoutMs", "token=actual-secret-value"));
|
||||
SkillCapabilityBindingServiceImpl listService = spy(service);
|
||||
doReturn(List.of(binding)).when(listService).list(any(QueryWrapper.class));
|
||||
|
||||
SkillCapabilityBinding result = listService.listBindings(SKILL_ID).get(0);
|
||||
|
||||
assertNull(result.getRuntimeName());
|
||||
assertTrue(result.getSelectedToolNamesJson().isEmpty());
|
||||
assertTrue(result.getResolvedToolNames().isEmpty());
|
||||
assertTrue(result.getHitlConfigJson().isEmpty());
|
||||
assertTrue(result.getOptionsJson().isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 replaceBindings 在进入持久化前拒绝超过 200 项的能力列表。
|
||||
*/
|
||||
@Test
|
||||
public void replaceRejectsMoreThanTwoHundredBindings() {
|
||||
List<SkillCapabilityBinding> bindings = new ArrayList<>();
|
||||
for (int index = 0; index < 201; index++) {
|
||||
bindings.add(binding("WORKFLOW", index + 1, "flow" + index));
|
||||
}
|
||||
|
||||
assertThrows(BusinessException.class, () -> service.replaceBindings(SKILL_ID, bindings));
|
||||
verify(resourceAccessService).assertAccess(
|
||||
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理 Skill 能力绑定");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证传入待保存 bindings 的校验必须执行 MANAGE 权限,不允许降级为 READ。
|
||||
*/
|
||||
@Test
|
||||
public void validatingClientBindingsRequiresManagePermission() {
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW", 1, "managedFlow");
|
||||
|
||||
service.validateBindings(SKILL_ID, List.of(binding), false);
|
||||
|
||||
verify(resourceAccessService).assertAccess(
|
||||
eq(CategoryResourceType.SKILL), same(skill), eq(ResourceAction.MANAGE), anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证只有 READ 权限的用户读取绑定时看不到当前环境目标 ID 和无权限目标残留名称。
|
||||
*/
|
||||
@Test
|
||||
public void visibleBindingsRedactTargetIdentityWithoutManagePermission() {
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW", 81, "readOnlyFlow");
|
||||
binding.setTargetLogicalRef("workflow:private-flow");
|
||||
binding.setTargetName("stale-private-name");
|
||||
binding.setSelectedToolNamesJson(List.of("stale-private-selected-tool"));
|
||||
binding.setResolvedToolNames(List.of("stale-private-tool"));
|
||||
SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mcpAccessPermissionChecker,
|
||||
resourceAccessService, new ObjectMapper()));
|
||||
doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class));
|
||||
when(resourceAccessService.canAccess(
|
||||
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(false);
|
||||
when(targetAccessService.requireUsableTarget(binding, false))
|
||||
.thenThrow(new BusinessException(403, 403, "无权限使用目标"));
|
||||
|
||||
List<SkillCapabilityBinding> result = viewService.listVisibleBindings(SKILL_ID);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertNull(result.get(0).getTargetId());
|
||||
assertNull(result.get(0).getTargetLogicalRef());
|
||||
assertNull(result.get(0).getTargetName());
|
||||
assertTrue(result.get(0).getSelectedToolNamesJson().isEmpty());
|
||||
assertTrue(result.get(0).getResolvedToolNames().isEmpty());
|
||||
assertEquals("NO_PERMISSION", result.get(0).getTargetStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证拥有 MANAGE 权限的用户读取绑定时仍可获得目标 ID 用于编辑。
|
||||
*/
|
||||
@Test
|
||||
public void visibleBindingsKeepTargetIdWithManagePermission() {
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW", 82, "managedFlow");
|
||||
SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mcpAccessPermissionChecker,
|
||||
resourceAccessService, new ObjectMapper()));
|
||||
doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class));
|
||||
when(resourceAccessService.canAccess(
|
||||
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(true);
|
||||
|
||||
List<SkillCapabilityBinding> result = viewService.listVisibleBindings(SKILL_ID);
|
||||
|
||||
assertEquals(BigInteger.valueOf(82), result.get(0).getTargetId());
|
||||
assertEquals("AVAILABLE", result.get(0).getTargetStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill MANAGE 权限不能替代 MCP 查询权限,目标标识和工具元数据仍需脱敏。
|
||||
*/
|
||||
@Test
|
||||
public void visibleBindingsRedactMcpTargetWithoutTargetPermissionEvenWhenSkillManageable() {
|
||||
SkillCapabilityBinding binding = mcpBinding("privateMcp", List.of("private_tool"));
|
||||
binding.setTargetLogicalRef("mcp:private-server");
|
||||
binding.setTargetName("private-server");
|
||||
binding.setResolvedToolNames(List.of("private_tool"));
|
||||
SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mcpAccessPermissionChecker,
|
||||
resourceAccessService, new ObjectMapper()));
|
||||
doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class));
|
||||
when(resourceAccessService.canAccess(
|
||||
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(true);
|
||||
when(targetAccessService.requireUsableTarget(binding, false))
|
||||
.thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"));
|
||||
|
||||
List<SkillCapabilityBinding> result = viewService.listVisibleBindings(SKILL_ID);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("NO_PERMISSION", result.get(0).getTargetStatus());
|
||||
assertNull(result.get(0).getTargetId());
|
||||
assertNull(result.get(0).getTargetLogicalRef());
|
||||
assertNull(result.get(0).getTargetName());
|
||||
assertTrue(result.get(0).getSelectedToolNamesJson().isEmpty());
|
||||
assertTrue(result.get(0).getResolvedToolNames().isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基础能力绑定。
|
||||
*
|
||||
* @param type 能力类型
|
||||
* @param targetId 目标 ID
|
||||
* @param runtimeName 运行时名称
|
||||
* @return 能力绑定
|
||||
*/
|
||||
private SkillCapabilityBinding binding(String type, long targetId, String runtimeName) {
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setCapabilityType(type);
|
||||
binding.setTargetId(BigInteger.valueOf(targetId));
|
||||
binding.setRuntimeName(runtimeName);
|
||||
binding.setEnabled(true);
|
||||
binding.setHitlConfigJson(new LinkedHashMap<>());
|
||||
binding.setOptionsJson(new LinkedHashMap<>());
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MCP SELECTED 能力绑定。
|
||||
*
|
||||
* @param runtimeName 命名空间
|
||||
* @param selectedTools 已选工具
|
||||
* @return MCP 绑定
|
||||
*/
|
||||
private SkillCapabilityBinding mcpBinding(String runtimeName, List<String> selectedTools) {
|
||||
SkillCapabilityBinding binding = binding("MCP", 10, runtimeName);
|
||||
binding.setSelectionMode("SELECTED");
|
||||
binding.setSelectedToolNamesJson(selectedTools);
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建等待导入映射的能力绑定。
|
||||
*
|
||||
* @param type 能力类型
|
||||
* @param logicalRef 可移植逻辑引用
|
||||
* @param runtimeName 运行时名称
|
||||
* @return 未映射能力绑定
|
||||
*/
|
||||
private SkillCapabilityBinding unresolvedBinding(String type, String logicalRef, String runtimeName) {
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setCapabilityType(type);
|
||||
binding.setTargetLogicalRef(logicalRef);
|
||||
binding.setRuntimeName(runtimeName);
|
||||
binding.setEnabled(true);
|
||||
binding.setHitlConfigJson(new LinkedHashMap<>());
|
||||
binding.setOptionsJson(new LinkedHashMap<>());
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可用能力目标。
|
||||
*
|
||||
* @param toolNames MCP 工具名
|
||||
* @return 目标摘要
|
||||
*/
|
||||
private SkillCapabilityTarget target(List<String> toolNames) {
|
||||
SkillCapabilityTarget target = new SkillCapabilityTarget();
|
||||
target.setName("target");
|
||||
target.setLogicalRef("target://demo");
|
||||
target.setRevision("r1");
|
||||
target.setStatus("AVAILABLE");
|
||||
target.setToolNames(toolNames);
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断校验结果是否包含指定问题码。
|
||||
*
|
||||
* @param result 校验结果
|
||||
* @param code 问题码
|
||||
* @return 包含时为 true
|
||||
*/
|
||||
private boolean hasIssue(SkillValidationResult result, String code) {
|
||||
return result.getIssues().stream().anyMatch(item -> code.equals(item.getCode()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定问题码的首个问题。
|
||||
*
|
||||
* @param result 校验结果
|
||||
* @param code 问题码
|
||||
* @return 校验问题
|
||||
*/
|
||||
private SkillValidationIssue issue(SkillValidationResult result, String code) {
|
||||
return result.getIssues().stream()
|
||||
.filter(item -> code.equals(item.getCode()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package tech.easyflow.skill.capability;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.validation.SkillValidationResult;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 能力绑定畸形客户端输入的结构化诊断测试。
|
||||
*/
|
||||
public class SkillCapabilityMalformedInputTest {
|
||||
|
||||
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
|
||||
|
||||
private SkillCapabilityBindingServiceImpl service;
|
||||
|
||||
/**
|
||||
* 初始化具有当前租户上下文的被测服务。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
SkillMapper skillMapper = mock(SkillMapper.class);
|
||||
Skill skill = new Skill();
|
||||
skill.setId(SKILL_ID);
|
||||
skill.setTenantId(BigInteger.ONE);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill);
|
||||
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
SkillCapabilityTarget target = new SkillCapabilityTarget();
|
||||
target.setName("target");
|
||||
target.setLogicalRef("target:demo");
|
||||
target.setStatus("AVAILABLE");
|
||||
target.setToolNames(List.of("search"));
|
||||
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean()))
|
||||
.thenReturn(target);
|
||||
service = new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class),
|
||||
mock(ResourceAccessService.class), new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MCP 工具数组中的 null 返回结构化错误,不触发排序空指针。
|
||||
*/
|
||||
@Test
|
||||
public void nullMcpToolNameShouldReturnStructuredIssue() {
|
||||
SkillCapabilityBinding binding = binding("MCP");
|
||||
binding.setSelectionMode("SELECTED");
|
||||
List<String> tools = new ArrayList<>();
|
||||
tools.add("search");
|
||||
tools.add(null);
|
||||
binding.setSelectedToolNamesJson(tools);
|
||||
|
||||
SkillValidationResult result = validate(binding);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getIssues().stream()
|
||||
.anyMatch(issue -> "MCP_TOOL_NAME_INVALID".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法执行模式进入结构化问题列表,不以枚举异常中断校验。
|
||||
*/
|
||||
@Test
|
||||
public void invalidExecutionModeShouldReturnStructuredIssue() {
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW");
|
||||
binding.setExecutionMode("INVALID_MODE");
|
||||
|
||||
SkillValidationResult result = validate(binding);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getIssues().stream()
|
||||
.anyMatch(issue -> issue.getPath() != null && issue.getPath().endsWith("executionMode")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法 MCP 选择模式进入结构化问题列表。
|
||||
*/
|
||||
@Test
|
||||
public void invalidSelectionModeShouldReturnStructuredIssue() {
|
||||
SkillCapabilityBinding binding = binding("MCP");
|
||||
binding.setSelectionMode("INVALID_MODE");
|
||||
|
||||
SkillValidationResult result = validate(binding);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getIssues().stream()
|
||||
.anyMatch(issue -> issue.getPath() != null && issue.getPath().endsWith("selectionMode")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证禁用且未映射的 MCP 仍执行选择模式静态校验,不能借 targetId 为空绕过。
|
||||
*/
|
||||
@Test
|
||||
public void disabledUnresolvedMcpShouldStillValidateSelectionMode() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:missing");
|
||||
binding.setSelectionMode("INVALID_MODE");
|
||||
|
||||
SkillValidationResult result = validate(binding);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getIssues().stream()
|
||||
.anyMatch(issue -> "MCP_SELECTION_MODE_INVALID".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证禁用且未映射的 MCP 仍拒绝非法工具名,避免恶意值持久化并再次导出。
|
||||
*/
|
||||
@Test
|
||||
public void disabledUnresolvedMcpShouldStillValidateToolNames() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:missing");
|
||||
binding.setSelectionMode("SELECTED");
|
||||
binding.setSelectedToolNamesJson(List.of("invalid tool name"));
|
||||
|
||||
SkillValidationResult result = validate(binding);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getIssues().stream()
|
||||
.anyMatch(issue -> "MCP_TOOL_NAME_INVALID".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证禁用且未映射的非 MCP 能力仍执行 executionMode 静态校验。
|
||||
*/
|
||||
@Test
|
||||
public void disabledUnresolvedWorkflowShouldStillValidateExecutionMode() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding("WORKFLOW", "workflow:missing");
|
||||
binding.setExecutionMode("INVALID_MODE");
|
||||
|
||||
SkillValidationResult result = validate(binding);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getIssues().stream()
|
||||
.anyMatch(issue -> "EXECUTION_MODE_INVALID".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证逻辑引用 scheme 必须与能力类型一致。
|
||||
*/
|
||||
@Test
|
||||
public void unresolvedLogicalRefShouldMatchCapabilityType() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding("MCP", "workflow:wrong-type");
|
||||
binding.setSelectionMode("ALL");
|
||||
|
||||
SkillValidationResult result = validate(binding);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getIssues().stream()
|
||||
.anyMatch(issue -> "TARGET_LOGICAL_REF_INVALID".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
private SkillValidationResult validate(SkillCapabilityBinding binding) {
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account());
|
||||
return service.validateBindings(SKILL_ID, List.of(binding), false);
|
||||
}
|
||||
}
|
||||
|
||||
private SkillCapabilityBinding binding(String type) {
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setCapabilityType(type);
|
||||
binding.setTargetId(BigInteger.valueOf(9));
|
||||
binding.setRuntimeName("demoTool");
|
||||
binding.setEnabled(true);
|
||||
binding.setHitlConfigJson(Map.of());
|
||||
binding.setOptionsJson(Map.of());
|
||||
return binding;
|
||||
}
|
||||
|
||||
private SkillCapabilityBinding unresolvedBinding(String type, String logicalRef) {
|
||||
SkillCapabilityBinding binding = binding(type);
|
||||
binding.setTargetId(null);
|
||||
binding.setTargetLogicalRef(logicalRef);
|
||||
binding.setEnabled(false);
|
||||
return binding;
|
||||
}
|
||||
|
||||
private LoginAccount account() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package tech.easyflow.skill.capability;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
|
||||
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
|
||||
import tech.easyflow.ai.service.McpService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.PluginService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.enums.SkillCapabilityType;
|
||||
import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper;
|
||||
import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.validation.SkillValidationIssue;
|
||||
import tech.easyflow.skill.validation.SkillValidationResult;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Skill 能力绑定的租户边界和严格校验回归测试。
|
||||
*/
|
||||
public class SkillCapabilityTenantAndValidationTest {
|
||||
|
||||
/**
|
||||
* 验证当前用户即使拥有全局插件可见范围,也不能绑定其他租户的插件工具项。
|
||||
*/
|
||||
@Test
|
||||
public void pluginItemShouldNeverCrossTenantBoundary() {
|
||||
PluginItemService pluginItemService = mock(PluginItemService.class);
|
||||
PluginService pluginService = mock(PluginService.class);
|
||||
PluginVisibilityService pluginVisibilityService = mock(PluginVisibilityService.class);
|
||||
PluginItem item = new PluginItem();
|
||||
item.setId(BigInteger.valueOf(11));
|
||||
item.setPluginId(BigInteger.valueOf(22));
|
||||
item.setName("tool");
|
||||
item.setStatus(1);
|
||||
item.setServiceStatus(1);
|
||||
Plugin plugin = new Plugin();
|
||||
plugin.setId(BigInteger.valueOf(22));
|
||||
plugin.setTenantId(2L);
|
||||
plugin.setCreatedBy(8L);
|
||||
plugin.setName("other-tenant-plugin");
|
||||
when(pluginItemService.getOne(any(QueryWrapper.class))).thenReturn(item);
|
||||
// 即使底层查询实现错误地返回了跨租户对象,服务层防御检查仍必须拒绝。
|
||||
when(pluginService.getOne(any(QueryWrapper.class))).thenReturn(plugin);
|
||||
when(pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())).thenReturn(true);
|
||||
when(pluginService.preparePluginForCurrentUser(plugin)).thenReturn(plugin);
|
||||
SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl(
|
||||
mock(WorkflowService.class), pluginItemService, pluginService, pluginVisibilityService,
|
||||
mock(McpService.class), mock(McpAccessPermissionChecker.class), mock(ResourceAccessService.class),
|
||||
mock(WorkflowVisibilityQueryHelper.class), mock(CategoryPermissionService.class));
|
||||
SkillCapabilityBinding binding = binding("PLUGIN_ITEM", item.getId(), "tool");
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.requireUsableTarget(binding, false));
|
||||
assertEquals(403, exception.getHttpStatus());
|
||||
}
|
||||
verify(pluginService, never()).preparePluginForCurrentUser(plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证包含凭据式 URL 的 MCP 标题不会被复制进跨环境逻辑引用。
|
||||
*/
|
||||
@Test
|
||||
public void unsafeMcpTitleShouldBecomeUnresolvedLogicalRef() {
|
||||
McpService mcpService = mock(McpService.class);
|
||||
Mcp mcp = new Mcp();
|
||||
mcp.setId(BigInteger.valueOf(31));
|
||||
mcp.setTenantId(BigInteger.ONE);
|
||||
mcp.setStatus(true);
|
||||
mcp.setTitle("https://user:secret@example.test/mcp?token=must-not-enter");
|
||||
when(mcpService.getOne(any(QueryWrapper.class))).thenReturn(mcp);
|
||||
McpAccessPermissionChecker mcpPermissionChecker = mock(McpAccessPermissionChecker.class);
|
||||
SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl(
|
||||
mock(WorkflowService.class), mock(PluginItemService.class), mock(PluginService.class),
|
||||
mock(PluginVisibilityService.class), mcpService, mcpPermissionChecker, mock(ResourceAccessService.class),
|
||||
mock(WorkflowVisibilityQueryHelper.class), mock(CategoryPermissionService.class));
|
||||
SkillCapabilityBinding binding = binding("MCP", mcp.getId(), "mcpTool");
|
||||
|
||||
SkillCapabilityTarget target;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
|
||||
target = service.requireUsableTarget(binding, false);
|
||||
}
|
||||
|
||||
assertEquals("unresolved:mcp", target.getLogicalRef());
|
||||
assertFalse(target.getLogicalRef().contains("secret"));
|
||||
assertFalse(target.getLogicalRef().contains("token"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MCP 候选、工具解析、目标绑定和增强导入映射都在访问数据前校验 MCP 模块权限。
|
||||
*/
|
||||
@Test
|
||||
public void mcpOperationsRejectCallerWithoutMcpQueryPermissionBeforeDataAccess() {
|
||||
McpService mcpService = mock(McpService.class);
|
||||
McpAccessPermissionChecker permissionChecker = mock(McpAccessPermissionChecker.class);
|
||||
doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"))
|
||||
.when(permissionChecker).assertCanUseMcp();
|
||||
SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl(
|
||||
mock(WorkflowService.class), mock(PluginItemService.class), mock(PluginService.class),
|
||||
mock(PluginVisibilityService.class), mcpService, permissionChecker,
|
||||
mock(ResourceAccessService.class), mock(WorkflowVisibilityQueryHelper.class),
|
||||
mock(CategoryPermissionService.class));
|
||||
SkillCapabilityBinding binding = binding("MCP", BigInteger.valueOf(31), "mcpTool");
|
||||
|
||||
BusinessException candidates = assertThrows(BusinessException.class,
|
||||
() -> service.listCandidates(SkillCapabilityType.MCP, null));
|
||||
BusinessException tools = assertThrows(BusinessException.class,
|
||||
() -> service.getMcpTools(BigInteger.valueOf(31)));
|
||||
BusinessException bindingAccess = assertThrows(BusinessException.class,
|
||||
() -> service.requireUsableTarget(binding, false));
|
||||
BusinessException importMapping = assertThrows(BusinessException.class,
|
||||
() -> service.resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo"));
|
||||
|
||||
assertEquals(403, candidates.getHttpStatus());
|
||||
assertEquals(403, tools.getHttpStatus());
|
||||
assertEquals(403, bindingAccess.getHttpStatus());
|
||||
assertEquals(403, importMapping.getHttpStatus());
|
||||
verify(permissionChecker, times(4)).assertCanUseMcp();
|
||||
verifyNoInteractions(mcpService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证省略 HITL 和 options 时按空配置处理,不产生不安全配置误报。
|
||||
*/
|
||||
@Test
|
||||
public void nullSafeConfigsShouldBeNormalizedToEmptyMaps() {
|
||||
SkillMapper skillMapper = mock(SkillMapper.class);
|
||||
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
Skill skill = skill(101, 1);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill);
|
||||
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean()))
|
||||
.thenReturn(target());
|
||||
SkillCapabilityBindingServiceImpl service = new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class),
|
||||
mock(ResourceAccessService.class), new ObjectMapper());
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW", BigInteger.valueOf(9), "workflowTool");
|
||||
|
||||
SkillValidationResult result;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
|
||||
result = service.validateBindings(skill.getId(), List.of(binding), false);
|
||||
}
|
||||
|
||||
assertTrue(result.getIssues().toString(), result.isValid());
|
||||
assertTrue(binding.getHitlConfigJson().isEmpty());
|
||||
assertTrue(binding.getOptionsJson().isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证目标 USE 权限失败时,即使绑定被禁用也不能作为 warning 绕过保存校验。
|
||||
*/
|
||||
@Test
|
||||
public void disabledBindingShouldNotBypassTargetPermission() {
|
||||
SkillMapper skillMapper = mock(SkillMapper.class);
|
||||
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
Skill skill = skill(101, 1);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill);
|
||||
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean()))
|
||||
.thenThrow(new BusinessException(403, 403, "无权限使用目标"));
|
||||
SkillCapabilityBindingServiceImpl service = new SkillCapabilityBindingServiceImpl(
|
||||
skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class),
|
||||
mock(ResourceAccessService.class), new ObjectMapper());
|
||||
SkillCapabilityBinding binding = binding("WORKFLOW", BigInteger.valueOf(9), "workflowTool");
|
||||
binding.setEnabled(false);
|
||||
|
||||
SkillValidationResult result;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
|
||||
result = service.validateBindings(skill.getId(), List.of(binding), false);
|
||||
}
|
||||
|
||||
assertFalse(result.isValid());
|
||||
SkillValidationIssue issue = result.getIssues().stream()
|
||||
.filter(item -> "TARGET_NO_PERMISSION".equals(item.getCode()))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("ERROR", issue.getSeverity());
|
||||
}
|
||||
|
||||
private SkillCapabilityBinding binding(String type, BigInteger targetId, String runtimeName) {
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setCapabilityType(type);
|
||||
binding.setTargetId(targetId);
|
||||
binding.setRuntimeName(runtimeName);
|
||||
binding.setEnabled(true);
|
||||
return binding;
|
||||
}
|
||||
|
||||
private Skill skill(long id, long tenantId) {
|
||||
Skill skill = new Skill();
|
||||
skill.setId(BigInteger.valueOf(id));
|
||||
skill.setTenantId(BigInteger.valueOf(tenantId));
|
||||
return skill;
|
||||
}
|
||||
|
||||
private SkillCapabilityTarget target() {
|
||||
SkillCapabilityTarget target = new SkillCapabilityTarget();
|
||||
target.setName("target");
|
||||
target.setLogicalRef("workflow:target");
|
||||
target.setStatus("AVAILABLE");
|
||||
return target;
|
||||
}
|
||||
|
||||
private LoginAccount account(long accountId, long tenantId) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(accountId));
|
||||
account.setTenantId(BigInteger.valueOf(tenantId));
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -153,8 +153,8 @@ public class SkillFileServiceImplTransactionTest {
|
||||
|
||||
SkillResource resource = savedResource.get();
|
||||
assertTrue(resource.getIsText());
|
||||
assertEquals("SCRIPT", resource.getKind());
|
||||
assertEquals("PYTHON", resource.getLanguage());
|
||||
assertEquals("SCRIPT", result.getType());
|
||||
assertEquals("PYTHON", result.getLanguage());
|
||||
assertEquals("print('ok')\n", resource.getTextContent());
|
||||
assertNull(resource.getContentRef());
|
||||
assertTrue(result.getIsText());
|
||||
@@ -177,7 +177,7 @@ public class SkillFileServiceImplTransactionTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证二进制资源重命名到 scripts 后转为文本,并释放原内容引用。
|
||||
* 验证二进制资源改为文本扩展名后转为文本,并释放原内容引用。
|
||||
*/
|
||||
@Test
|
||||
public void binaryRenameToScriptConvertsAndReleasesContent() {
|
||||
@@ -197,8 +197,8 @@ public class SkillFileServiceImplTransactionTest {
|
||||
|
||||
assertTrue(resource.getIsText());
|
||||
assertEquals("scripts/tool.py", resource.getNormalizedPath());
|
||||
assertEquals("SCRIPT", resource.getKind());
|
||||
assertEquals("PYTHON", resource.getLanguage());
|
||||
assertEquals("SCRIPT", result.getType());
|
||||
assertEquals("PYTHON", result.getLanguage());
|
||||
assertNull(resource.getContentRef());
|
||||
assertEquals("print('ok')\n", resource.getTextContent());
|
||||
assertTrue(result.getIsText());
|
||||
@@ -206,45 +206,82 @@ public class SkillFileServiceImplTransactionTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文本资源重命名到 assets 后转为二进制内容引用。
|
||||
* 验证文本资源移动到 assets 后仍按扩展名保留文本表示。
|
||||
*/
|
||||
@Test
|
||||
public void textRenameToAssetConvertsToBinaryRepresentation() {
|
||||
public void textRenameToAssetKeepsTextRepresentation() {
|
||||
String sourceHash = "c".repeat(64);
|
||||
SkillResource resource = resource(
|
||||
"references/guide.md", true, "# Guide\n", null, sourceHash, 8L);
|
||||
when(skillResourceService.list(any(QueryWrapper.class)))
|
||||
.thenReturn(List.of(), List.of(resource), List.of(resource), List.of(resource));
|
||||
when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true);
|
||||
when(contentStore.put(any(byte[].class))).thenReturn(NEW_CONTENT_REF);
|
||||
SkillFileRenameRequest request = renameRequest(
|
||||
"references/guide.md", "assets/guide.md", sourceHash);
|
||||
|
||||
SkillFileContent result = service.renameFile(request);
|
||||
|
||||
assertFalse(resource.getIsText());
|
||||
assertEquals("ASSET", resource.getKind());
|
||||
assertEquals(NEW_CONTENT_REF, resource.getContentRef());
|
||||
assertNull(resource.getTextContent());
|
||||
assertFalse(result.getIsText());
|
||||
verify(contentStore).put("# Guide\n".getBytes(StandardCharsets.UTF_8));
|
||||
assertTrue(resource.getIsText());
|
||||
assertEquals("ASSET", result.getType());
|
||||
assertNull(resource.getContentRef());
|
||||
assertEquals("# Guide\n", resource.getTextContent());
|
||||
assertTrue(result.getIsText());
|
||||
verify(contentStore, never()).put(any(byte[].class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 assets 路径不能通过文本创建入口形成非规范表示。
|
||||
* 验证 assets 目录允许创建可编辑文本资源。
|
||||
*/
|
||||
@Test
|
||||
public void createTextAssetIsRejected() {
|
||||
public void createTextAssetUsesCanonicalTextRepresentation() {
|
||||
AtomicReference<SkillResource> savedResource = new AtomicReference<>();
|
||||
when(skillResourceService.list(any(QueryWrapper.class)))
|
||||
.thenAnswer(invocation -> savedResource.get() == null
|
||||
? List.of() : List.of(savedResource.get()));
|
||||
when(skillResourceService.save(any(SkillResource.class))).thenAnswer(invocation -> {
|
||||
SkillResource resource = invocation.getArgument(0);
|
||||
resource.setId(BigInteger.valueOf(11));
|
||||
savedResource.set(resource);
|
||||
return true;
|
||||
});
|
||||
SkillFileSaveRequest request = new SkillFileSaveRequest();
|
||||
request.setSkillId(SKILL_ID);
|
||||
request.setPath("assets/readme.txt");
|
||||
request.setContent("text");
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.createTextFile(request));
|
||||
SkillFileContent result = service.createTextFile(request);
|
||||
|
||||
assertTrue(exception.getMessage().contains("二进制文件管理"));
|
||||
verify(skillResourceService, never()).save(any(SkillResource.class));
|
||||
assertEquals("ASSET", result.getType());
|
||||
assertTrue(result.getIsText());
|
||||
assertEquals("text", savedResource.get().getTextContent());
|
||||
assertNull(savedResource.get().getContentRef());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 scripts 目录中的不透明文件按二进制资源无损保存。
|
||||
*/
|
||||
@Test
|
||||
public void binaryScriptUploadUsesContentStore() {
|
||||
AtomicReference<SkillResource> savedResource = new AtomicReference<>();
|
||||
when(skillResourceService.list(any(QueryWrapper.class)))
|
||||
.thenAnswer(invocation -> savedResource.get() == null
|
||||
? List.of() : List.of(savedResource.get()));
|
||||
when(skillResourceService.save(any(SkillResource.class))).thenAnswer(invocation -> {
|
||||
SkillResource resource = invocation.getArgument(0);
|
||||
resource.setId(BigInteger.valueOf(12));
|
||||
savedResource.set(resource);
|
||||
return true;
|
||||
});
|
||||
|
||||
SkillFileContent result = service.uploadResource(
|
||||
SKILL_ID, "scripts/helper.bin", new TestMultipartFile(
|
||||
"helper.bin", new byte[]{0, (byte) 0xFF, 1}));
|
||||
|
||||
assertEquals("SCRIPT", result.getType());
|
||||
assertFalse(result.getIsText());
|
||||
assertEquals(NEW_CONTENT_REF, savedResource.get().getContentRef());
|
||||
assertNull(savedResource.get().getTextContent());
|
||||
verify(contentStore).put(any(MultipartFile.class), anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,9 +306,9 @@ public class SkillFileServiceImplTransactionTest {
|
||||
|
||||
SkillFileContent result = service.createTextFile(request);
|
||||
|
||||
assertEquals("SCRIPT", savedResource.get().getKind());
|
||||
assertEquals("SCRIPT", result.getType());
|
||||
assertTrue(savedResource.get().getIsText());
|
||||
assertNull(savedResource.get().getLanguage());
|
||||
assertNull(result.getLanguage());
|
||||
assertEquals("text/plain", savedResource.get().getMediaType());
|
||||
assertEquals("puts 'ok'\n", result.getContent());
|
||||
}
|
||||
@@ -284,8 +321,6 @@ public class SkillFileServiceImplTransactionTest {
|
||||
String sourceHash = "d".repeat(64);
|
||||
SkillResource resource = resource(
|
||||
"references/data.json", true, "{}", null, sourceHash, 2L);
|
||||
resource.setKind("REFERENCE");
|
||||
resource.setLanguage(null);
|
||||
resource.setMediaType("application/json");
|
||||
when(skillResourceService.list(any(QueryWrapper.class)))
|
||||
.thenReturn(List.of(resource), List.of(resource));
|
||||
@@ -298,9 +333,9 @@ public class SkillFileServiceImplTransactionTest {
|
||||
|
||||
SkillFileContent result = service.saveContent(request);
|
||||
|
||||
assertEquals("REFERENCE", resource.getKind());
|
||||
assertEquals("REFERENCE", result.getType());
|
||||
assertEquals("application/json", resource.getMediaType());
|
||||
assertNull(resource.getLanguage());
|
||||
assertEquals("JSON", result.getLanguage());
|
||||
assertEquals("application/json", result.getMediaType());
|
||||
assertEquals("{\"ok\":true}\n", result.getContent());
|
||||
}
|
||||
@@ -319,10 +354,8 @@ public class SkillFileServiceImplTransactionTest {
|
||||
persisted.setTenantId(BigInteger.ONE);
|
||||
persisted.setCategoryId(BigInteger.valueOf(3));
|
||||
persisted.setDisplayName("演示 Skill");
|
||||
persisted.setEnabled(false);
|
||||
persisted.setVisibilityScope("DEPT");
|
||||
persisted.setSkillContent(oldContent);
|
||||
persisted.getMetadataJson().put("owner", "qa");
|
||||
AtomicReference<Skill> updateRef = new AtomicReference<>();
|
||||
when(skillService.getOne(any(QueryWrapper.class))).thenReturn(persisted);
|
||||
when(skillService.updateDraftIfContentMatches(any(Skill.class), eq(oldHash)))
|
||||
@@ -344,10 +377,7 @@ public class SkillFileServiceImplTransactionTest {
|
||||
assertEquals(newContent, update.getSkillContent());
|
||||
assertEquals(persisted.getCategoryId(), update.getCategoryId());
|
||||
assertEquals(persisted.getDisplayName(), update.getDisplayName());
|
||||
assertEquals(persisted.getEnabled(), update.getEnabled());
|
||||
assertEquals(persisted.getVisibilityScope(), update.getVisibilityScope());
|
||||
assertNotSame(persisted.getMetadataJson(), update.getMetadataJson());
|
||||
assertEquals(persisted.getMetadataJson(), update.getMetadataJson());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
import com.easyagents.skill.exception.SkillPackageException;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* {@link EasyFlowBundleReader} 外层 ZIP 文件数量边界测试。
|
||||
*/
|
||||
public class EasyFlowBundleReaderEntryLimitTest {
|
||||
|
||||
/**
|
||||
* 验证标准包最大文件数之外允许额外携带一个 EasyFlow manifest。
|
||||
*/
|
||||
@Test
|
||||
public void containsManifestAllowsOneManifestBeyondStandardEntryLimit() {
|
||||
int standardEntryLimit = SkillPackageLimits.defaults().getMaxEntryCount();
|
||||
byte[] bundle = bundle(standardEntryLimit);
|
||||
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
|
||||
|
||||
assertTrue(reader.containsManifest(new ByteArrayInputStream(bundle)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证外层 ZIP 不能借 manifest 配额多携带第二个普通文件。
|
||||
*/
|
||||
@Test
|
||||
public void containsManifestRejectsMoreThanOneEntryBeyondStandardLimit() {
|
||||
int standardEntryLimit = SkillPackageLimits.defaults().getMaxEntryCount();
|
||||
byte[] bundle = bundle(standardEntryLimit + 1);
|
||||
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
|
||||
|
||||
assertThrows(BusinessException.class,
|
||||
() -> reader.containsManifest(new ByteArrayInputStream(bundle)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 损坏的增强包属于客户端输入错误,不能伪装成服务端存储故障。
|
||||
*/
|
||||
@Test
|
||||
public void corruptedBundleUsesClientErrorStatus() {
|
||||
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
|
||||
|
||||
BusinessException detectionError = assertThrows(BusinessException.class,
|
||||
() -> reader.containsManifest(new ByteArrayInputStream("not-a-zip".getBytes(StandardCharsets.UTF_8))));
|
||||
BusinessException prepareError = assertThrows(BusinessException.class,
|
||||
() -> reader.prepare(new ByteArrayInputStream("not-a-zip".getBytes(StandardCharsets.UTF_8))));
|
||||
|
||||
assertEquals(400, detectionError.getHttpStatus());
|
||||
assertEquals(400, prepareError.getHttpStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 之后的非法原始文件名字节也必须被完整扫描并返回稳定错误码。
|
||||
*/
|
||||
@Test
|
||||
public void invalidUtf8EntryNameUsesStablePackageCode() {
|
||||
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
|
||||
|
||||
SkillPackageException detectionError = assertThrows(SkillPackageException.class,
|
||||
() -> reader.containsManifest(new ByteArrayInputStream(invalidUtf8EntryNameBundle())));
|
||||
SkillPackageException prepareError = assertThrows(SkillPackageException.class,
|
||||
() -> reader.prepare(new ByteArrayInputStream(invalidUtf8EntryNameBundle())));
|
||||
|
||||
assertEquals("INVALID_UTF8_ENTRY_NAME", detectionError.getCode());
|
||||
assertEquals("INVALID_UTF8_ENTRY_NAME", prepareError.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 外层 ZIP 中央目录 CRC 被篡改时必须在重新打包前拒绝,并返回稳定错误码。
|
||||
*/
|
||||
@Test
|
||||
public void crcMismatchUsesStablePackageCode() {
|
||||
byte[] corrupted = tamperFirstCentralDirectoryCrc(bundle(1));
|
||||
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
|
||||
|
||||
SkillPackageException detectionError = assertThrows(SkillPackageException.class,
|
||||
() -> reader.containsManifest(new ByteArrayInputStream(corrupted)));
|
||||
SkillPackageException prepareError = assertThrows(SkillPackageException.class,
|
||||
() -> reader.prepare(new ByteArrayInputStream(corrupted)));
|
||||
|
||||
assertEquals("CRC_MISMATCH", detectionError.getCode());
|
||||
assertEquals("CRC_MISMATCH", prepareError.getCode());
|
||||
assertTrue(detectionError.getPath().startsWith("skills/"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建将 manifest 放在末尾的增强包,以覆盖完整枚举边界。
|
||||
*
|
||||
* @param standardEntries 普通文件数量
|
||||
* @return 增强包字节
|
||||
*/
|
||||
private byte[] bundle(int standardEntries) {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
|
||||
for (int index = 0; index < standardEntries; index++) {
|
||||
zip.putNextEntry(new ZipEntry(String.format(
|
||||
"skills/demo-skill/assets/file-%04d.txt", index)));
|
||||
zip.closeEntry();
|
||||
}
|
||||
zip.putNextEntry(new ZipEntry(EasyFlowSkillManifestCodec.MANIFEST_PATH));
|
||||
zip.write("{}".getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建增强包文件数量边界样例失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 manifest 位于非法文件名前方的恶意增强包,验证检测流程不会提前返回。
|
||||
*
|
||||
* @return 恶意增强包字节
|
||||
*/
|
||||
private byte[] invalidUtf8EntryNameBundle() {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) {
|
||||
output.setEncoding(StandardCharsets.ISO_8859_1.name());
|
||||
output.setUseLanguageEncodingFlag(false);
|
||||
output.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NEVER);
|
||||
|
||||
ZipArchiveEntry manifest = new ZipArchiveEntry(EasyFlowSkillManifestCodec.MANIFEST_PATH);
|
||||
output.putArchiveEntry(manifest);
|
||||
output.write("{}".getBytes(StandardCharsets.UTF_8));
|
||||
output.closeArchiveEntry();
|
||||
|
||||
ZipArchiveEntry invalidName = new ZipArchiveEntry("skills/demo-skill/assets/\u00ff.bin");
|
||||
output.putArchiveEntry(invalidName);
|
||||
output.write(new byte[]{1});
|
||||
output.closeArchiveEntry();
|
||||
output.finish();
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建非法 UTF-8 文件名增强包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 篡改首个中央目录条目的 CRC 字段。
|
||||
*
|
||||
* @param source 原始 ZIP
|
||||
* @return 篡改后的 ZIP
|
||||
*/
|
||||
private byte[] tamperFirstCentralDirectoryCrc(byte[] source) {
|
||||
byte[] bytes = Arrays.copyOf(source, source.length);
|
||||
for (int index = 0; index <= bytes.length - 20; index++) {
|
||||
if (bytes[index] == 0x50 && bytes[index + 1] == 0x4B
|
||||
&& bytes[index + 2] == 0x01 && bytes[index + 3] == 0x02) {
|
||||
bytes[index + 16] ^= 0x01;
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("未找到 ZIP 中央目录");
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* EasyFlow manifest 输入侧敏感配置拒绝测试。
|
||||
*/
|
||||
public class EasyFlowManifestStrictInputTest {
|
||||
|
||||
/**
|
||||
* 验证导入包中的凭据键会被明确拒绝,不能依靠静默清洗掩盖不合规包。
|
||||
*/
|
||||
@Test
|
||||
public void decodeShouldRejectCredentialFieldsInsideCapability() {
|
||||
EasyFlowSkillManifestCodec codec = new EasyFlowSkillManifestCodec(
|
||||
new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class));
|
||||
byte[] manifest = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"skills": [
|
||||
{
|
||||
"packageRoot": "demo-skill",
|
||||
"packageHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"capabilities": [
|
||||
{
|
||||
"bindingKey": "demo-skill:0",
|
||||
"capabilityType": "MCP",
|
||||
"runtimeName": "demo",
|
||||
"targetLogicalRef": "mcp:demo",
|
||||
"enabled": false,
|
||||
"token": "must-not-be-accepted"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
assertThrows(BusinessException.class, () -> codec.decode(manifest));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 options 内出现认证字段时也会在输入边界被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void decodeShouldRejectCredentialFieldsInsideOptions() {
|
||||
EasyFlowSkillManifestCodec codec = new EasyFlowSkillManifestCodec(
|
||||
new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class));
|
||||
byte[] manifest = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"skills": [
|
||||
{
|
||||
"packageRoot": "demo-skill",
|
||||
"packageHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"capabilities": [
|
||||
{
|
||||
"bindingKey": "demo-skill:0",
|
||||
"capabilityType": "WORKFLOW",
|
||||
"runtimeName": "demo",
|
||||
"targetLogicalRef": "workflow:demo",
|
||||
"enabled": true,
|
||||
"options": {
|
||||
"timeoutMs": 3000,
|
||||
"authorization": "Bearer must-not-be-accepted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
assertThrows(BusinessException.class, () -> codec.decode(manifest));
|
||||
}
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityTarget;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link EasyFlowSkillManifestCodec} 安全白名单与输入限额测试。
|
||||
*/
|
||||
public class EasyFlowSkillManifestCodecTest {
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
private SkillCapabilityTargetAccessService targetAccessService;
|
||||
private EasyFlowSkillManifestCodec codec;
|
||||
|
||||
/**
|
||||
* 初始化 manifest 编解码器。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
codec = new EasyFlowSkillManifestCodec(objectMapper, targetAccessService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强 manifest 仅导出 HITL/options 白名单字段,不泄露 Token、Header 与嵌套凭据。
|
||||
*
|
||||
* @throws Exception JSON 解析失败
|
||||
*/
|
||||
@Test
|
||||
public void encodeExportsOnlySafeCapabilityConfiguration() throws Exception {
|
||||
SkillCapabilityBinding binding = unresolvedBinding();
|
||||
Map<String, Object> hitl = new LinkedHashMap<>();
|
||||
hitl.put("prompt", "确认执行");
|
||||
hitl.put("token", "hitl-secret");
|
||||
hitl.put("headers", Map.of("Authorization", "Bearer nested-secret"));
|
||||
binding.setHitlConfigJson(hitl);
|
||||
Map<String, Object> options = new LinkedHashMap<>();
|
||||
options.put("timeoutMs", 3_000);
|
||||
options.put("retryCount", 2);
|
||||
options.put("apiKey", "api-secret");
|
||||
options.put("authorization", "Bearer option-secret");
|
||||
options.put("readOnly", List.of("complex-value-must-be-dropped"));
|
||||
binding.setOptionsJson(options);
|
||||
Skill skill = skillWithBindings(List.of(binding));
|
||||
|
||||
byte[] encoded = codec.encode(List.of(skill));
|
||||
String json = new String(encoded, StandardCharsets.UTF_8);
|
||||
Map<String, Object> manifest = objectMapper.readValue(encoded, new TypeReference<>() { });
|
||||
Map<String, Object> encodedBinding = firstBinding(manifest);
|
||||
|
||||
assertFalse(json.contains("hitl-secret"));
|
||||
assertFalse(json.contains("nested-secret"));
|
||||
assertFalse(json.contains("api-secret"));
|
||||
assertFalse(json.contains("option-secret"));
|
||||
assertFalse(json.contains("complex-value-must-be-dropped"));
|
||||
assertEquals(Map.of("prompt", "确认执行"), encodedBinding.get("hitlConfig"));
|
||||
assertEquals(Map.of("timeoutMs", 3_000, "retryCount", 2), encodedBinding.get("options"));
|
||||
assertEquals("unresolved:mcp", encodedBinding.get("targetLogicalRef"));
|
||||
assertFalse(encodedBinding.containsKey("targetId"));
|
||||
verifyNoInteractions(targetAccessService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证目标服务返回的凭据 URI、查询 Token 和绝对路径不会进入增强导出。
|
||||
*
|
||||
* @throws Exception JSON 解析失败
|
||||
*/
|
||||
@Test
|
||||
public void encodeDowngradesUnsafeResolvedTargetMetadata() throws Exception {
|
||||
SkillCapabilityBinding binding = unresolvedBinding();
|
||||
binding.setTargetId(java.math.BigInteger.valueOf(91));
|
||||
binding.setEnabled(true);
|
||||
binding.setTargetLogicalRef("mcp:https://user:secret@example.test?token=stored-secret");
|
||||
SkillCapabilityTarget target = new SkillCapabilityTarget();
|
||||
target.setLogicalRef("mcp:https://user:secret@example.test?token=resolved-secret");
|
||||
target.setName("https://user:secret@example.test/service");
|
||||
target.setRevision("/Users/operator/.config/easyflow/credential.json");
|
||||
when(targetAccessService.requireUsableTarget(binding, false)).thenReturn(target);
|
||||
|
||||
byte[] encoded = codec.encode(List.of(skillWithBindings(List.of(binding))));
|
||||
String json = new String(encoded, StandardCharsets.UTF_8);
|
||||
Map<String, Object> encodedBinding = firstBinding(
|
||||
objectMapper.readValue(encoded, new TypeReference<>() { }));
|
||||
|
||||
assertEquals("unresolved:mcp", encodedBinding.get("targetLogicalRef"));
|
||||
assertFalse(encodedBinding.containsKey("targetName"));
|
||||
assertFalse(encodedBinding.containsKey("targetRevision"));
|
||||
assertFalse(encodedBinding.containsKey("targetId"));
|
||||
assertFalse(json.contains("secret"));
|
||||
assertFalse(json.contains("/Users/operator"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证编码和解码都拒绝超过 1 MiB 的 manifest。
|
||||
*/
|
||||
@Test
|
||||
public void manifestByteLimitIsEnforcedOnEncodeAndDecode() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding();
|
||||
binding.setHitlConfigJson(Map.of("prompt", "x".repeat((int) EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES)));
|
||||
|
||||
assertThrows(BusinessException.class,
|
||||
() -> codec.encode(List.of(skillWithBindings(List.of(binding)))));
|
||||
assertThrows(BusinessException.class,
|
||||
() -> codec.decode(new byte[(int) EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES + 1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证版本和 skills 基础结构必须存在。
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsUnsupportedVersionAndMissingSkills() {
|
||||
assertThrows(BusinessException.class,
|
||||
() -> codec.decode("{\"schemaVersion\":\"2.0\",\"skills\":[]}".getBytes(StandardCharsets.UTF_8)));
|
||||
assertThrows(BusinessException.class,
|
||||
() -> codec.decode("{\"schemaVersion\":\"1.0\"}".getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 targetLogicalRef 只接受能力类型对应的严格逻辑段语法。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsUrlAndQueryInsideTargetLogicalRef() throws Exception {
|
||||
assertRejectedField("targetLogicalRef", "mcp:https://example.test/service");
|
||||
assertRejectedField("targetLogicalRef", "mcp:demo?access_token=must-not-enter");
|
||||
assertRejectedField("targetLogicalRef", "mcp:/Users/operator/.config/mcp.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 targetName 和 targetRevision 拒绝凭据 URI、认证查询参数及绝对路径。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsUnsafeTargetNameAndRevision() throws Exception {
|
||||
assertRejectedField("targetName", "https://user:password@example.test/service");
|
||||
assertRejectedField("targetName", "C:\\Users\\operator\\mcp.json");
|
||||
assertRejectedField("targetRevision", "https://example.test/revision?token=must-not-enter");
|
||||
assertRejectedField("targetRevision", "%2FUsers%2Foperator%2Fcredential.json");
|
||||
assertRejectedField("targetRevision", "https%253A%252F%252Fexample.test%253Ftoken%253Dencoded-secret");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强导入拒绝允许字段字符串内部的实际凭据,并保留精确问题路径。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsCredentialInsideAllowedHitlString() throws Exception {
|
||||
Map<String, Object> binding = validManifestBinding();
|
||||
binding.put("hitlConfig", Map.of("prompt", "Authorization: Bearer actual-secret-value"));
|
||||
byte[] manifest = manifestWithBinding(binding);
|
||||
|
||||
SkillManifestValidationException exception = assertThrows(
|
||||
SkillManifestValidationException.class, () -> codec.decode(manifest));
|
||||
|
||||
assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode());
|
||||
assertEquals("skills[0].capabilities[0].hitlConfig.prompt", exception.getPath());
|
||||
assertFalse(exception.getMessage().contains("actual-secret-value"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强导入会扫描运行时名称、工具名和目标逻辑引用等全部字符串面。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsCredentialsAcrossAllBindingStrings() throws Exception {
|
||||
Map<String, Object> runtimeBinding = validManifestBinding();
|
||||
runtimeBinding.put("runtimeName", "sk-proj-abcdefghijklmnopqrstuvwxyz123456");
|
||||
assertSensitivePath(runtimeBinding, "skills[0].capabilities[0].runtimeName");
|
||||
|
||||
Map<String, Object> toolBinding = validManifestBinding();
|
||||
toolBinding.put("selectedToolNames", List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
|
||||
assertSensitivePath(toolBinding, "skills[0].capabilities[0].selectedToolNames[0]");
|
||||
|
||||
Map<String, Object> targetBinding = validManifestBinding();
|
||||
targetBinding.put("targetLogicalRef", "mcp:sk-proj-abcdefghijklmnopqrstuvwxyz123456");
|
||||
assertSensitivePath(targetBinding, "skills[0].capabilities[0].targetLogicalRef");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 options 只接受协议定义的数值和布尔类型。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsStringTypedOptionsWithExactPath() throws Exception {
|
||||
Map<String, Object> binding = validManifestBinding();
|
||||
binding.put("options", Map.of("timeoutMs", "3000"));
|
||||
|
||||
SkillManifestValidationException exception = assertThrows(
|
||||
SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding)));
|
||||
|
||||
assertEquals("CAPABILITY_OPTION_VALUE_INVALID", exception.getValidationCode());
|
||||
assertEquals("skills[0].capabilities[0].options.timeoutMs", exception.getPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法枚举错误使用稳定消息且不回显原始输入。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeDoesNotEchoInvalidEnumValue() throws Exception {
|
||||
Map<String, Object> binding = validManifestBinding();
|
||||
binding.put("capabilityType", "UNSUPPORTED_PRIVATE_VALUE");
|
||||
|
||||
SkillManifestValidationException exception = assertThrows(
|
||||
SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding)));
|
||||
|
||||
assertEquals("CAPABILITY_TYPE_INVALID", exception.getValidationCode());
|
||||
assertEquals("skills[0].capabilities[0].capabilityType", exception.getPath());
|
||||
assertFalse(exception.getMessage().contains("UNSUPPORTED_PRIVATE_VALUE"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强导出遇到遗留脏 HITL 配置时直接失败且不回显凭据。
|
||||
*/
|
||||
@Test
|
||||
public void encodeRejectsDirtyHitlCredentialInsteadOfExportingIt() {
|
||||
SkillCapabilityBinding binding = unresolvedBinding();
|
||||
binding.setHitlConfigJson(Map.of("description", "token=actual-secret-value"));
|
||||
|
||||
SkillManifestValidationException exception = assertThrows(
|
||||
SkillManifestValidationException.class,
|
||||
() -> codec.encode(List.of(skillWithBindings(List.of(binding)))));
|
||||
|
||||
assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode());
|
||||
assertFalse(exception.getMessage().contains("actual-secret-value"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强包输入不能携带当前环境数据库目标 ID。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsInternalTargetId() throws Exception {
|
||||
assertRejectedField("targetId", 99887766);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Codec 边界拒绝超过导入上限的 Skill 数量。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsMoreThanOneHundredSkills() throws Exception {
|
||||
List<Map<String, Object>> skills = new ArrayList<>();
|
||||
for (int index = 0; index < 101; index++) {
|
||||
skills.add(Map.of("packageRoot", "skill-" + index, "capabilities", List.of()));
|
||||
}
|
||||
byte[] bytes = objectMapper.writeValueAsBytes(Map.of("schemaVersion", "1.0", "skills", skills));
|
||||
|
||||
assertThrows(BusinessException.class, () -> codec.decode(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Codec 边界拒绝超长 packageRoot 字段。
|
||||
*
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
@Test
|
||||
public void decodeRejectsOversizedPackageRoot() throws Exception {
|
||||
Map<String, Object> skill = Map.of(
|
||||
"packageRoot", "s".repeat(129),
|
||||
"capabilities", List.of());
|
||||
byte[] bytes = objectMapper.writeValueAsBytes(
|
||||
Map.of("schemaVersion", "1.0", "skills", List.of(skill)));
|
||||
|
||||
assertThrows(BusinessException.class, () -> codec.decode(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含未解析能力的 Skill。
|
||||
*
|
||||
* @param bindings 能力绑定
|
||||
* @return Skill
|
||||
*/
|
||||
private Skill skillWithBindings(List<SkillCapabilityBinding> bindings) {
|
||||
Skill skill = new Skill();
|
||||
skill.setName("demo-skill");
|
||||
skill.setPackageHash("package-hash");
|
||||
skill.setCapabilityBindings(bindings);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建无需读取目标资源的未解析能力绑定。
|
||||
*
|
||||
* @return 能力绑定
|
||||
*/
|
||||
private SkillCapabilityBinding unresolvedBinding() {
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setCapabilityType("MCP");
|
||||
binding.setTargetLogicalRef("mcp://demo");
|
||||
binding.setRuntimeName("demo_mcp");
|
||||
binding.setEnabled(false);
|
||||
binding.setSelectionMode("SELECTED");
|
||||
binding.setSelectedToolNamesJson(List.of("search"));
|
||||
binding.setHitlEnabled(true);
|
||||
binding.setSortNo(0);
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造单绑定 manifest 并断言指定覆盖字段被拒绝。
|
||||
*
|
||||
* @param field 覆盖字段
|
||||
* @param value 覆盖值
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
private void assertRejectedField(String field, Object value) throws Exception {
|
||||
Map<String, Object> binding = validManifestBinding();
|
||||
binding.put(field, value);
|
||||
byte[] manifest = manifestWithBinding(binding);
|
||||
|
||||
assertThrows(BusinessException.class, () -> codec.decode(manifest));
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言单绑定中的凭据值被拒绝并保留精确路径。
|
||||
*
|
||||
* @param binding 待编码能力对象
|
||||
* @param expectedPath 预期问题路径
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
private void assertSensitivePath(Map<String, Object> binding, String expectedPath) throws Exception {
|
||||
SkillManifestValidationException exception = assertThrows(
|
||||
SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding)));
|
||||
assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode());
|
||||
assertEquals(expectedPath, exception.getPath());
|
||||
assertFalse(exception.getMessage().contains("sk-proj-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单绑定的合法 manifest 能力对象。
|
||||
*
|
||||
* @return 可按测试覆盖字段的能力对象
|
||||
*/
|
||||
private Map<String, Object> validManifestBinding() {
|
||||
Map<String, Object> binding = new LinkedHashMap<>();
|
||||
binding.put("bindingKey", "demo-skill:0");
|
||||
binding.put("capabilityType", "MCP");
|
||||
binding.put("runtimeName", "demo_mcp");
|
||||
binding.put("enabled", false);
|
||||
binding.put("selectionMode", "SELECTED");
|
||||
binding.put("selectedToolNames", List.of("search"));
|
||||
binding.put("targetLogicalRef", "mcp:demo");
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个能力对象封装为合法 manifest JSON。
|
||||
*
|
||||
* @param binding 能力对象
|
||||
* @return manifest JSON 字节
|
||||
* @throws Exception JSON 生成失败
|
||||
*/
|
||||
private byte[] manifestWithBinding(Map<String, Object> binding) throws Exception {
|
||||
Map<String, Object> skill = new LinkedHashMap<>();
|
||||
skill.put("packageRoot", "demo-skill");
|
||||
skill.put("packageHash", "a".repeat(64));
|
||||
skill.put("capabilities", List.of(binding));
|
||||
return objectMapper.writeValueAsBytes(
|
||||
Map.of("schemaVersion", "1.0", "skills", List.of(skill)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取编码结果中的首个能力绑定。
|
||||
*
|
||||
* @param manifest manifest
|
||||
* @return 能力绑定映射
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> firstBinding(Map<String, Object> manifest) {
|
||||
List<Map<String, Object>> skills = (List<Map<String, Object>>) manifest.get("skills");
|
||||
List<Map<String, Object>> bindings = (List<Map<String, Object>>) skills.get(0).get("capabilities");
|
||||
assertTrue(!bindings.isEmpty());
|
||||
return bindings.get(0);
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.entity.SkillResource;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 标准 Skill 包与 EasyFlow 增强包的格式隔离回归测试。
|
||||
*/
|
||||
public class SkillExportFormatIsolationTest {
|
||||
|
||||
private static final String SKILL_ID = "987654321012345678";
|
||||
private static final String TARGET_ID = "998877665544332211";
|
||||
private static final byte[] BINARY_BYTES = new byte[]{0, 1, 2, 3, 127, -1};
|
||||
|
||||
private SkillExportServiceImpl exportService;
|
||||
private SkillService skillService;
|
||||
|
||||
/**
|
||||
* 初始化包含平台能力绑定的 Skill。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
Skill skill = new Skill();
|
||||
skill.setId(new BigInteger(SKILL_ID));
|
||||
skill.setTenantId(BigInteger.valueOf(55667788));
|
||||
skill.setCategoryId(BigInteger.valueOf(66778899));
|
||||
skill.setCurrentApprovalInstanceId(BigInteger.valueOf(77889900));
|
||||
skill.setName("demo-skill");
|
||||
skill.setDescription("Demo skill");
|
||||
skill.setSkillContent("""
|
||||
---
|
||||
name: demo-skill
|
||||
description: Demo skill
|
||||
---
|
||||
# Demo
|
||||
""");
|
||||
skill.setPackageHash("a".repeat(64));
|
||||
String binaryHash = SkillHashes.sha256Hex(BINARY_BYTES);
|
||||
String binaryRef = "sha256:" + binaryHash;
|
||||
SkillResource reference = new SkillResource();
|
||||
reference.setPath("references/guide.md");
|
||||
reference.setNormalizedPath("references/guide.md");
|
||||
reference.setKind("REFERENCE");
|
||||
reference.setMediaType("text/markdown");
|
||||
reference.setIsText(true);
|
||||
reference.setTextContent("# Guide\nportable text\n");
|
||||
reference.setContentHash(SkillHashes.sha256Hex(
|
||||
reference.getTextContent().getBytes(StandardCharsets.UTF_8)));
|
||||
reference.setSize((long) reference.getTextContent().getBytes(StandardCharsets.UTF_8).length);
|
||||
SkillResource binary = new SkillResource();
|
||||
binary.setPath("assets/data.bin");
|
||||
binary.setNormalizedPath("assets/data.bin");
|
||||
binary.setKind("ASSET");
|
||||
binary.setMediaType("application/octet-stream");
|
||||
binary.setIsText(false);
|
||||
binary.setContentRef(binaryRef);
|
||||
binary.setContentHash(binaryHash);
|
||||
binary.setSize((long) BINARY_BYTES.length);
|
||||
skill.setResources(List.of(reference, binary));
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setCapabilityType("MCP");
|
||||
binding.setTargetId(new BigInteger(TARGET_ID));
|
||||
binding.setRuntimeName("demo");
|
||||
binding.setTargetLogicalRef("mcp:demo");
|
||||
binding.setEnabled(false);
|
||||
binding.setSelectionMode("SELECTED");
|
||||
binding.setSelectedToolNamesJson(List.of("search"));
|
||||
binding.setOptionsJson(Map.of("timeoutMs", 3000, "token", "must-not-leak"));
|
||||
skill.setCapabilityBindings(List.of(binding));
|
||||
skillService = mock(SkillService.class);
|
||||
when(skillService.getPackageDetail(skill.getId())).thenReturn(skill);
|
||||
when(skillService.getDetail(skill.getId())).thenReturn(skill);
|
||||
EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(
|
||||
new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class));
|
||||
DBSkillContentStore contentStore = mock(DBSkillContentStore.class);
|
||||
when(contentStore.exists(binaryRef)).thenReturn(true);
|
||||
when(contentStore.open(binaryRef)).thenAnswer(ignored -> new ByteArrayInputStream(BINARY_BYTES));
|
||||
exportService = new SkillExportServiceImpl(skillService, contentStore, manifestCodec);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证标准 ZIP 只包含标准 Skill 内容,不携带 EasyFlow manifest 或平台能力配置。
|
||||
*/
|
||||
@Test
|
||||
public void standardExportShouldExcludePlatformManifestAndBindings() {
|
||||
when(skillService.getDetail(new BigInteger(SKILL_ID)))
|
||||
.thenThrow(new IllegalStateException("capability target unavailable"));
|
||||
|
||||
Map<String, byte[]> entries = export(SkillImportFormat.STANDARD);
|
||||
String allText = text(entries);
|
||||
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/SKILL.md")));
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/references/")));
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/scripts/")));
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/assets/")));
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/references/guide.md")));
|
||||
assertTrue(entries.entrySet().stream().anyMatch(entry -> entry.getKey().endsWith("/assets/data.bin")
|
||||
&& java.util.Arrays.equals(BINARY_BYTES, entry.getValue())));
|
||||
assertFalse(entries.containsKey(EasyFlowSkillManifestCodec.MANIFEST_PATH));
|
||||
assertFalse(allText.contains("targetLogicalRef"));
|
||||
assertFalse(allText.contains("must-not-leak"));
|
||||
assertFalse(allText.contains(SKILL_ID));
|
||||
assertFalse(allText.contains(TARGET_ID));
|
||||
assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(SKILL_ID)));
|
||||
assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(TARGET_ID)));
|
||||
verify(skillService).getPackageDetail(new BigInteger(SKILL_ID));
|
||||
verify(skillService, never()).getDetail(new BigInteger(SKILL_ID));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强包具有独立 manifest,且敏感 options 不会进入导出内容。
|
||||
*/
|
||||
@Test
|
||||
public void easyFlowExportShouldContainSafeManifestAndStandardSkillTree() {
|
||||
Map<String, byte[]> entries = export(SkillImportFormat.EASYFLOW);
|
||||
String manifest = new String(entries.get(EasyFlowSkillManifestCodec.MANIFEST_PATH), StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/") && path.endsWith("/SKILL.md")));
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/")
|
||||
&& path.endsWith("/references/")));
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/")
|
||||
&& path.endsWith("/scripts/")));
|
||||
assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/")
|
||||
&& path.endsWith("/assets/")));
|
||||
assertTrue(entries.entrySet().stream().anyMatch(entry -> entry.getKey().startsWith("skills/")
|
||||
&& entry.getKey().endsWith("/assets/data.bin")
|
||||
&& java.util.Arrays.equals(BINARY_BYTES, entry.getValue())));
|
||||
assertTrue(manifest.contains("targetLogicalRef"));
|
||||
assertTrue(manifest.contains("timeoutMs"));
|
||||
assertFalse(manifest.contains("must-not-leak"));
|
||||
assertFalse(manifest.contains("targetId"));
|
||||
assertFalse(manifest.contains(SKILL_ID));
|
||||
assertFalse(manifest.contains(TARGET_ID));
|
||||
assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(SKILL_ID)));
|
||||
assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(TARGET_ID)));
|
||||
}
|
||||
|
||||
private Map<String, byte[]> export(SkillImportFormat format) {
|
||||
try (SkillExportArtifact artifact = exportService.prepare(List.of(new BigInteger(SKILL_ID)), format)) {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
artifact.transferTo(bytes);
|
||||
return unzip(bytes.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, byte[]> unzip(byte[] bytes) {
|
||||
try {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
try (ZipInputStream zip = new ZipInputStream(
|
||||
new ByteArrayInputStream(bytes), StandardCharsets.UTF_8)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
entries.put(entry.getName(), entry.isDirectory() ? new byte[0] : zip.readAllBytes());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("读取测试导出包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String text(Map<String, byte[]> entries) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
entries.values().forEach(bytes -> result.append(new String(bytes, StandardCharsets.UTF_8)));
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillImportStage;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
import tech.easyflow.skill.entity.SkillResource;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 多 Skill 增强导出的可移植路径与 preview round-trip 测试。
|
||||
*/
|
||||
public class SkillExportRoundTripTest {
|
||||
|
||||
/**
|
||||
* 验证多 Skill `.efskill` 不在路径中暴露数据库 ID,且可被增强导入 preview 完整解析。
|
||||
*/
|
||||
@Test
|
||||
public void multiSkillEasyFlowBundleShouldRoundTripWithoutDatabaseIds() throws IOException {
|
||||
BigInteger firstId = new BigInteger("987654321012345678");
|
||||
BigInteger secondId = new BigInteger("887766554433221100");
|
||||
Skill first = portableSkill(firstId, "alpha-skill");
|
||||
Skill second = portableSkill(secondId, "beta-skill");
|
||||
SkillService exportSkillService = mock(SkillService.class);
|
||||
when(exportSkillService.getPackageDetail(firstId)).thenReturn(first);
|
||||
when(exportSkillService.getPackageDetail(secondId)).thenReturn(second);
|
||||
when(exportSkillService.getDetail(firstId)).thenReturn(first);
|
||||
when(exportSkillService.getDetail(secondId)).thenReturn(second);
|
||||
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(
|
||||
new ObjectMapper(), targetAccessService);
|
||||
SkillExportServiceImpl exportService = new SkillExportServiceImpl(
|
||||
exportSkillService, mock(DBSkillContentStore.class), manifestCodec);
|
||||
|
||||
byte[] standard = export(exportService, List.of(firstId, secondId), SkillImportFormat.STANDARD);
|
||||
Set<String> standardPaths = paths(standard);
|
||||
assertTrue(standardPaths.stream().anyMatch(path -> path.endsWith("alpha-skill/SKILL.md")));
|
||||
assertTrue(standardPaths.stream().anyMatch(path -> path.endsWith("beta-skill/SKILL.md")));
|
||||
assertFalse(standardPaths.stream().anyMatch(path -> path.contains(firstId.toString())));
|
||||
assertFalse(standardPaths.stream().anyMatch(path -> path.contains(secondId.toString())));
|
||||
|
||||
byte[] bundle = export(exportService, List.of(firstId, secondId), SkillImportFormat.EASYFLOW);
|
||||
Set<String> paths = paths(bundle);
|
||||
|
||||
assertTrue(paths.contains(EasyFlowSkillManifestCodec.MANIFEST_PATH));
|
||||
assertTrue(paths.stream().anyMatch(path -> path.endsWith("alpha-skill/SKILL.md")));
|
||||
assertTrue(paths.stream().anyMatch(path -> path.endsWith("beta-skill/SKILL.md")));
|
||||
assertFalse(paths.stream().anyMatch(path -> path.contains(firstId.toString())));
|
||||
assertFalse(paths.stream().anyMatch(path -> path.contains(secondId.toString())));
|
||||
|
||||
FileStorageService fileStorageService = mock(FileStorageService.class);
|
||||
String storedPath = "skill-imports/round-trip.efskill";
|
||||
when(fileStorageService.save(any(MultipartFile.class), anyString())).thenReturn(storedPath);
|
||||
when(fileStorageService.readStream(storedPath))
|
||||
.thenAnswer(ignored -> new ByteArrayInputStream(bundle));
|
||||
SkillImportStage stage = new SkillImportStage();
|
||||
stage.setImportToken("a".repeat(32));
|
||||
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
when(stageStore.create(anyString(), anyString(), any(SkillImportFormat.class))).thenReturn(stage);
|
||||
SkillService importSkillService = mock(SkillService.class);
|
||||
when(importSkillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
SkillImportServiceImpl importService = new SkillImportServiceImpl(
|
||||
importSkillService,
|
||||
mock(SkillCapabilityBindingService.class),
|
||||
targetAccessService,
|
||||
mock(DBSkillContentStore.class),
|
||||
fileStorageService,
|
||||
stageStore,
|
||||
new EasyFlowBundleReader(manifestCodec),
|
||||
mock(ResourceAccessService.class));
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
preview = importService.preview(new TestMultipartFile("skills.efskill", bundle));
|
||||
}
|
||||
|
||||
assertEquals(SkillImportFormat.EASYFLOW.name(), preview.getFormat());
|
||||
assertEquals(2, preview.getSkills().size());
|
||||
assertEquals(Set.of("alpha-skill", "beta-skill"), preview.getSkills().stream()
|
||||
.map(SkillImportPreviewItem::getPackageRoot).collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(preview.getSkills().stream().allMatch(item -> item.getFiles().stream()
|
||||
.anyMatch(file -> "SKILL.md".equals(file.getPath()) && file.isText())));
|
||||
assertTrue(preview.getSkills().stream().allMatch(item -> item.getFiles().stream()
|
||||
.anyMatch(file -> "examples/readme.md".equals(file.getPath())
|
||||
&& "EXAMPLE".equals(file.getKind()) && file.isText())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仅含入口文档的可移植 Skill。
|
||||
*
|
||||
* @param id 数据库 ID
|
||||
* @param name 标准 Skill 名称
|
||||
* @return Skill 实体
|
||||
*/
|
||||
private Skill portableSkill(BigInteger id, String name) {
|
||||
String content = "---\nname: " + name + "\ndescription: Portable " + name + "\n---\n# " + name + "\n";
|
||||
String exampleContent = "# Example\n";
|
||||
String exampleHash = SkillHashes.sha256Hex(exampleContent.getBytes(StandardCharsets.UTF_8));
|
||||
String canonical = "SKILL.md\n"
|
||||
+ SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n"
|
||||
+ "examples/readme.md\n" + exampleHash + "\n";
|
||||
SkillResource example = new SkillResource();
|
||||
example.setPath("examples/readme.md");
|
||||
example.setNormalizedPath("examples/readme.md");
|
||||
example.setKind("EXAMPLE");
|
||||
example.setMediaType("text/markdown");
|
||||
example.setIsText(true);
|
||||
example.setTextContent(exampleContent);
|
||||
example.setContentHash(exampleHash);
|
||||
example.setSize((long) exampleContent.getBytes(StandardCharsets.UTF_8).length);
|
||||
Skill skill = new Skill();
|
||||
skill.setId(id);
|
||||
skill.setName(name);
|
||||
skill.setDescription("Portable " + name);
|
||||
skill.setSkillContent(content);
|
||||
skill.setPackageHash(SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
skill.setResources(List.of(example));
|
||||
skill.setCapabilityBindings(List.of());
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出指定格式的包字节。
|
||||
*
|
||||
* @param service 导出服务
|
||||
* @param ids Skill ID
|
||||
* @param format 包格式
|
||||
* @return 导出包字节
|
||||
*/
|
||||
private byte[] export(SkillExportServiceImpl service,
|
||||
List<BigInteger> ids,
|
||||
SkillImportFormat format) {
|
||||
try (SkillExportArtifact artifact = service.prepare(ids, format)) {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
artifact.transferTo(output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 ZIP 非目录项路径。
|
||||
*
|
||||
* @param bytes ZIP 字节
|
||||
* @return 条目路径
|
||||
*/
|
||||
private Set<String> paths(byte[] bytes) {
|
||||
try {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
try (ZipInputStream zip = new ZipInputStream(
|
||||
new ByteArrayInputStream(bytes), StandardCharsets.UTF_8)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
result.add(entry.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("读取 round-trip 导出包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存 MultipartFile 测试替身。
|
||||
*/
|
||||
private static final class TestMultipartFile implements MultipartFile {
|
||||
|
||||
private final String filename;
|
||||
private final byte[] bytes;
|
||||
|
||||
private TestMultipartFile(String filename, byte[] bytes) {
|
||||
this.filename = filename;
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
@Override public String getName() { return "file"; }
|
||||
@Override public String getOriginalFilename() { return filename; }
|
||||
@Override public String getContentType() { return "application/vnd.easyflow.skill+zip"; }
|
||||
@Override public boolean isEmpty() { return bytes.length == 0; }
|
||||
@Override public long getSize() { return bytes.length; }
|
||||
@Override public byte[] getBytes() { return bytes.clone(); }
|
||||
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
|
||||
@Override public void transferTo(File destination) throws IOException {
|
||||
org.springframework.util.FileCopyUtils.copy(bytes, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillImportStage;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillImportServiceImpl} 同名冲突隐私回归测试。
|
||||
*/
|
||||
public class SkillImportConflictPrivacyTest {
|
||||
|
||||
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
|
||||
private static final BigInteger TENANT_ID = BigInteger.ONE;
|
||||
private static final String IMPORT_TOKEN = "c".repeat(32);
|
||||
private static final String SKILL_NAME = "private-skill";
|
||||
private static final String STORED_PATH = "skill-imports/private-skill.zip";
|
||||
|
||||
/**
|
||||
* 验证无管理权的草稿和已发布 Skill 在预览中完全使用相同冲突结果。
|
||||
*/
|
||||
@Test
|
||||
public void previewRedactsUnauthorizedDraftAndPublishedConflicts() {
|
||||
Skill draft = existing("private-draft", PublishStatus.DRAFT);
|
||||
Skill published = existing("private-published", PublishStatus.PUBLISHED);
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(draft, published));
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
SkillImportServiceImpl service = service(skillService, accessService,
|
||||
mock(FileStorageService.class), mock(SkillImportStageStore.class));
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> ignored = login()) {
|
||||
preview = service.previewStandardForTest(new ByteArrayInputStream(
|
||||
standardPackage(List.of(draft.getName(), published.getName()))));
|
||||
}
|
||||
|
||||
assertEquals(2, preview.getSkills().size());
|
||||
for (SkillImportPreviewItem item : preview.getSkills()) {
|
||||
assertEquals("NAME_UNAVAILABLE", item.getConflictReason());
|
||||
assertFalse(item.getOverwriteAllowed());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证确认阶段不会根据无权 Skill 的草稿或发布状态返回不同结果。
|
||||
*/
|
||||
@Test
|
||||
public void confirmRejectsUnauthorizedDraftAndPublishedWithSameResult() {
|
||||
BusinessException draft = confirmAgainstUnauthorized(PublishStatus.DRAFT);
|
||||
BusinessException published = confirmAgainstUnauthorized(PublishStatus.PUBLISHED);
|
||||
|
||||
assertNameUnavailable(draft);
|
||||
assertNameUnavailable(published);
|
||||
assertEquals(draft.getMessage(), published.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证预查后发生的并发唯一键冲突与无权同名冲突使用同一公开结果。
|
||||
*/
|
||||
@Test
|
||||
public void confirmMapsConcurrentUniqueNameConflictToNameUnavailable() {
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
when(skillService.saveDraft(any(Skill.class)))
|
||||
.thenThrow(new BusinessException(409, 4092, "当前租户已存在同名 Skill"));
|
||||
FileStorageService storage = storedPackage();
|
||||
SkillImportStageStore stageStore = stageStore();
|
||||
SkillImportServiceImpl service = service(skillService, mock(ResourceAccessService.class), storage, stageStore);
|
||||
SkillImportConfirmRequest request = confirmRequest(SkillImportConflictStrategy.REJECT);
|
||||
|
||||
BusinessException exception;
|
||||
try (MockedStatic<SaTokenUtil> ignored = login()) {
|
||||
exception = assertThrows(BusinessException.class, () -> service.confirm(request));
|
||||
}
|
||||
|
||||
assertNameUnavailable(exception);
|
||||
}
|
||||
|
||||
/**
|
||||
* 针对指定发布状态执行一次无管理权限的覆盖确认。
|
||||
*
|
||||
* @param status 已存在 Skill 的发布状态
|
||||
* @return 确认阶段抛出的名称不可用异常
|
||||
*/
|
||||
private BusinessException confirmAgainstUnauthorized(PublishStatus status) {
|
||||
Skill existing = existing(SKILL_NAME, status);
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(existing));
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
SkillImportServiceImpl service = service(skillService, accessService, storedPackage(), stageStore());
|
||||
|
||||
BusinessException exception;
|
||||
try (MockedStatic<SaTokenUtil> ignored = login()) {
|
||||
exception = assertThrows(BusinessException.class,
|
||||
() -> service.confirm(confirmRequest(SkillImportConflictStrategy.OVERWRITE)));
|
||||
}
|
||||
verify(skillService, never()).overwriteImportedDraft(any(Skill.class));
|
||||
return exception;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仅包含当前测试所需依赖的导入服务。
|
||||
*
|
||||
* @param skillService Skill 管理服务
|
||||
* @param accessService 资源访问服务
|
||||
* @param storage 文件存储服务
|
||||
* @param stageStore 导入暂存服务
|
||||
* @return 导入服务实例
|
||||
*/
|
||||
private SkillImportServiceImpl service(SkillService skillService,
|
||||
ResourceAccessService accessService,
|
||||
FileStorageService storage,
|
||||
SkillImportStageStore stageStore) {
|
||||
return new SkillImportServiceImpl(skillService, mock(SkillCapabilityBindingService.class),
|
||||
mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class), storage, stageStore,
|
||||
mock(EasyFlowBundleReader.class), accessService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建指定名称和发布状态的既有 Skill。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
* @param status 发布状态
|
||||
* @return Skill 测试数据
|
||||
*/
|
||||
private Skill existing(String name, PublishStatus status) {
|
||||
Skill skill = new Skill();
|
||||
skill.setId(BigInteger.valueOf(Math.abs(name.hashCode())));
|
||||
skill.setTenantId(TENANT_ID);
|
||||
skill.setName(name);
|
||||
skill.setPublishStatus(status.getCode());
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建导入确认请求。
|
||||
*
|
||||
* @param strategy 名称冲突处理策略
|
||||
* @return 导入确认请求
|
||||
*/
|
||||
private SkillImportConfirmRequest confirmRequest(SkillImportConflictStrategy strategy) {
|
||||
SkillImportConfirmRequest request = new SkillImportConfirmRequest();
|
||||
request.setImportToken(IMPORT_TOKEN);
|
||||
request.setConflictStrategy(strategy.name());
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可消费固定导入记录的暂存服务替身。
|
||||
*
|
||||
* @return 导入暂存服务替身
|
||||
*/
|
||||
private SkillImportStageStore stageStore() {
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
SkillImportStage stage = new SkillImportStage();
|
||||
stage.setImportToken(IMPORT_TOKEN);
|
||||
stage.setFilePath(STORED_PATH);
|
||||
stage.setFormat(SkillImportFormat.STANDARD.name());
|
||||
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
|
||||
when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage);
|
||||
return stageStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建返回标准 Skill 测试包的文件存储替身。
|
||||
*
|
||||
* @return 文件存储服务替身
|
||||
*/
|
||||
private FileStorageService storedPackage() {
|
||||
byte[] bytes = standardPackage(List.of(SKILL_NAME));
|
||||
FileStorageService storage = mock(FileStorageService.class);
|
||||
try {
|
||||
when(storage.readStream(STORED_PATH)).thenAnswer(ignored -> new ByteArrayInputStream(bytes));
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new IllegalStateException("创建导入包存储测试替身失败", exception);
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造包含指定 Skill 名称的标准 ZIP 包。
|
||||
*
|
||||
* @param names Skill 名称列表
|
||||
* @return ZIP 包字节
|
||||
*/
|
||||
private byte[] standardPackage(List<String> names) {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
|
||||
for (String name : names) {
|
||||
zip.putNextEntry(new ZipEntry(name + "/SKILL.md"));
|
||||
zip.write(("---\nname: " + name + "\ndescription: Privacy fixture\n---\n# Privacy\n")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建导入冲突测试包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建固定租户与账号的登录上下文。
|
||||
*
|
||||
* @return 可自动关闭的静态方法替身
|
||||
*/
|
||||
private MockedStatic<SaTokenUtil> login() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(ACCOUNT_ID);
|
||||
account.setTenantId(TENANT_ID);
|
||||
MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
return saToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言异常为稳定、无资源细节的名称不可用结果。
|
||||
*
|
||||
* @param exception 待校验的业务异常
|
||||
*/
|
||||
private void assertNameUnavailable(BusinessException exception) {
|
||||
assertEquals(409, exception.getHttpStatus());
|
||||
assertEquals(4092, exception.getErrorCode());
|
||||
assertEquals("Skill 名称不可用:" + SKILL_NAME, exception.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingServiceImpl;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
|
||||
import tech.easyflow.skill.entity.SkillImportStage;
|
||||
import tech.easyflow.skill.enums.SkillCapabilityType;
|
||||
import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillImportServiceImpl} 只读导入预检契约测试。
|
||||
*/
|
||||
public class SkillImportServiceImplPreviewTest {
|
||||
|
||||
/**
|
||||
* 验证可解析但校验失败的包返回完整问题列表,二进制资源不会触发正式提交。
|
||||
*/
|
||||
@Test
|
||||
public void previewReturnsValidationReportForParseableInvalidPackage() {
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
SkillImportServiceImpl service = new SkillImportServiceImpl(
|
||||
skillService,
|
||||
mock(SkillCapabilityBindingService.class),
|
||||
mock(SkillCapabilityTargetAccessService.class),
|
||||
mock(DBSkillContentStore.class),
|
||||
mock(FileStorageService.class),
|
||||
mock(SkillImportStageStore.class),
|
||||
mock(EasyFlowBundleReader.class),
|
||||
mock(ResourceAccessService.class));
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
preview = service.previewStandardForTest(new ByteArrayInputStream(invalidPackage()));
|
||||
}
|
||||
|
||||
assertEquals("STANDARD", preview.getFormat());
|
||||
assertEquals(1, preview.getSkills().size());
|
||||
assertEquals(1, preview.getSkills().get(0).getAssetCount());
|
||||
assertTrue(preview.getIssues().stream().anyMatch(issue -> "INVALID_NAME".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法 EasyFlow Bundle 也返回结构化预览问题,不生成一次性导入令牌。
|
||||
*/
|
||||
@Test
|
||||
public void previewReturnsStructuredIssueForInvalidEasyFlowBundle() throws Exception {
|
||||
FileStorageService fileStorageService = mock(FileStorageService.class);
|
||||
EasyFlowBundleReader bundleReader = mock(EasyFlowBundleReader.class);
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(128L);
|
||||
when(file.getOriginalFilename()).thenReturn("invalid.efskill");
|
||||
when(fileStorageService.save(file, "skill-imports/1")).thenReturn("stored-invalid-bundle");
|
||||
when(fileStorageService.readStream("stored-invalid-bundle"))
|
||||
.thenAnswer(invocation -> new ByteArrayInputStream(new byte[]{1, 2, 3}));
|
||||
when(bundleReader.containsManifest(any())).thenReturn(true);
|
||||
when(bundleReader.prepare(any())).thenThrow(new BusinessException("EasyFlow manifest 包含敏感字段"));
|
||||
SkillImportServiceImpl service = new SkillImportServiceImpl(
|
||||
mock(SkillService.class), mock(SkillCapabilityBindingService.class),
|
||||
mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class),
|
||||
fileStorageService, stageStore, bundleReader, mock(ResourceAccessService.class));
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
preview = service.preview(file);
|
||||
}
|
||||
|
||||
assertEquals("EASYFLOW", preview.getFormat());
|
||||
assertTrue(preview.getSkills().isEmpty());
|
||||
assertTrue(preview.getImportToken() == null);
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.anyMatch(issue -> "EASYFLOW_BUNDLE_INVALID".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证敏感值 manifest 异常保留稳定问题码和精确路径,且响应不回显凭据。
|
||||
*
|
||||
* @throws Exception 测试输入流构造失败
|
||||
*/
|
||||
@Test
|
||||
public void previewKeepsSensitiveManifestIssueCodeAndPath() throws Exception {
|
||||
FileStorageService fileStorageService = mock(FileStorageService.class);
|
||||
EasyFlowBundleReader bundleReader = mock(EasyFlowBundleReader.class);
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(128L);
|
||||
when(file.getOriginalFilename()).thenReturn("unsafe.efskill");
|
||||
when(fileStorageService.save(file, "skill-imports/1")).thenReturn("stored-unsafe-bundle");
|
||||
when(fileStorageService.readStream("stored-unsafe-bundle"))
|
||||
.thenAnswer(invocation -> new ByteArrayInputStream(new byte[]{1, 2, 3}));
|
||||
when(bundleReader.containsManifest(any())).thenReturn(true);
|
||||
when(bundleReader.prepare(any())).thenThrow(new SkillManifestValidationException(
|
||||
"SENSITIVE_VALUE_DETECTED", "skills[0].capabilities[0].hitlConfig.prompt",
|
||||
"EasyFlow Skill manifest 不能包含认证凭据"));
|
||||
SkillImportServiceImpl service = new SkillImportServiceImpl(
|
||||
mock(SkillService.class), mock(SkillCapabilityBindingService.class),
|
||||
mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class),
|
||||
fileStorageService, mock(SkillImportStageStore.class), bundleReader,
|
||||
mock(ResourceAccessService.class));
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
preview = service.preview(file);
|
||||
}
|
||||
|
||||
assertEquals("SENSITIVE_VALUE_DETECTED", preview.getIssues().get(0).getCode());
|
||||
assertEquals("skills[0].capabilities[0].hitlConfig.prompt", preview.getIssues().get(0).getPath());
|
||||
assertTrue(preview.getIssues().get(0).getMessage().contains("不能包含认证凭据"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正式服务契约只暴露上传预览、单次确认和取消,不保留无令牌直导入口。
|
||||
*/
|
||||
@Test
|
||||
public void publicImportContractHasNoTokenBypass() {
|
||||
List<String> declaredMethods = Arrays.stream(SkillImportService.class.getDeclaredMethods())
|
||||
.map(java.lang.reflect.Method::getName)
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
assertEquals(List.of("cancel", "confirm", "preview"), declaredMethods);
|
||||
}
|
||||
|
||||
/**
|
||||
* 损坏的标准包与增强包都应通过 preview 返回结构化问题,而非中断为服务器错误。
|
||||
*
|
||||
* @throws Exception 模拟文件存储流配置失败
|
||||
*/
|
||||
@Test
|
||||
public void corruptedArchivesReturnStructuredPreviewIssues() throws Exception {
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
|
||||
SkillImportPreview standard = previewBrokenArchive("broken.zip");
|
||||
SkillImportPreview enhanced = previewBrokenArchive("broken.efskill");
|
||||
|
||||
assertTrue(standard.getIssues().stream()
|
||||
.anyMatch(issue -> "STANDARD_PACKAGE_INVALID".equals(issue.getCode())));
|
||||
assertTrue(enhanced.getIssues().stream()
|
||||
.anyMatch(issue -> "EASYFLOW_BUNDLE_INVALID".equals(issue.getCode())));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建损坏归档的上传预览。
|
||||
*
|
||||
* @param filename 上传文件名
|
||||
* @return 结构化失败预览
|
||||
* @throws Exception 模拟文件存储流配置失败
|
||||
*/
|
||||
private SkillImportPreview previewBrokenArchive(String filename) throws Exception {
|
||||
byte[] bytes = "not-a-zip".getBytes(StandardCharsets.UTF_8);
|
||||
String storedPath = "skill-imports/" + filename;
|
||||
FileStorageService fileStorageService = mock(FileStorageService.class);
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn((long) bytes.length);
|
||||
when(file.getOriginalFilename()).thenReturn(filename);
|
||||
when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath);
|
||||
when(fileStorageService.readStream(storedPath))
|
||||
.thenAnswer(invocation -> new ByteArrayInputStream(bytes));
|
||||
SkillImportServiceImpl service = new SkillImportServiceImpl(
|
||||
mock(SkillService.class), mock(SkillCapabilityBindingService.class),
|
||||
mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class),
|
||||
fileStorageService, mock(SkillImportStageStore.class),
|
||||
new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class)),
|
||||
mock(ResourceAccessService.class));
|
||||
return service.preview(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强包预览聚合能力绑定静态问题,并保留未解析目标供后续映射。
|
||||
*/
|
||||
@Test
|
||||
public void enhancedPreviewReturnsStructuredCapabilityIssuesWithoutRejectingUnresolvedTargets() throws Exception {
|
||||
byte[] bundle = invalidCapabilityBundle();
|
||||
String storedPath = "skill-imports/static-capability-preview.efskill";
|
||||
FileStorageService fileStorageService = mock(FileStorageService.class);
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn((long) bundle.length);
|
||||
when(file.getOriginalFilename()).thenReturn("static-capability-preview.efskill");
|
||||
when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath);
|
||||
when(fileStorageService.readStream(storedPath))
|
||||
.thenAnswer(invocation -> new ByteArrayInputStream(bundle));
|
||||
SkillImportStage stage = new SkillImportStage();
|
||||
stage.setImportToken("b".repeat(32));
|
||||
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
when(stageStore.create(storedPath, "static-capability-preview.efskill", SkillImportFormat.EASYFLOW))
|
||||
.thenReturn(stage);
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
SkillCapabilityBindingService capabilityService = new SkillCapabilityBindingServiceImpl(
|
||||
mock(SkillMapper.class), targetAccessService, mock(McpAccessPermissionChecker.class),
|
||||
resourceAccessService, objectMapper);
|
||||
EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(objectMapper, targetAccessService);
|
||||
SkillImportServiceImpl service = new SkillImportServiceImpl(
|
||||
skillService, capabilityService, targetAccessService, mock(DBSkillContentStore.class),
|
||||
fileStorageService, stageStore, new EasyFlowBundleReader(manifestCodec), resourceAccessService);
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
preview = service.preview(file);
|
||||
}
|
||||
|
||||
assertEquals("EASYFLOW", preview.getFormat());
|
||||
assertEquals("b".repeat(32), preview.getImportToken());
|
||||
assertEquals(3, preview.getCapabilityMappings().size());
|
||||
assertTrue(preview.getCapabilityMappings().stream()
|
||||
.allMatch(mapping -> "UNRESOLVED".equals(mapping.getStatus())));
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.anyMatch(issue -> "CAPABILITY_OPTION_VALUE_INVALID".equals(issue.getCode())));
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.anyMatch(issue -> "RUNTIME_NAME_DUPLICATE".equals(issue.getCode())));
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.anyMatch(issue -> "MCP_SELECTION_MODE_NOT_ALLOWED".equals(issue.getCode())));
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.anyMatch(issue -> "MCP_EXECUTION_MODE_NOT_ALLOWED".equals(issue.getCode())));
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.anyMatch(issue -> "MCP_TOOL_SELECTION_EMPTY".equals(issue.getCode())));
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.filter(issue -> (issue.getCode() != null && issue.getCode().startsWith("MCP_"))
|
||||
|| "CAPABILITY_OPTION_VALUE_INVALID".equals(issue.getCode())
|
||||
|| "RUNTIME_NAME_DUPLICATE".equals(issue.getCode()))
|
||||
.allMatch(issue -> issue.getPath().startsWith("skills[demo-skill].capabilities[")));
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.noneMatch(issue -> "TARGET_UNRESOLVED".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强导入自动映射 MCP 时必须传播权限拒绝,不能降级为未解析映射或包结构问题。
|
||||
*
|
||||
* @throws Exception 模拟文件存储流配置失败
|
||||
*/
|
||||
@Test
|
||||
public void enhancedPreviewPropagatesMcpPermissionDenial() throws Exception {
|
||||
byte[] bundle = invalidCapabilityBundle();
|
||||
String storedPath = "skill-imports/mcp-permission-preview.efskill";
|
||||
FileStorageService fileStorageService = mock(FileStorageService.class);
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn((long) bundle.length);
|
||||
when(file.getOriginalFilename()).thenReturn("mcp-permission-preview.efskill");
|
||||
when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath);
|
||||
when(fileStorageService.readStream(storedPath))
|
||||
.thenAnswer(invocation -> new ByteArrayInputStream(bundle));
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
when(targetAccessService.resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo"))
|
||||
.thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"));
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
SkillImportServiceImpl service = new SkillImportServiceImpl(
|
||||
skillService, mock(SkillCapabilityBindingService.class), targetAccessService,
|
||||
mock(DBSkillContentStore.class), fileStorageService, stageStore,
|
||||
new EasyFlowBundleReader(new EasyFlowSkillManifestCodec(objectMapper, targetAccessService)),
|
||||
mock(ResourceAccessService.class));
|
||||
|
||||
BusinessException exception;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
exception = assertThrows(BusinessException.class, () -> service.preview(file));
|
||||
}
|
||||
|
||||
assertEquals(403, exception.getHttpStatus());
|
||||
verify(targetAccessService).resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo");
|
||||
verify(stageStore, never()).create(any(), any(), any());
|
||||
verify(fileStorageService).delete(storedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含二进制资源和非法名称的可解析标准包。
|
||||
*
|
||||
* @return ZIP 字节
|
||||
*/
|
||||
private byte[] invalidPackage() {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
|
||||
writeEntry(zip, "invalid-skill/SKILL.md", ("---\n"
|
||||
+ "name: Invalid Name\n"
|
||||
+ "description: Invalid preview fixture\n"
|
||||
+ "---\n# Invalid\n").getBytes(StandardCharsets.UTF_8));
|
||||
writeEntry(zip, "invalid-skill/assets/data.bin", new byte[]{0, 1, 2});
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建导入预检测试包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含多类能力静态配置错误、但结构与安全边界合法的增强包。
|
||||
*
|
||||
* @return `.efskill` 字节
|
||||
*/
|
||||
private byte[] invalidCapabilityBundle() {
|
||||
try {
|
||||
String name = "demo-skill";
|
||||
String content = "---\nname: demo-skill\ndescription: Capability preview fixture\n---\n# Demo\n";
|
||||
Map<String, Object> first = new java.util.LinkedHashMap<>();
|
||||
first.put("bindingKey", "demo-skill:0");
|
||||
first.put("capabilityType", "WORKFLOW");
|
||||
first.put("runtimeName", "sharedTool");
|
||||
first.put("selectionMode", "SELECTED");
|
||||
first.put("selectedToolNames", List.of("search"));
|
||||
first.put("targetLogicalRef", "workflow:first");
|
||||
first.put("options", Map.of("timeoutMs", 99));
|
||||
Map<String, Object> second = new java.util.LinkedHashMap<>();
|
||||
second.put("bindingKey", "demo-skill:1");
|
||||
second.put("capabilityType", "PLUGIN_ITEM");
|
||||
second.put("runtimeName", "sharedTool");
|
||||
second.put("targetLogicalRef", "plugin-item:demo/tool");
|
||||
second.put("options", Map.of("retryCount", 11));
|
||||
Map<String, Object> third = new java.util.LinkedHashMap<>();
|
||||
third.put("bindingKey", "demo-skill:2");
|
||||
third.put("capabilityType", "MCP");
|
||||
third.put("runtimeName", "mcpTools");
|
||||
third.put("selectionMode", "SELECTED");
|
||||
third.put("selectedToolNames", List.of());
|
||||
third.put("executionMode", "SYNC");
|
||||
third.put("targetLogicalRef", "mcp:demo");
|
||||
Map<String, Object> manifest = Map.of(
|
||||
"schemaVersion", "1.0",
|
||||
"skills", List.of(Map.of(
|
||||
"packageRoot", name,
|
||||
"packageHash", packageHash(content),
|
||||
"capabilities", List.of(first, second, third))));
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
|
||||
writeEntry(zip, EasyFlowSkillManifestCodec.MANIFEST_PATH,
|
||||
new ObjectMapper().writeValueAsBytes(manifest));
|
||||
writeEntry(zip, "skills/" + name + "/SKILL.md", content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建增强能力预检测试包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算仅含入口文档的 Skill 包 hash。
|
||||
*
|
||||
* @param content SKILL.md 内容
|
||||
* @return 包 hash
|
||||
*/
|
||||
private String packageHash(String content) {
|
||||
String canonical = "SKILL.md\n"
|
||||
+ SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n";
|
||||
return SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 ZIP 文件项。
|
||||
*
|
||||
* @param zip ZIP 输出流
|
||||
* @param path 包内路径
|
||||
* @param content 文件内容
|
||||
* @throws Exception 写入失败
|
||||
*/
|
||||
private void writeEntry(ZipOutputStream zip, String path, byte[] content) throws Exception {
|
||||
zip.putNextEntry(new ZipEntry(path));
|
||||
zip.write(content);
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillImportStage;
|
||||
import tech.easyflow.skill.enums.SkillCapabilityType;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.skill.validation.SkillValidationResult;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillImportServiceImpl} 批量查询与请求内能力映射缓存测试。
|
||||
*/
|
||||
public class SkillImportServiceImplQueryEfficiencyTest {
|
||||
|
||||
private static final BigInteger TENANT_ID = BigInteger.ONE;
|
||||
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
|
||||
private static final String STORED_PATH = "skill-imports/query-efficiency.efskill";
|
||||
private static final String IMPORT_TOKEN = "a".repeat(32);
|
||||
private static final String SHARED_LOGICAL_REF = "workflow:shared-flow";
|
||||
|
||||
/**
|
||||
* 验证多 Skill 预览只执行一次名称批量查询,同时保留逐项冲突标记语义。
|
||||
*/
|
||||
@Test
|
||||
public void previewLoadsAllNameConflictsWithOneQuery() {
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
Skill existing = new Skill();
|
||||
existing.setName("beta-skill");
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(existing));
|
||||
SkillImportServiceImpl service = service(skillService,
|
||||
mock(SkillCapabilityTargetAccessService.class),
|
||||
mock(SkillCapabilityBindingService.class),
|
||||
mock(FileStorageService.class),
|
||||
mock(SkillImportStageStore.class));
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> saToken = login()) {
|
||||
preview = service.previewStandardForTest(new ByteArrayInputStream(standardPackage(List.of(
|
||||
"alpha-skill", "beta-skill", "gamma-skill"))));
|
||||
}
|
||||
|
||||
verify(skillService, times(1)).list(any(QueryWrapper.class));
|
||||
assertEquals(3, preview.getSkills().size());
|
||||
assertFalse(preview.getSkills().get(0).isConflict());
|
||||
assertTrue(preview.getSkills().get(1).isConflict());
|
||||
assertFalse(preview.getSkills().get(2).isConflict());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强导入预览会缓存未匹配的逻辑引用,重复绑定不会重复访问目标解析服务。
|
||||
*/
|
||||
@Test
|
||||
public void enhancedPreviewCachesUnresolvedLogicalRefWithinRequest() {
|
||||
byte[] bundle = enhancedPackage();
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
FileStorageService fileStorageService = storedBundle(bundle);
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
when(stageStore.create(anyString(), anyString(), any(SkillImportFormat.class)))
|
||||
.thenReturn(stage(STORED_PATH));
|
||||
SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class);
|
||||
SkillValidationResult validBindings = new SkillValidationResult();
|
||||
validBindings.setValid(true);
|
||||
when(bindingService.validateImportBindings(any())).thenReturn(validBindings);
|
||||
SkillImportServiceImpl service = service(skillService, targetAccessService,
|
||||
bindingService, fileStorageService, stageStore);
|
||||
MultipartFile file = upload(bundle);
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> saToken = login()) {
|
||||
preview = service.preview(file);
|
||||
}
|
||||
|
||||
verify(targetAccessService, times(1))
|
||||
.resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF);
|
||||
assertEquals(2, preview.getCapabilityMappings().size());
|
||||
assertTrue(preview.getCapabilityMappings().stream()
|
||||
.allMatch(mapping -> "UNRESOLVED".equals(mapping.getStatus())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证多 Skill 导入确认也只执行一次名称批量查询。
|
||||
*/
|
||||
@Test
|
||||
public void confirmLoadsAllNameConflictsWithOneQuery() {
|
||||
byte[] skillPackage = standardPackage(List.of("alpha-skill", "beta-skill", "gamma-skill"));
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
AtomicInteger nextId = new AtomicInteger(40);
|
||||
when(skillService.saveDraft(any(Skill.class))).thenAnswer(invocation -> {
|
||||
Skill skill = invocation.getArgument(0);
|
||||
skill.setId(BigInteger.valueOf(nextId.incrementAndGet()));
|
||||
return skill;
|
||||
});
|
||||
FileStorageService fileStorageService = storedBundle(skillPackage);
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage(STORED_PATH, SkillImportFormat.STANDARD));
|
||||
SkillImportServiceImpl service = service(skillService,
|
||||
mock(SkillCapabilityTargetAccessService.class),
|
||||
mock(SkillCapabilityBindingService.class), fileStorageService, stageStore);
|
||||
SkillImportConfirmRequest request = new SkillImportConfirmRequest();
|
||||
request.setImportToken(IMPORT_TOKEN);
|
||||
request.setConflictStrategy(SkillImportConflictStrategy.REJECT.name());
|
||||
|
||||
List<Skill> imported;
|
||||
try (MockedStatic<SaTokenUtil> saToken = login()) {
|
||||
imported = service.confirm(request);
|
||||
}
|
||||
|
||||
verify(skillService, times(1)).list(any(QueryWrapper.class));
|
||||
verify(skillService, times(3)).saveDraft(any(Skill.class));
|
||||
assertEquals(3, imported.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强导入确认会缓存成功匹配的逻辑引用,并将同一结果用于全部重复绑定。
|
||||
*/
|
||||
@Test
|
||||
public void enhancedConfirmCachesResolvedLogicalRefWithinRequest() {
|
||||
byte[] bundle = enhancedPackage();
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
AtomicReference<Skill> savedSkill = new AtomicReference<>();
|
||||
BigInteger skillId = BigInteger.valueOf(41);
|
||||
when(skillService.saveDraft(any(Skill.class))).thenAnswer(invocation -> {
|
||||
Skill skill = invocation.getArgument(0);
|
||||
skill.setId(skillId);
|
||||
savedSkill.set(skill);
|
||||
return skill;
|
||||
});
|
||||
when(skillService.getDetail(skillId)).thenAnswer(ignored -> savedSkill.get());
|
||||
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
|
||||
BigInteger targetId = BigInteger.valueOf(73);
|
||||
when(targetAccessService.resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF))
|
||||
.thenReturn(targetId);
|
||||
SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class);
|
||||
FileStorageService fileStorageService = storedBundle(bundle);
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage(STORED_PATH, SkillImportFormat.EASYFLOW));
|
||||
SkillImportServiceImpl service = service(skillService, targetAccessService,
|
||||
bindingService, fileStorageService, stageStore);
|
||||
SkillImportConfirmRequest request = new SkillImportConfirmRequest();
|
||||
request.setImportToken(IMPORT_TOKEN);
|
||||
request.setConflictStrategy(SkillImportConflictStrategy.REJECT.name());
|
||||
|
||||
List<Skill> imported;
|
||||
try (MockedStatic<SaTokenUtil> saToken = login()) {
|
||||
imported = service.confirm(request);
|
||||
}
|
||||
|
||||
verify(targetAccessService, times(1))
|
||||
.resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF);
|
||||
verify(bindingService, times(1)).replaceBindings(eq(skillId),
|
||||
org.mockito.ArgumentMatchers.argThat(bindings -> bindings.size() == 2
|
||||
&& bindings.stream().allMatch(binding -> targetId.equals(binding.getTargetId()))));
|
||||
assertEquals(1, imported.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建待测服务。
|
||||
*
|
||||
* @param skillService Skill 服务
|
||||
* @param targetAccessService 能力目标服务
|
||||
* @param bindingService 能力绑定服务
|
||||
* @param fileStorageService 文件存储服务
|
||||
* @param stageStore 导入会话仓库
|
||||
* @return 待测导入服务
|
||||
*/
|
||||
private SkillImportServiceImpl service(SkillService skillService,
|
||||
SkillCapabilityTargetAccessService targetAccessService,
|
||||
SkillCapabilityBindingService bindingService,
|
||||
FileStorageService fileStorageService,
|
||||
SkillImportStageStore stageStore) {
|
||||
EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(
|
||||
new ObjectMapper(), targetAccessService);
|
||||
return new SkillImportServiceImpl(skillService, bindingService, targetAccessService,
|
||||
mock(DBSkillContentStore.class), fileStorageService, stageStore,
|
||||
new EasyFlowBundleReader(manifestCodec), mock(ResourceAccessService.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准 Skill ZIP。
|
||||
*
|
||||
* @param names Skill 名称
|
||||
* @return ZIP 字节
|
||||
*/
|
||||
private byte[] standardPackage(List<String> names) {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
|
||||
for (String name : names) {
|
||||
writeEntry(zip, name + "/SKILL.md", skillContent(name).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建标准 Skill 测试包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含两个相同逻辑引用绑定的增强 Skill 包。
|
||||
*
|
||||
* @return `.efskill` 字节
|
||||
*/
|
||||
private byte[] enhancedPackage() {
|
||||
try {
|
||||
String name = "alpha-skill";
|
||||
String content = skillContent(name);
|
||||
Map<String, Object> firstBinding = binding("alpha-workflow-one", "alphaFlowOne");
|
||||
Map<String, Object> secondBinding = binding("alpha-workflow-two", "alphaFlowTwo");
|
||||
Map<String, Object> manifest = Map.of(
|
||||
"schemaVersion", "1.0",
|
||||
"skills", List.of(Map.of(
|
||||
"packageRoot", name,
|
||||
"packageHash", packageHash(content),
|
||||
"capabilities", List.of(firstBinding, secondBinding))));
|
||||
byte[] manifestBytes = new ObjectMapper().writeValueAsBytes(manifest);
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
|
||||
writeEntry(zip, EasyFlowSkillManifestCodec.MANIFEST_PATH, manifestBytes);
|
||||
writeEntry(zip, "skills/" + name + "/SKILL.md", content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建增强 Skill 测试包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建能力绑定 manifest 项。
|
||||
*
|
||||
* @param bindingKey 绑定键
|
||||
* @param runtimeName 运行时名称
|
||||
* @return manifest 项
|
||||
*/
|
||||
private Map<String, Object> binding(String bindingKey, String runtimeName) {
|
||||
return Map.of(
|
||||
"bindingKey", bindingKey,
|
||||
"capabilityType", SkillCapabilityType.WORKFLOW.name(),
|
||||
"runtimeName", runtimeName,
|
||||
"targetLogicalRef", SHARED_LOGICAL_REF);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准入口文档。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
* @return Markdown 内容
|
||||
*/
|
||||
private String skillContent(String name) {
|
||||
return "---\nname: " + name + "\ndescription: Query efficiency fixture for "
|
||||
+ name + "\n---\n# " + name + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算仅含入口文档的标准包 hash。
|
||||
*
|
||||
* @param content 入口文档
|
||||
* @return 包 hash
|
||||
*/
|
||||
private String packageHash(String content) {
|
||||
String canonical = "SKILL.md\n"
|
||||
+ SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n";
|
||||
return SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建每次都返回新输入流的文件存储 mock。
|
||||
*
|
||||
* @param bundle 增强包字节
|
||||
* @return 文件存储 mock
|
||||
*/
|
||||
private FileStorageService storedBundle(byte[] bundle) {
|
||||
FileStorageService storage = mock(FileStorageService.class);
|
||||
when(storage.save(any(MultipartFile.class), anyString())).thenReturn(STORED_PATH);
|
||||
try {
|
||||
when(storage.readStream(STORED_PATH)).thenAnswer(ignored -> new ByteArrayInputStream(bundle));
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new IllegalStateException("创建文件存储测试替身失败", exception);
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建上传文件 mock。
|
||||
*
|
||||
* @param bundle 增强包字节
|
||||
* @return 上传文件
|
||||
*/
|
||||
private MultipartFile upload(byte[] bundle) {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn((long) bundle.length);
|
||||
when(file.getOriginalFilename()).thenReturn("skills.efskill");
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建增强导入会话。
|
||||
*
|
||||
* @param path 文件路径
|
||||
* @return 导入会话
|
||||
*/
|
||||
private SkillImportStage stage(String path) {
|
||||
return stage(path, SkillImportFormat.EASYFLOW);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建指定格式的导入会话。
|
||||
*
|
||||
* @param path 文件路径
|
||||
* @param format 包格式
|
||||
* @return 导入会话
|
||||
*/
|
||||
private SkillImportStage stage(String path, SkillImportFormat format) {
|
||||
SkillImportStage stage = new SkillImportStage();
|
||||
stage.setImportToken(IMPORT_TOKEN);
|
||||
stage.setFilePath(path);
|
||||
stage.setFormat(format.name());
|
||||
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
|
||||
return stage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立当前租户登录态静态 mock。
|
||||
*
|
||||
* @return 静态 mock 句柄
|
||||
*/
|
||||
private MockedStatic<SaTokenUtil> login() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(ACCOUNT_ID);
|
||||
account.setTenantId(TENANT_ID);
|
||||
MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
return saToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 ZIP 条目。
|
||||
*
|
||||
* @param zip ZIP 输出流
|
||||
* @param path 条目路径
|
||||
* @param content 条目内容
|
||||
* @throws Exception 写入失败
|
||||
*/
|
||||
private void writeEntry(ZipOutputStream zip, String path, byte[] content) throws Exception {
|
||||
zip.putNextEntry(new ZipEntry(path));
|
||||
zip.write(content);
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ public class SkillImportStageStoreTest {
|
||||
BusinessException exception;
|
||||
try (MockedStatic<SaTokenUtil> login = login()) {
|
||||
exception = assertThrows(BusinessException.class,
|
||||
() -> fixture.store.create("skill-imports/demo.zip", "demo.zip", SkillImportFormat.STANDARD));
|
||||
() -> fixture.store.create("skill-imports/demo.zip", "demo.zip"));
|
||||
}
|
||||
|
||||
assertEquals(500, exception.getHttpStatus());
|
||||
@@ -212,7 +212,6 @@ public class SkillImportStageStoreTest {
|
||||
stage.setTenantId(TENANT_ID);
|
||||
stage.setAccountId(ACCOUNT_ID);
|
||||
stage.setFilePath("skill-imports/demo.zip");
|
||||
stage.setFormat(SkillImportFormat.STANDARD.name());
|
||||
stage.setStatus("PENDING");
|
||||
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
|
||||
return stage;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package tech.easyflow.skill.imports;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 标准 Skill ZIP 的导入导出契约测试。
|
||||
*/
|
||||
public class StandardSkillPackageContractTest {
|
||||
|
||||
/**
|
||||
* 单个标准目录包可预检,并保留任意自定义资源目录。
|
||||
*/
|
||||
@Test
|
||||
public void previewAcceptsOneStandardSkillWithCustomResources() {
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
SkillImportServiceImpl service = importService(skillService);
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> ignored = login()) {
|
||||
preview = service.previewStandardForTest(new ByteArrayInputStream(skillZip(
|
||||
"demo-skill", "custom/prompts/system.txt")));
|
||||
}
|
||||
|
||||
assertEquals(1, preview.getSkills().size());
|
||||
assertEquals("demo-skill", preview.getSkills().get(0).getName());
|
||||
assertTrue(preview.getSkills().get(0).getFiles().stream()
|
||||
.anyMatch(file -> "custom/prompts/system.txt".equals(file.getPath())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个上传 ZIP 只能承载一个 Skill,批量导入由多个 token 独立完成。
|
||||
*/
|
||||
@Test
|
||||
public void previewRejectsMultipleSkillsInOneZip() {
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
SkillImportServiceImpl service = importService(skillService);
|
||||
|
||||
SkillImportPreview preview;
|
||||
try (MockedStatic<SaTokenUtil> ignored = login()) {
|
||||
preview = service.previewStandardForTest(new ByteArrayInputStream(multiSkillZip()));
|
||||
}
|
||||
|
||||
assertTrue(preview.getSkills().isEmpty());
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.anyMatch(issue -> "STANDARD_PACKAGE_SKILL_COUNT".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有 efskill 扩展名必须在读取前被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void previewRejectsEfskillExtension() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(10L);
|
||||
when(file.getOriginalFilename()).thenReturn("legacy.efskill");
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> importService(mock(SkillService.class)).preview(file));
|
||||
|
||||
assertTrue(exception.getMessage().contains(".efskill 已停止支持"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出始终生成标准 ZIP,路径不暴露数据库 ID。
|
||||
*/
|
||||
@Test
|
||||
public void exportProducesPortableStandardZip() {
|
||||
BigInteger id = new BigInteger("987654321012345678");
|
||||
Skill skill = new Skill();
|
||||
skill.setId(id);
|
||||
skill.setName("portable-skill");
|
||||
skill.setDescription("Portable skill");
|
||||
skill.setSkillContent(skillContent("portable-skill"));
|
||||
skill.setResources(List.of());
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.getPackageDetail(id)).thenReturn(skill);
|
||||
SkillExportServiceImpl service = new SkillExportServiceImpl(
|
||||
skillService, mock(DBSkillContentStore.class));
|
||||
|
||||
byte[] bytes;
|
||||
try (SkillExportArtifact artifact = service.prepare(List.of(id))) {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
artifact.transferTo(output);
|
||||
bytes = output.toByteArray();
|
||||
}
|
||||
|
||||
Set<String> paths = zipPaths(bytes);
|
||||
assertTrue(paths.stream().anyMatch(path -> path.endsWith("portable-skill/SKILL.md")));
|
||||
assertTrue(paths.stream().noneMatch(path -> path.contains(id.toString())));
|
||||
}
|
||||
|
||||
private SkillImportServiceImpl importService(SkillService skillService) {
|
||||
return new SkillImportServiceImpl(skillService, mock(DBSkillContentStore.class),
|
||||
mock(FileStorageService.class), mock(SkillImportStageStore.class),
|
||||
mock(ResourceAccessService.class));
|
||||
}
|
||||
|
||||
private MockedStatic<SaTokenUtil> login() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
return saToken;
|
||||
}
|
||||
|
||||
private byte[] skillZip(String name, String resourcePath) {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
|
||||
write(zip, name + "/SKILL.md", skillContent(name));
|
||||
write(zip, name + "/" + resourcePath, "Use concise language.");
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建标准 Skill 测试包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] multiSkillZip() {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
|
||||
write(zip, "alpha-skill/SKILL.md", skillContent("alpha-skill"));
|
||||
write(zip, "beta-skill/SKILL.md", skillContent("beta-skill"));
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("创建多 Skill 测试包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void write(ZipOutputStream zip, String path, String content) throws Exception {
|
||||
zip.putNextEntry(new ZipEntry(path));
|
||||
zip.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
|
||||
private String skillContent(String name) {
|
||||
return "---\nname: " + name + "\ndescription: Standard package fixture\n---\n# Instructions\n";
|
||||
}
|
||||
|
||||
private Set<String> zipPaths(byte[] bytes) {
|
||||
try (InputStream input = new ByteArrayInputStream(bytes);
|
||||
ZipInputStream zip = new ZipInputStream(input, StandardCharsets.UTF_8)) {
|
||||
java.util.LinkedHashSet<String> paths = new java.util.LinkedHashSet<>();
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
paths.add(entry.getName());
|
||||
}
|
||||
}
|
||||
return paths.stream().collect(Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("读取标准 Skill 测试包失败", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tech.easyflow.skill.mapper;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* 标准 Skill 持久化收敛迁移的不可逆操作守卫测试。
|
||||
*/
|
||||
public class SkillStandardCleanupMigrationContractTest {
|
||||
|
||||
/**
|
||||
* 数据完整性与私有包守卫必须先于任何旧表删除。
|
||||
*
|
||||
* @throws Exception 读取迁移文件失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void guardsRunBeforeDestructiveCleanup() throws Exception {
|
||||
String sql = migrationSql();
|
||||
int firstDrop = sql.indexOf("DROP TABLE IF EXISTS `tb_skill_capability_binding`");
|
||||
|
||||
assertTrue(firstDrop > 0);
|
||||
assertTrue(sql.indexOf("tmp_skill_standard_cleanup_guard") < firstDrop);
|
||||
assertTrue(sql.indexOf("resource.`tenant_id` <> skill.`tenant_id`") < firstDrop);
|
||||
assertTrue(sql.indexOf("WHERE `format` <> ''STANDARD''") < firstDrop);
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移仅删除旧包表和派生字段,保留六张标准 Skill 表。
|
||||
*
|
||||
* @throws Exception 读取迁移文件失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void keepsOnlyStandardPackagePersistence() throws Exception {
|
||||
String sql = migrationSql();
|
||||
|
||||
for (String legacyTable : new String[]{
|
||||
"tb_skill_capability_binding",
|
||||
"tb_skill_reference",
|
||||
"tb_skill_script",
|
||||
"tb_skill_asset",
|
||||
"tb_skill_asset_content"
|
||||
}) {
|
||||
assertTrue(sql.contains("DROP TABLE IF EXISTS `" + legacyTable + "`"));
|
||||
}
|
||||
for (String standardTable : new String[]{
|
||||
"tb_skill_category",
|
||||
"tb_skill_resource",
|
||||
"tb_skill_content",
|
||||
"tb_skill_content_write_intent",
|
||||
"tb_skill_import_stage"
|
||||
}) {
|
||||
assertFalse(sql.contains("DROP TABLE IF EXISTS `" + standardTable + "`"));
|
||||
}
|
||||
assertTrue(sql.contains("'metadata_json', 'enabled', 'source_type', 'capability_hash'"));
|
||||
assertTrue(sql.contains("'kind', 'language', 'metadata_json', 'sort_no'"));
|
||||
assertTrue(sql.contains("ALTER TABLE `tb_skill_import_stage` DROP COLUMN `format`"));
|
||||
}
|
||||
|
||||
private String migrationSql() throws Exception {
|
||||
Path root = Path.of("").toAbsolutePath();
|
||||
for (int level = 0; level < 5 && root != null; level++, root = root.getParent()) {
|
||||
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
|
||||
+ "db/migration/mysql/V55__mysql_skill_standard_package_cleanup.sql");
|
||||
if (Files.isRegularFile(migration)) {
|
||||
return Files.readString(migration, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("未找到 V55 Skill 标准化迁移脚本");
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package tech.easyflow.skill.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link SkillMapper} 迁移摘要回填的并发与审计 SQL 契约测试。
|
||||
*/
|
||||
public class SkillSummaryBackfillSqlTest {
|
||||
|
||||
/**
|
||||
* 验证 package hash 只回填空值旧记录,并显式保持业务修改审计列。
|
||||
*
|
||||
* @throws Exception 反射读取 Mapper 方法失败
|
||||
*/
|
||||
@Test
|
||||
public void packageBackfillShouldBeConditionalAndAuditNeutral() throws Exception {
|
||||
Method method = SkillMapper.class.getMethod(
|
||||
"backfillPackageSummary", BigInteger.class, BigInteger.class, String.class,
|
||||
Integer.class, Integer.class, Integer.class, Integer.class);
|
||||
|
||||
String sql = sql(method);
|
||||
|
||||
assertTrue(sql.contains("tenant_id=#{tenantId}"));
|
||||
assertTrue(sql.contains("package_hash IS NULL"));
|
||||
assertTrue(sql.contains("modified=modified"));
|
||||
assertTrue(sql.contains("modified_by=modified_by"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 capability hash 首次回填只作用于空值旧记录,并保持业务修改审计列。
|
||||
*
|
||||
* @throws Exception 反射读取 Mapper 方法失败
|
||||
*/
|
||||
@Test
|
||||
public void capabilityBackfillShouldBeConditionalAndAuditNeutral() throws Exception {
|
||||
Method method = SkillMapper.class.getMethod(
|
||||
"backfillCapabilityHash", BigInteger.class, BigInteger.class, String.class);
|
||||
|
||||
String sql = sql(method);
|
||||
|
||||
assertTrue(sql.contains("tenant_id=#{tenantId}"));
|
||||
assertTrue(sql.contains("capability_hash IS NULL"));
|
||||
assertTrue(sql.contains("modified=modified"));
|
||||
assertTrue(sql.contains("modified_by=modified_by"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Mapper 方法声明的更新 SQL。
|
||||
*
|
||||
* @param method Mapper 方法
|
||||
* @return 合并后的 SQL
|
||||
*/
|
||||
private String sql(Method method) {
|
||||
return String.join(" ", method.getAnnotation(Update.class).value());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.approval.entity.ApprovalInstance;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
|
||||
import tech.easyflow.approval.enums.ApprovalActionType;
|
||||
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
|
||||
import tech.easyflow.approval.enums.ApprovalResourceType;
|
||||
import tech.easyflow.approval.service.ApprovalInstanceService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
@@ -68,6 +70,9 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
when(skillMapper.updateApprovalState(any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillMapper.publish(any(), any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillMapper.publishApproved(any(), any(), any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillMapper.markOfflineApproved(any(), any(), any())).thenReturn(1);
|
||||
when(skillMapper.restoreApprovalState(any(), any(), any(), any())).thenReturn(1);
|
||||
handler = new SkillApprovalSubjectHandler(
|
||||
approvalInstanceService,
|
||||
new ObjectMapper(),
|
||||
@@ -85,10 +90,10 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证发布候选在提交审批请求时立即持有自己的内容引用。
|
||||
* 验证只构建审批请求不会持有内容引用,避免预检产生副作用。
|
||||
*/
|
||||
@Test
|
||||
public void publishCandidateRetainsSnapshotContentsOnSubmit() {
|
||||
public void buildPublishRequestDoesNotRetainSnapshotContents() {
|
||||
Skill draft = skill(PublishStatus.DRAFT, Map.of());
|
||||
Map<String, Object> candidate = snapshot("sha256:candidate");
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
|
||||
@@ -100,7 +105,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(skillMapper).selectOneByQuery(queryCaptor.capture());
|
||||
assertTrue(queryCaptor.getValue().toSQL().toLowerCase().contains("for update"));
|
||||
verify(skillService).retainSnapshotContents(candidate);
|
||||
verify(skillService, never()).retainSnapshotContents(candidate);
|
||||
assertSame(candidate, request.getSnapshotJson().get("resourceSnapshot"));
|
||||
assertEquals(PublishStatus.DRAFT.getCode(), request.getSnapshotJson().get("previousPublishStatus"));
|
||||
}
|
||||
@@ -157,8 +162,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
|
||||
Skill draft = skill(PublishStatus.DRAFT, Map.of());
|
||||
Map<String, Object> governance = Map.of(
|
||||
"id", SKILL_ID,
|
||||
"name", "demo-skill",
|
||||
"capabilityCount", 1);
|
||||
"name", "demo-skill");
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
|
||||
when(skillService.buildGovernanceSnapshot(draft)).thenReturn(governance);
|
||||
|
||||
@@ -198,6 +202,160 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
|
||||
verify(skillService, never()).removeAggregate(SKILL_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批发布仅允许当前审批实例写入冻结快照。
|
||||
*/
|
||||
@Test
|
||||
public void approvedPublishUsesApprovalInstanceCompareAndSet() {
|
||||
BigInteger instanceId = BigInteger.valueOf(99);
|
||||
Map<String, Object> candidate = Map.of("snapshotHash", "candidate-hash");
|
||||
Skill pending = skill(PublishStatus.PUBLISH_PENDING, Map.of());
|
||||
pending.setCurrentApprovalInstanceId(instanceId);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending);
|
||||
when(approvalInstanceService.getById(instanceId))
|
||||
.thenReturn(approvalInstance(instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
|
||||
|
||||
handler.applyApprovedAction(
|
||||
ApprovalActionType.PUBLISH.getCode(),
|
||||
SKILL_ID,
|
||||
candidate,
|
||||
OPERATOR_ID,
|
||||
instanceId);
|
||||
|
||||
verify(skillService).assertSnapshotHash(candidate);
|
||||
verify(skillMapper).publishApproved(
|
||||
eq(SKILL_ID),
|
||||
eq(BigInteger.ONE),
|
||||
eq(instanceId),
|
||||
same(candidate),
|
||||
any(Date.class),
|
||||
eq(OPERATOR_ID),
|
||||
eq("candidate-hash"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 过期审批实例不得覆盖新的 Skill 状态。
|
||||
*/
|
||||
@Test
|
||||
public void staleApprovalCallbackIsRejected() {
|
||||
Skill pending = skill(PublishStatus.PUBLISH_PENDING, Map.of());
|
||||
pending.setCurrentApprovalInstanceId(BigInteger.valueOf(100));
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending);
|
||||
when(approvalInstanceService.getById(BigInteger.valueOf(99)))
|
||||
.thenReturn(approvalInstance(
|
||||
BigInteger.valueOf(99), ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class, () -> handler.applyApprovedAction(
|
||||
ApprovalActionType.PUBLISH.getCode(),
|
||||
SKILL_ID,
|
||||
Map.of("snapshotHash", "candidate-hash"),
|
||||
OPERATOR_ID,
|
||||
BigInteger.valueOf(99)));
|
||||
|
||||
assertEquals(409, exception.getHttpStatus());
|
||||
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一发布申请的重复通过回调应幂等成功且不重复释放内容。
|
||||
*/
|
||||
@Test
|
||||
public void repeatedPublishApprovalIsNoOp() {
|
||||
BigInteger instanceId = BigInteger.valueOf(99);
|
||||
Map<String, Object> candidate = Map.of("snapshotHash", "candidate-hash");
|
||||
Skill published = skill(PublishStatus.PUBLISHED, candidate);
|
||||
published.setSnapshotHash("candidate-hash");
|
||||
published.setCurrentApprovalInstanceId(instanceId);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
|
||||
when(approvalInstanceService.getById(instanceId))
|
||||
.thenReturn(approvalInstance(instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
|
||||
|
||||
handler.applyApprovedAction(
|
||||
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID, instanceId);
|
||||
|
||||
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
|
||||
verify(skillService, never()).releaseSnapshotContents(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一下线申请的重复通过回调应幂等成功。
|
||||
*/
|
||||
@Test
|
||||
public void repeatedOfflineApprovalIsNoOp() {
|
||||
BigInteger instanceId = BigInteger.valueOf(99);
|
||||
Skill offline = skill(PublishStatus.OFFLINE, snapshot("sha256:published"));
|
||||
offline.setCurrentApprovalInstanceId(instanceId);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(offline);
|
||||
when(approvalInstanceService.getById(instanceId))
|
||||
.thenReturn(approvalInstance(instanceId, ApprovalActionType.OFFLINE, ApprovalInstanceStatus.APPROVED));
|
||||
|
||||
handler.applyApprovedAction(
|
||||
ApprovalActionType.OFFLINE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId);
|
||||
|
||||
verify(skillMapper, never()).markOfflineApproved(any(), any(), any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一删除申请在资源已经删除后重复回调应幂等成功。
|
||||
*/
|
||||
@Test
|
||||
public void repeatedDeleteApprovalForMissingSkillIsNoOp() {
|
||||
BigInteger instanceId = BigInteger.valueOf(99);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
|
||||
when(approvalInstanceService.getById(instanceId))
|
||||
.thenReturn(approvalInstance(instanceId, ApprovalActionType.DELETE, ApprovalInstanceStatus.APPROVED));
|
||||
when(approvalInstanceService.isLatestResourceInstance(
|
||||
instanceId, ApprovalResourceType.SKILL.getCode(), SKILL_ID)).thenReturn(true);
|
||||
|
||||
handler.applyApprovedAction(
|
||||
ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId);
|
||||
|
||||
verify(skillService, never()).removeLifecycleAggregate(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 较旧删除申请不能把资源缺失误判为自身已完成。
|
||||
*/
|
||||
@Test
|
||||
public void staleDeleteApprovalForMissingSkillIsRejected() {
|
||||
BigInteger instanceId = BigInteger.valueOf(99);
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
|
||||
when(approvalInstanceService.getById(instanceId))
|
||||
.thenReturn(approvalInstance(instanceId, ApprovalActionType.DELETE, ApprovalInstanceStatus.APPROVED));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class, () -> handler.applyApprovedAction(
|
||||
ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId));
|
||||
|
||||
assertEquals(409, exception.getHttpStatus());
|
||||
verify(skillService, never()).removeLifecycleAggregate(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回回调首次释放候选引用,之后同一实例重放保持无副作用。
|
||||
*/
|
||||
@Test
|
||||
public void repeatedRejectRestoreIsNoOpAfterFirstApplication() {
|
||||
BigInteger instanceId = BigInteger.valueOf(99);
|
||||
Map<String, Object> candidate = snapshot("sha256:candidate");
|
||||
Skill pending = skill(PublishStatus.PUBLISHED, snapshot("sha256:published"));
|
||||
pending.setCurrentApprovalInstanceId(instanceId);
|
||||
Skill restored = skill(PublishStatus.PUBLISHED, snapshot("sha256:published"));
|
||||
ApprovalInstance instance = approvalInstance(
|
||||
instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.REJECTED);
|
||||
instance.setSnapshotJson(Map.of("resourceSnapshot", candidate));
|
||||
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending, restored);
|
||||
when(approvalInstanceService.getById(instanceId)).thenReturn(instance);
|
||||
when(approvalInstanceService.isLatestResourceInstance(
|
||||
instanceId, ApprovalResourceType.SKILL.getCode(), SKILL_ID)).thenReturn(true);
|
||||
|
||||
handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED, instanceId);
|
||||
handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED, instanceId);
|
||||
|
||||
verify(skillService).releaseSnapshotContents(candidate);
|
||||
verify(skillMapper).restoreApprovalState(
|
||||
SKILL_ID, BigInteger.ONE, instanceId, PublishStatus.PUBLISHED.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建指定生命周期状态的 Skill。
|
||||
*
|
||||
@@ -227,4 +385,24 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
|
||||
"path", "assets/file.bin",
|
||||
"contentRef", contentRef)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建与当前 Skill 回调匹配的审批实例。
|
||||
*
|
||||
* @param instanceId 实例 ID
|
||||
* @param action 动作
|
||||
* @param status 实例状态
|
||||
* @return 审批实例
|
||||
*/
|
||||
private ApprovalInstance approvalInstance(BigInteger instanceId,
|
||||
ApprovalActionType action,
|
||||
ApprovalInstanceStatus status) {
|
||||
ApprovalInstance instance = new ApprovalInstance();
|
||||
instance.setId(instanceId);
|
||||
instance.setResourceType(ApprovalResourceType.SKILL.getCode());
|
||||
instance.setResourceId(SKILL_ID);
|
||||
instance.setActionType(action.getCode());
|
||||
instance.setStatus(status.getCode());
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package tech.easyflow.skill.publish;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.ai.publish.AiResourceLifecycleService;
|
||||
import tech.easyflow.approval.enums.ApprovalActionType;
|
||||
import tech.easyflow.approval.enums.ApprovalResourceType;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* {@link SkillPublishAppService} 发布说明契约测试。
|
||||
*/
|
||||
public class SkillPublishAppServiceTest {
|
||||
|
||||
/**
|
||||
* 发布说明必须包含可见字符。
|
||||
*/
|
||||
@Test
|
||||
public void rejectsBlankPublishReason() {
|
||||
SkillPublishAppService service = new SkillPublishAppService(mock(AiResourceLifecycleService.class));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.submitPublishApproval(BigInteger.ONE, " \n "));
|
||||
|
||||
assertEquals("发布说明不能为空", exception.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布说明最长为 500 个字符。
|
||||
*/
|
||||
@Test
|
||||
public void rejectsPublishReasonLongerThanLimit() {
|
||||
SkillPublishAppService service = new SkillPublishAppService(mock(AiResourceLifecycleService.class));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.submitPublishApproval(BigInteger.ONE, "a".repeat(501)));
|
||||
|
||||
assertEquals("发布说明不能超过 500 个字符", exception.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交发布时会规范化说明并透传登录身份。
|
||||
*/
|
||||
@Test
|
||||
public void trimsAndForwardsPublishReason() {
|
||||
AiResourceLifecycleService lifecycleService = mock(AiResourceLifecycleService.class);
|
||||
SkillPublishAppService service = new SkillPublishAppService(lifecycleService);
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
service.submitPublishApproval(BigInteger.valueOf(101), " 首次发布 ");
|
||||
}
|
||||
|
||||
verify(lifecycleService).submitAction(
|
||||
ApprovalResourceType.SKILL.getCode(),
|
||||
BigInteger.valueOf(101),
|
||||
ApprovalActionType.PUBLISH.getCode(),
|
||||
BigInteger.valueOf(7),
|
||||
"首次发布");
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package tech.easyflow.skill.repository;
|
||||
|
||||
import com.easyagents.skill.factory.SkillFactory;
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillResource;
|
||||
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link DBSkillRepository} 二进制内容引用所有权转移测试。
|
||||
*/
|
||||
public class DBSkillRepositoryContentOwnershipTest {
|
||||
|
||||
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
|
||||
private static final String REF_A = "sha256:" + "a".repeat(64);
|
||||
private static final String REF_B = "sha256:" + "b".repeat(64);
|
||||
|
||||
private SkillService skillService;
|
||||
private DBSkillContentStore contentStore;
|
||||
private DBSkillRepository repository;
|
||||
private MockedStatic<SaTokenUtil> saToken;
|
||||
|
||||
/**
|
||||
* 初始化仓储及登录态。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
skillService = mock(SkillService.class);
|
||||
contentStore = mock(DBSkillContentStore.class);
|
||||
repository = new DBSkillRepository(
|
||||
skillService, contentStore, mock(SkillVisibilityQueryHelper.class));
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken = mockStatic(SaTokenUtil.class);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放静态登录态 Mock。
|
||||
*/
|
||||
@After
|
||||
public void tearDown() {
|
||||
saToken.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新增 Skill 直接接管调用方已取得的所有二进制引用。
|
||||
*/
|
||||
@Test
|
||||
public void newSkillTransfersIncomingReferencesWithoutRetain() {
|
||||
com.easyagents.skill.model.Skill incoming = incoming(null, REF_A, REF_A);
|
||||
|
||||
repository.save(incoming);
|
||||
|
||||
verify(skillService).saveDraft(any(Skill.class));
|
||||
verify(contentStore, never()).retain(anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证更新时仅出现在新聚合中的引用直接转移,不额外 retain。
|
||||
*/
|
||||
@Test
|
||||
public void updateTransfersNewOnlyReferenceWithoutRetain() {
|
||||
prepareExisting(List.of(resource("assets/old.bin", REF_A)));
|
||||
|
||||
repository.save(incoming(SKILL_ID.toString(), REF_B));
|
||||
|
||||
verify(skillService).updateDraft(any(Skill.class));
|
||||
verify(contentStore, never()).retain(anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证旧、新聚合重叠的引用会 retain 一次,以抵消旧聚合替换时的 release。
|
||||
*/
|
||||
@Test
|
||||
public void updateRetainsOverlappingReference() {
|
||||
prepareExisting(List.of(resource("assets/old.bin", REF_A)));
|
||||
|
||||
repository.save(incoming(SKILL_ID.toString(), REF_A));
|
||||
|
||||
verify(contentStore).retain(REF_A);
|
||||
verify(contentStore, never()).retain(REF_B);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证仅存在于旧聚合的引用不 retain,由资源替换流程负责释放。
|
||||
*/
|
||||
@Test
|
||||
public void updateDoesNotRetainRemovedReference() {
|
||||
prepareExisting(List.of(resource("assets/old.bin", REF_A)));
|
||||
|
||||
repository.save(incoming(SKILL_ID.toString()));
|
||||
|
||||
verify(skillService).updateDraft(any(Skill.class));
|
||||
verify(contentStore, never()).retain(anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证共享 contentRef 按资源出现次数计算交集,不因 hash 去重而少持有或多持有。
|
||||
*/
|
||||
@Test
|
||||
public void updateRetainsSharedReferenceByMultisetIntersection() {
|
||||
prepareExisting(List.of(
|
||||
resource("assets/old-a.bin", REF_A),
|
||||
resource("assets/old-b.bin", REF_A),
|
||||
resource("assets/old-c.bin", REF_B)));
|
||||
|
||||
repository.save(incoming(SKILL_ID.toString(), REF_A, REF_A, REF_A, REF_B, REF_B));
|
||||
|
||||
verify(contentStore, times(2)).retain(REF_A);
|
||||
verify(contentStore).retain(REF_B);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺失或不可读 Skill 按仓储契约返回 empty,不把详情服务的 404 泄漏给调用方。
|
||||
*/
|
||||
@Test
|
||||
public void getReturnsEmptyWhenSkillIsNotReadable() {
|
||||
when(skillService.getOne(any(QueryWrapper.class))).thenReturn(null);
|
||||
|
||||
assertTrue(repository.get(SKILL_ID.toString()).isEmpty());
|
||||
|
||||
verify(skillService, never()).getDetail(SKILL_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓储删除必须走带生命周期状态约束的普通聚合删除入口。
|
||||
*/
|
||||
@Test
|
||||
public void deleteUsesGuardedAggregateRemoval() {
|
||||
repository.delete(SKILL_ID.toString());
|
||||
|
||||
verify(skillService).removeAggregate(SKILL_ID);
|
||||
verify(skillService, never()).removeLifecycleAggregate(SKILL_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备一个可更新的已存在 Skill。
|
||||
*
|
||||
* @param resources 已持久化资源
|
||||
*/
|
||||
private void prepareExisting(List<SkillResource> resources) {
|
||||
Skill header = new Skill();
|
||||
header.setId(SKILL_ID);
|
||||
header.setTenantId(BigInteger.ONE);
|
||||
Skill detail = new Skill();
|
||||
detail.setId(SKILL_ID);
|
||||
detail.setTenantId(BigInteger.ONE);
|
||||
detail.setResources(resources);
|
||||
when(skillService.getOne(any(QueryWrapper.class))).thenReturn(header);
|
||||
when(skillService.getDetail(SKILL_ID)).thenReturn(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 M18 Skill 聚合。
|
||||
*
|
||||
* @param id 仓储 ID,可为空
|
||||
* @param refs 二进制引用多重集
|
||||
* @return M18 Skill
|
||||
*/
|
||||
private com.easyagents.skill.model.Skill incoming(String id, String... refs) {
|
||||
List<com.easyagents.skill.model.SkillResource> resources = new ArrayList<>();
|
||||
for (int index = 0; index < refs.length; index++) {
|
||||
com.easyagents.skill.model.SkillResource resource = new com.easyagents.skill.model.SkillResource();
|
||||
resource.setPath("assets/incoming-" + index + ".bin");
|
||||
resource.setKind(SkillResourceKind.ASSET);
|
||||
resource.setMediaType("application/octet-stream");
|
||||
resource.setContentRef(refs[index]);
|
||||
resource.setContentHash(refs[index].substring("sha256:".length()));
|
||||
resource.setSize(1);
|
||||
resources.add(resource);
|
||||
}
|
||||
return SkillFactory.createWithResources(id,
|
||||
"---\nname: demo-skill\ndescription: Demo skill\n---\n# Demo\n", resources);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建已持久化二进制资源。
|
||||
*
|
||||
* @param path 包内路径
|
||||
* @param contentRef 内容引用
|
||||
* @return 资源实体
|
||||
*/
|
||||
private SkillResource resource(String path, String contentRef) {
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setPath(path);
|
||||
resource.setNormalizedPath(path);
|
||||
resource.setIsText(false);
|
||||
resource.setContentRef(contentRef);
|
||||
resource.setContentHash(contentRef.substring("sha256:".length()));
|
||||
resource.setSize(1L);
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package tech.easyflow.skill.security;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link SkillCredentialValueGuard} 结构化凭据检测测试。
|
||||
*/
|
||||
public class SkillCredentialValueGuardTest {
|
||||
|
||||
/**
|
||||
* 验证认证头、赋值、URI userinfo、私钥和常见 Token 前缀被识别。
|
||||
*/
|
||||
@Test
|
||||
public void detectsHighConfidenceCredentialStructures() {
|
||||
List<String> credentials = List.of(
|
||||
"Authorization: Bearer actual-secret-value",
|
||||
"{\"token\":\"actual-secret-value\"}",
|
||||
"clientSecret=actual-secret-value",
|
||||
"https://operator:actual-password@example.test/service",
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"key=sk-proj-abcdefghijklmnopqrstuvwxyz123456",
|
||||
"password=actual-secret-value",
|
||||
"token%253Dactual-secret-value",
|
||||
"token=${TOKEN}actual-secret",
|
||||
"token=${TOKEN} actual-secret",
|
||||
"Authorization: Bearer ${TOKEN} actual-secret",
|
||||
"https://example.test/service?token=actual-secret-value",
|
||||
"https://example.test/service?mode=read&client_secret=actual-secret-value",
|
||||
"https://example.test/callback#access_token=actual-secret-value",
|
||||
"token%2525253Dactual-secret-value",
|
||||
"token%3Dactual-secret-value%ZZ",
|
||||
"token%252525252525253Dactual-secret-value",
|
||||
"to\u200Bken=actual-secret-value",
|
||||
"to\u0000ken=actual-secret-value",
|
||||
"spring.datasource.password=actual-secret-value",
|
||||
"headers[Authorization]=Bearer actual-secret-value",
|
||||
"OPENAI_API_KEY=actual-secret-value",
|
||||
"AWS_SECRET_ACCESS_KEY=actual-secret-value",
|
||||
"Cookie=session-value-actual-secret",
|
||||
"X-Auth-Token=actual-secret-value",
|
||||
"session=actual-secret-value",
|
||||
"Bearer actual-secret-value",
|
||||
"Basic dXNlcjphY3R1YWwtc2VjcmV0",
|
||||
"glpat-abcdefghijklmnopqrstuvwxyz123456",
|
||||
"hf_abcdefghijklmnopqrstuvwxyz123456",
|
||||
"sk_live_abcdefghijklmnopqrstuvwxyz123456",
|
||||
"AIzaSyabcdefghijklmnopqrstuvwxyz1234567890",
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature-value-123456");
|
||||
|
||||
for (String credential : credentials) {
|
||||
assertTrue(credential, SkillCredentialValueGuard.containsCredential(credential));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证明确占位符和普通展示文案不会被当作真实凭据。
|
||||
*/
|
||||
@Test
|
||||
public void allowsPlaceholdersAndOrdinaryDisplayCopy() {
|
||||
List<String> safeValues = List.of(
|
||||
"是否继续执行当前工作流?",
|
||||
"请确认操作,运行时会从安全配置读取认证信息",
|
||||
"token=${TOKEN}",
|
||||
"Authorization: Bearer {{ token }}",
|
||||
"apiKey=<API_KEY>",
|
||||
"password=[REDACTED]",
|
||||
"secret=***",
|
||||
"https://operator:${PASSWORD}@example.test/service",
|
||||
"token=none",
|
||||
"client_secret=not-set",
|
||||
"Bearer ${TOKEN}",
|
||||
"Basic {{ basic_auth }}",
|
||||
"Basic information",
|
||||
"Bearer authentication",
|
||||
"Authorization: Bearer",
|
||||
"请将 Authorization: Bearer 写入请求头",
|
||||
"sk-project-management-service",
|
||||
"https://operator@example.test/service");
|
||||
|
||||
for (String safeValue : safeValues) {
|
||||
assertFalse(safeValue, SkillCredentialValueGuard.containsCredential(safeValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package tech.easyflow.skill.security;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
/**
|
||||
* {@link SkillSensitiveConfigSanitizer} 字段与值类型白名单测试。
|
||||
*/
|
||||
public class SkillSensitiveConfigSanitizerTest {
|
||||
|
||||
/**
|
||||
* 验证执行选项仅保留允许的 JSON 标量,并移除常见凭据和复杂值。
|
||||
*/
|
||||
@Test
|
||||
public void optionsKeepAllowedScalarsAndDropCredentialsOrComplexValues() {
|
||||
Map<String, Object> source = new LinkedHashMap<>();
|
||||
source.put("timeoutMs", 5_000);
|
||||
source.put("retryCount", 3);
|
||||
source.put("async", true);
|
||||
source.put("readOnly", List.of("complex"));
|
||||
source.put("token", "secret-token");
|
||||
source.put("apiKey", "secret-key");
|
||||
source.put("headers", Map.of("Authorization", "Bearer secret"));
|
||||
|
||||
Map<String, Object> sanitized = SkillSensitiveConfigSanitizer.sanitizeOptions(source);
|
||||
|
||||
assertEquals(Map.of("timeoutMs", 5_000, "retryCount", 3, "async", true), sanitized);
|
||||
assertFalse(sanitized.containsKey("token"));
|
||||
assertFalse(sanitized.containsKey("apiKey"));
|
||||
assertFalse(sanitized.containsKey("headers"));
|
||||
assertEquals("secret-token", source.get("token"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 HITL 只保留展示字段,认证信息和复杂值不会穿透。
|
||||
*/
|
||||
@Test
|
||||
public void hitlKeepsDisplayScalarsOnly() {
|
||||
Map<String, Object> source = new LinkedHashMap<>();
|
||||
source.put("prompt", "是否继续");
|
||||
source.put("title", "人工确认");
|
||||
source.put("confirmLabel", "继续");
|
||||
source.put("cancelLabel", "取消");
|
||||
source.put("description", Map.of("token", "nested-secret"));
|
||||
source.put("authorization", "Bearer secret");
|
||||
|
||||
Map<String, Object> sanitized = SkillSensitiveConfigSanitizer.sanitizeHitl(source);
|
||||
|
||||
assertEquals(Map.of(
|
||||
"prompt", "是否继续",
|
||||
"title", "人工确认",
|
||||
"confirmLabel", "继续",
|
||||
"cancelLabel", "取消"), sanitized);
|
||||
assertFalse(sanitized.containsKey("authorization"));
|
||||
assertFalse(sanitized.containsKey("description"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空输入返回可安全修改的独立映射。
|
||||
*/
|
||||
@Test
|
||||
public void nullInputReturnsMutableEmptyMap() {
|
||||
Map<String, Object> sanitized = SkillSensitiveConfigSanitizer.sanitizeOptions(null);
|
||||
|
||||
sanitized.put("timeoutMs", 1_000);
|
||||
|
||||
assertEquals(1_000, sanitized.get("timeoutMs"));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.math.BigInteger;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
@@ -50,10 +51,10 @@ public class SkillVisibilityQueryHelperTenantTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分类 ALL 范围的列表查询显式包含未分类 Skill。
|
||||
* 验证分类 ALL 范围仍按创建人和可见范围过滤,不放行未分类私有 Skill。
|
||||
*/
|
||||
@Test
|
||||
public void allCategoryScopeQueryShouldIncludeUnclassifiedSkills() {
|
||||
public void allCategoryScopeQueryShouldNotBypassPrivateScopeForUnclassifiedSkills() {
|
||||
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
SysDeptService sysDeptService = mock(SysDeptService.class);
|
||||
SkillVisibilityQueryHelper helper = new SkillVisibilityQueryHelper(
|
||||
@@ -70,7 +71,9 @@ public class SkillVisibilityQueryHelperTenantTest {
|
||||
}
|
||||
|
||||
String sql = query.toSQL().toLowerCase(Locale.ROOT);
|
||||
assertTrue("ALL 分类查询缺少未分类分支: " + sql,
|
||||
assertTrue("ALL 分类查询缺少创建人边界: " + sql, sql.contains("created_by"));
|
||||
assertTrue("ALL 分类查询缺少可见范围边界: " + sql, sql.contains("visibility_scope"));
|
||||
assertFalse("ALL 分类查询不应包含未分类越权分支: " + sql,
|
||||
sql.contains("category_id") && sql.contains("is null"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package tech.easyflow.skill.service.impl;
|
||||
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.entity.SkillResource;
|
||||
import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
import tech.easyflow.skill.service.SkillResourceService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* V27 旧 Skill 在只读详情路径中的摘要回填测试。
|
||||
*/
|
||||
public class SkillLegacySummaryBackfillTest {
|
||||
|
||||
/**
|
||||
* 验证 READ 权限即可获得完整 hash,并通过专用 Mapper 条件回填且不改内存审计值。
|
||||
*/
|
||||
@Test
|
||||
public void readDetailShouldBackfillMissingHashesWithoutManagePermission() {
|
||||
BigInteger skillId = BigInteger.valueOf(101);
|
||||
BigInteger tenantId = BigInteger.valueOf(42);
|
||||
BigInteger accountId = BigInteger.valueOf(7);
|
||||
Date originalModified = new Date(1_700_000_000_000L);
|
||||
String content = "---\nname: demo-skill\ndescription: Demo\n---\n# Demo\n";
|
||||
String referenceText = "# Reference\n";
|
||||
String referenceHash = SkillHashes.sha256Hex(referenceText.getBytes(StandardCharsets.UTF_8));
|
||||
String expectedCapabilityHash = SkillHashes.sha256Hex("[]".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
Skill skill = new Skill();
|
||||
skill.setId(skillId);
|
||||
skill.setTenantId(tenantId);
|
||||
skill.setCreatedBy(accountId);
|
||||
skill.setSkillContent(content);
|
||||
skill.setModified(originalModified);
|
||||
skill.setModifiedBy(accountId);
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setPath("references/guide.md");
|
||||
resource.setNormalizedPath("references/guide.md");
|
||||
resource.setKind("REFERENCE");
|
||||
resource.setIsText(true);
|
||||
resource.setTextContent(referenceText);
|
||||
resource.setContentHash(referenceHash);
|
||||
resource.setSize((long) referenceText.getBytes(StandardCharsets.UTF_8).length);
|
||||
|
||||
SkillMapper mapper = mock(SkillMapper.class);
|
||||
SkillResourceService resourceService = mock(SkillResourceService.class);
|
||||
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
SkillServiceImpl service = spy(new SkillServiceImpl(
|
||||
mock(SkillCategoryService.class), resourceService, capabilityService,
|
||||
mock(DBSkillContentStore.class), accessService,
|
||||
mock(CategoryPermissionService.class), new ObjectMapper()));
|
||||
doReturn(mapper).when(service).getMapper();
|
||||
doReturn(skill).when(service).getOne(any(QueryWrapper.class));
|
||||
when(resourceService.list(any(QueryWrapper.class))).thenReturn(List.of(resource));
|
||||
when(capabilityService.listBindings(skillId)).thenReturn(List.of());
|
||||
when(capabilityService.calculateStoredHash(skillId)).thenReturn(expectedCapabilityHash);
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(accountId);
|
||||
account.setTenantId(tenantId);
|
||||
|
||||
Skill detail;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
detail = service.getDetail(skillId);
|
||||
}
|
||||
|
||||
String canonical = "SKILL.md\n"
|
||||
+ SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n"
|
||||
+ "references/guide.md\n" + referenceHash + "\n";
|
||||
String expectedPackageHash = SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals(expectedPackageHash, detail.getPackageHash());
|
||||
assertEquals(expectedCapabilityHash, detail.getCapabilityHash());
|
||||
assertSame(originalModified, detail.getModified());
|
||||
assertEquals(accountId, detail.getModifiedBy());
|
||||
verify(mapper).backfillPackageSummary(
|
||||
skillId, tenantId, expectedPackageHash, 1, 1, 0, 0);
|
||||
verify(mapper).backfillCapabilityHash(skillId, tenantId, expectedCapabilityHash);
|
||||
verify(accessService).assertAccess(
|
||||
CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill");
|
||||
verify(accessService, never()).assertAccess(
|
||||
eq(CategoryResourceType.SKILL), any(Skill.class), eq(ResourceAction.MANAGE), anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证目标权限不足导致绑定脱敏时,不会用脱敏数据回填错误的能力 hash。
|
||||
*/
|
||||
@Test
|
||||
public void redactedCapabilityShouldNotBackfillMissingHash() {
|
||||
BigInteger skillId = BigInteger.valueOf(102);
|
||||
BigInteger tenantId = BigInteger.valueOf(42);
|
||||
BigInteger accountId = BigInteger.valueOf(7);
|
||||
Skill skill = new Skill();
|
||||
skill.setId(skillId);
|
||||
skill.setTenantId(tenantId);
|
||||
skill.setCreatedBy(accountId);
|
||||
skill.setSkillContent("---\nname: private-skill\ndescription: Private\n---\n# Private\n");
|
||||
skill.setPackageHash("existing-package-hash");
|
||||
|
||||
SkillCapabilityBinding redacted = new SkillCapabilityBinding();
|
||||
redacted.setCapabilityType("MCP");
|
||||
redacted.setRuntimeName("private_mcp");
|
||||
redacted.setTargetStatus("NO_PERMISSION");
|
||||
|
||||
SkillMapper mapper = mock(SkillMapper.class);
|
||||
SkillResourceService resourceService = mock(SkillResourceService.class);
|
||||
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
SkillServiceImpl service = spy(new SkillServiceImpl(
|
||||
mock(SkillCategoryService.class), resourceService, capabilityService,
|
||||
mock(DBSkillContentStore.class), accessService,
|
||||
mock(CategoryPermissionService.class), new ObjectMapper()));
|
||||
doReturn(mapper).when(service).getMapper();
|
||||
doReturn(skill).when(service).getOne(any(QueryWrapper.class));
|
||||
when(resourceService.listDescriptors(skillId, tenantId)).thenReturn(List.of());
|
||||
when(capabilityService.listBindings(skillId)).thenReturn(List.of(redacted));
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(accountId);
|
||||
account.setTenantId(tenantId);
|
||||
|
||||
Skill detail;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
detail = service.getManagementDetail(skillId);
|
||||
}
|
||||
|
||||
assertNull(detail.getCapabilityHash());
|
||||
verify(capabilityService, never()).calculateStoredHash(any());
|
||||
verify(mapper, never()).backfillCapabilityHash(any(), any(), anyString());
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ public class SkillResourceServiceImplProjectionTest {
|
||||
|
||||
assertTrue(sql.contains("normalized_path"));
|
||||
assertTrue(sql.contains("content_hash"));
|
||||
assertTrue(sql.contains("metadata_json"));
|
||||
assertTrue(sql.contains("media_type"));
|
||||
assertTrue(sql.contains("is_text"));
|
||||
assertTrue(sql.contains("size"));
|
||||
assertFalse(sql.contains("metadata_json"));
|
||||
assertFalse(sql.contains("text_content"));
|
||||
assertFalse(sql.contains("content_ref"));
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package tech.easyflow.skill.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
import tech.easyflow.skill.service.SkillResourceService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* {@link SkillServiceImpl} 发布候选和已发布快照内容引用计数测试。
|
||||
*/
|
||||
public class SkillServiceImplContentReferenceTest {
|
||||
|
||||
private DBSkillContentStore contentStore;
|
||||
private SkillServiceImpl service;
|
||||
|
||||
/**
|
||||
* 初始化只关注内容引用的 Skill 服务。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
contentStore = mock(DBSkillContentStore.class);
|
||||
service = new SkillServiceImpl(
|
||||
mock(SkillCategoryService.class),
|
||||
mock(SkillResourceService.class),
|
||||
mock(SkillCapabilityBindingService.class),
|
||||
contentStore,
|
||||
mock(ResourceAccessService.class),
|
||||
mock(CategoryPermissionService.class),
|
||||
new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证发布候选按资源出现次数 retain;同一 hash 被多个资源引用时必须持有多份引用。
|
||||
*/
|
||||
@Test
|
||||
public void retainSnapshotContentsPreservesDuplicateResourceReferences() {
|
||||
Map<String, Object> snapshot = snapshot("sha256:a", "sha256:a", "sha256:b", null, "");
|
||||
|
||||
service.retainSnapshotContents(snapshot);
|
||||
|
||||
verify(contentStore, times(2)).retain("sha256:a");
|
||||
verify(contentStore).retain("sha256:b");
|
||||
verify(contentStore, never()).retain("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证候选驳回、旧快照替换或聚合删除时按相同出现次数 release。
|
||||
*/
|
||||
@Test
|
||||
public void releaseSnapshotContentsBalancesEveryHeldReference() {
|
||||
Map<String, Object> snapshot = snapshot("sha256:a", "sha256:a", "sha256:b", null, "");
|
||||
|
||||
service.releaseSnapshotContents(snapshot);
|
||||
|
||||
verify(contentStore, times(2)).release("sha256:a");
|
||||
verify(contentStore).release("sha256:b");
|
||||
verify(contentStore, never()).release("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 V24 assets 快照仍按资源出现次数释放内容引用。
|
||||
*/
|
||||
@Test
|
||||
public void legacyAssetSnapshotBalancesEveryHeldReference() {
|
||||
Map<String, Object> snapshot = Map.of(
|
||||
"assets", List.of(
|
||||
Map.of("contentRef", "sha256:legacy"),
|
||||
Map.of("contentRef", "sha256:legacy"),
|
||||
Map.of("contentRef", "sha256:other")));
|
||||
|
||||
service.retainSnapshotContents(snapshot);
|
||||
service.releaseSnapshotContents(snapshot);
|
||||
|
||||
verify(contentStore, times(2)).retain("sha256:legacy");
|
||||
verify(contentStore).retain("sha256:other");
|
||||
verify(contentStore, times(2)).release("sha256:legacy");
|
||||
verify(contentStore).release("sha256:other");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空快照和非列表 resources 不触发引用变化。
|
||||
*/
|
||||
@Test
|
||||
public void malformedOrEmptySnapshotDoesNotChangeReferences() {
|
||||
service.retainSnapshotContents(null);
|
||||
service.releaseSnapshotContents(Map.of("resources", "invalid"));
|
||||
|
||||
verify(contentStore, never()).retain(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(contentStore, never()).release(org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证删除治理快照仅包含审计所需字段,不携带提示词、资源、能力配置或自定义元数据。
|
||||
*/
|
||||
@Test
|
||||
public void governanceSnapshotExcludesExecutableAndSensitivePayloads() {
|
||||
Skill skill = new Skill();
|
||||
skill.setId(java.math.BigInteger.valueOf(101));
|
||||
skill.setTenantId(java.math.BigInteger.ONE);
|
||||
skill.setName("demo-skill");
|
||||
skill.setDisplayName("Demo Skill");
|
||||
skill.setSkillContent("secret prompt");
|
||||
skill.setMetadataJson(Map.of("apiKey", "secret"));
|
||||
skill.setPublishStatus("DRAFT");
|
||||
skill.setResourceCount(2);
|
||||
skill.setCapabilityCount(1);
|
||||
|
||||
Map<String, Object> snapshot = service.buildGovernanceSnapshot(skill);
|
||||
|
||||
assertEquals(skill.getId(), snapshot.get("id"));
|
||||
assertEquals("demo-skill", snapshot.get("name"));
|
||||
assertEquals(2, snapshot.get("resourceCount"));
|
||||
assertEquals(1, snapshot.get("capabilityCount"));
|
||||
assertFalse(snapshot.containsKey("skillContent"));
|
||||
assertFalse(snapshot.containsKey("metadataJson"));
|
||||
assertFalse(snapshot.containsKey("resources"));
|
||||
assertFalse(snapshot.containsKey("capabilities"));
|
||||
assertFalse(snapshot.toString().contains("secret"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含指定内容引用序列的快照。
|
||||
*
|
||||
* @param refs 内容引用,可含空值
|
||||
* @return 发布快照
|
||||
*/
|
||||
private Map<String, Object> snapshot(String... refs) {
|
||||
List<Map<String, Object>> resources = new ArrayList<>();
|
||||
for (String ref : refs) {
|
||||
Map<String, Object> resource = new LinkedHashMap<>();
|
||||
resource.put("path", "assets/" + resources.size());
|
||||
resource.put("contentRef", ref);
|
||||
resources.add(resource);
|
||||
}
|
||||
return Map.of("resources", resources);
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package tech.easyflow.skill.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
import tech.easyflow.skill.service.SkillResourceService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillServiceImpl} 聚合删除状态与主行锁约束测试。
|
||||
*/
|
||||
public class SkillServiceImplDeletionGuardTest {
|
||||
|
||||
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
|
||||
|
||||
private SkillMapper mapper;
|
||||
private SkillServiceImpl service;
|
||||
private MockedStatic<SaTokenUtil> saToken;
|
||||
|
||||
/**
|
||||
* 初始化 Skill 删除服务与当前租户登录态。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
mapper = mock(SkillMapper.class);
|
||||
service = spy(new SkillServiceImpl(
|
||||
mock(SkillCategoryService.class),
|
||||
mock(SkillResourceService.class),
|
||||
mock(SkillCapabilityBindingService.class),
|
||||
mock(DBSkillContentStore.class),
|
||||
mock(ResourceAccessService.class),
|
||||
mock(CategoryPermissionService.class),
|
||||
new ObjectMapper()));
|
||||
doReturn(mapper).when(service).getMapper();
|
||||
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
saToken = mockStatic(SaTokenUtil.class);
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放静态登录态 Mock。
|
||||
*/
|
||||
@After
|
||||
public void tearDown() {
|
||||
saToken.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通仓储删除不得绕过已发布状态约束,且检查状态前必须锁定 Skill 主行。
|
||||
*/
|
||||
@Test
|
||||
public void ordinaryDeleteRejectsPublishedSkillAfterRowLock() {
|
||||
doReturn(skill(PublishStatus.PUBLISHED)).when(service).getOne(any(QueryWrapper.class));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.removeAggregate(SKILL_ID));
|
||||
|
||||
assertEquals(409, exception.getHttpStatus());
|
||||
assertTrue(exception.getMessage().contains("先下线"));
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(service).getOne(queryCaptor.capture());
|
||||
assertTrue(queryCaptor.getValue().toSQL().toUpperCase().contains("FOR UPDATE"));
|
||||
verify(mapper, never()).deleteByQuery(any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通仓储删除不得删除处于删除审批中的 Skill。
|
||||
*/
|
||||
@Test
|
||||
public void ordinaryDeleteRejectsDeletePendingSkill() {
|
||||
doReturn(skill(PublishStatus.DELETE_PENDING)).when(service).getOne(any(QueryWrapper.class));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.removeAggregate(SKILL_ID));
|
||||
|
||||
assertEquals(409, exception.getHttpStatus());
|
||||
assertTrue(exception.getMessage().contains("进行中的审批"));
|
||||
verify(mapper, never()).deleteByQuery(any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批通过后的生命周期入口应允许删除 DELETE_PENDING,并仍通过已锁定聚合执行删除。
|
||||
*/
|
||||
@Test
|
||||
public void lifecycleDeleteAllowsDeletePendingSkill() {
|
||||
SkillResourceService resourceService = mock(SkillResourceService.class);
|
||||
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
SkillServiceImpl lifecycleService = spy(new SkillServiceImpl(
|
||||
mock(SkillCategoryService.class),
|
||||
resourceService,
|
||||
capabilityService,
|
||||
mock(DBSkillContentStore.class),
|
||||
accessService,
|
||||
mock(CategoryPermissionService.class),
|
||||
new ObjectMapper()));
|
||||
doReturn(mapper).when(lifecycleService).getMapper();
|
||||
doReturn(skill(PublishStatus.DELETE_PENDING)).when(lifecycleService).getOne(any(QueryWrapper.class));
|
||||
when(resourceService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
when(mapper.deleteByQuery(any(QueryWrapper.class))).thenReturn(1);
|
||||
|
||||
lifecycleService.removeLifecycleAggregate(SKILL_ID);
|
||||
|
||||
verify(capabilityService).removeBySkillId(SKILL_ID);
|
||||
verify(mapper).deleteByQuery(any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建指定发布状态的最小 Skill。
|
||||
*
|
||||
* @param status 发布状态
|
||||
* @return Skill 实体
|
||||
*/
|
||||
private Skill skill(PublishStatus status) {
|
||||
Skill skill = new Skill();
|
||||
skill.setId(SKILL_ID);
|
||||
skill.setTenantId(BigInteger.ONE);
|
||||
skill.setName("demo-skill");
|
||||
skill.setPublishStatus(status.getCode());
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
package tech.easyflow.skill.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.entity.SkillResource;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
import tech.easyflow.skill.service.SkillResourceService;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.skill.validation.SkillValidationResult;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillServiceImpl} 管理操作测试,覆盖复制、更新与发布校验语义。
|
||||
*/
|
||||
public class SkillServiceImplManagementTest {
|
||||
|
||||
/**
|
||||
* 复制 Skill 时应改写标准名称、保留未知 frontmatter,并为二进制资源建立独立引用。
|
||||
*/
|
||||
@Test
|
||||
public void copyDraftPreservesPortableContentAndOwnsBinaryReferences() {
|
||||
BigInteger sourceId = BigInteger.valueOf(101);
|
||||
BigInteger copiedId = BigInteger.valueOf(202);
|
||||
DBSkillContentStore contentStore = mock(DBSkillContentStore.class);
|
||||
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
|
||||
SkillServiceImpl service = spy(service(contentStore, capabilityService,
|
||||
mock(SkillCategoryService.class), mock(ResourceAccessService.class),
|
||||
mock(CategoryPermissionService.class)));
|
||||
|
||||
Skill source = sourceSkill(sourceId);
|
||||
Skill copied = new Skill();
|
||||
copied.setId(copiedId);
|
||||
copied.setCapabilityHash("empty-capability-hash");
|
||||
doAnswer(invocation -> sourceId.equals(invocation.getArgument(0)) ? source : copied)
|
||||
.when(service).getDetail(any(BigInteger.class));
|
||||
ArgumentCaptor<Skill> draftCaptor = ArgumentCaptor.forClass(Skill.class);
|
||||
doReturn(copied).when(service).saveDraft(draftCaptor.capture());
|
||||
when(capabilityService.replaceBindings(eq(copiedId), any(), eq("empty-capability-hash")))
|
||||
.thenReturn(List.of());
|
||||
|
||||
Skill result = service.copyDraft(sourceId, "demo-skill-copy", "演示副本", BigInteger.valueOf(9));
|
||||
|
||||
assertEquals(copiedId, result.getId());
|
||||
Skill draft = draftCaptor.getValue();
|
||||
assertEquals(BigInteger.valueOf(9), draft.getCategoryId());
|
||||
assertEquals("演示副本", draft.getDisplayName());
|
||||
assertTrue(draft.getSkillContent().contains("name: demo-skill-copy"));
|
||||
assertTrue(draft.getSkillContent().contains("nested:"));
|
||||
assertTrue(draft.getSkillContent().contains("keep-me"));
|
||||
assertEquals(2, draft.getResources().size());
|
||||
verify(contentStore).retain("sha256:" + "a".repeat(64));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<SkillCapabilityBinding>> bindingsCaptor = ArgumentCaptor.forClass(List.class);
|
||||
verify(capabilityService).replaceBindings(eq(copiedId), bindingsCaptor.capture(),
|
||||
eq("empty-capability-hash"));
|
||||
assertEquals(BigInteger.valueOf(77), bindingsCaptor.getValue().get(0).getTargetId());
|
||||
assertEquals("demo_tool", bindingsCaptor.getValue().get(0).getRuntimeName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新草稿可能同时移动分类,必须先锁分类树再锁 Skill 行。
|
||||
*/
|
||||
@Test
|
||||
public void updateDraftLocksCategoryTreeBeforeSkillRow() {
|
||||
BigInteger tenantId = BigInteger.valueOf(10);
|
||||
BigInteger categoryId = BigInteger.valueOf(30);
|
||||
SkillCategoryService categoryService = mock(SkillCategoryService.class);
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class),
|
||||
mock(SkillCapabilityBindingService.class), categoryService, accessService,
|
||||
mock(CategoryPermissionService.class)));
|
||||
Skill existing = skill(BigInteger.ONE, tenantId, "demo-skill");
|
||||
existing.setDescription("Demo skill");
|
||||
existing.setSkillContent("---\nname: demo-skill\ndescription: Demo skill\n---\n# Demo\n");
|
||||
existing.setPublishStatus(PublishStatus.DRAFT.getCode());
|
||||
Skill incoming = skill(existing.getId(), tenantId, existing.getName());
|
||||
incoming.setCategoryId(categoryId);
|
||||
doReturn(existing).when(service).getOne(any(QueryWrapper.class));
|
||||
doThrow(new BusinessException("stop after row lock")).when(accessService)
|
||||
.assertAccess(eq(CategoryResourceType.SKILL), eq(existing), eq(ResourceAction.MANAGE), anyString());
|
||||
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(20));
|
||||
account.setTenantId(tenantId);
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
assertThrows(BusinessException.class, () -> service.updateDraft(incoming));
|
||||
}
|
||||
|
||||
InOrder lockOrder = inOrder(categoryService, service);
|
||||
lockOrder.verify(categoryService).lockAndValidateUsableCategory(categoryId);
|
||||
lockOrder.verify(service).getOne(any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖导入必须在取得行锁后重验状态,禁止覆盖并发完成发布的 Skill。
|
||||
*/
|
||||
@Test
|
||||
public void overwriteImportRechecksDraftStatusAfterLock() {
|
||||
BigInteger tenantId = BigInteger.valueOf(10);
|
||||
BigInteger accountId = BigInteger.valueOf(20);
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class),
|
||||
mock(SkillCapabilityBindingService.class), mock(SkillCategoryService.class),
|
||||
accessService, mock(CategoryPermissionService.class)));
|
||||
Skill published = skill(BigInteger.valueOf(1), tenantId, "published-skill");
|
||||
published.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||
doReturn(published).when(service).getOne(any(QueryWrapper.class));
|
||||
Skill imported = skill(published.getId(), tenantId, published.getName());
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(accountId);
|
||||
account.setTenantId(tenantId);
|
||||
|
||||
BusinessException exception;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
exception = assertThrows(BusinessException.class,
|
||||
() -> service.overwriteImportedDraft(imported));
|
||||
}
|
||||
|
||||
assertEquals(409, exception.getHttpStatus());
|
||||
assertTrue(exception.getMessage().contains("仅允许覆盖草稿状态"));
|
||||
verify(accessService).assertAccess(CategoryResourceType.SKILL, published,
|
||||
ResourceAction.MANAGE, "无权限管理该 Skill");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布级校验必须在解析实时能力前重新校验 Skill 管理权限。
|
||||
*/
|
||||
@Test
|
||||
public void publishValidationRequiresManagePermission() {
|
||||
BigInteger skillId = BigInteger.valueOf(101);
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
|
||||
SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class), capabilityService,
|
||||
mock(SkillCategoryService.class), accessService, mock(CategoryPermissionService.class)));
|
||||
Skill detail = skill(skillId, BigInteger.TEN, "demo-skill");
|
||||
detail.setSkillContent("""
|
||||
---
|
||||
name: demo-skill
|
||||
description: Demonstration skill
|
||||
---
|
||||
# Instructions
|
||||
""");
|
||||
doReturn(detail).when(service).getDetail(skillId);
|
||||
SkillValidationResult capabilityResult = new SkillValidationResult();
|
||||
capabilityResult.setValid(true);
|
||||
when(capabilityService.validateBindings(eq(skillId), isNull(), eq(true)))
|
||||
.thenReturn(capabilityResult);
|
||||
|
||||
service.validateSkill(skillId, true);
|
||||
|
||||
verify(accessService).assertAccess(CategoryResourceType.SKILL, detail,
|
||||
ResourceAction.MANAGE, "无权限管理该 Skill");
|
||||
verify(capabilityService).validateBindings(skillId, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库拒绝创建草稿属于服务端持久化故障,不能返回客户端输入错误。
|
||||
*/
|
||||
@Test
|
||||
public void saveDraftPersistenceFailureUsesServerErrorStatus() {
|
||||
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
|
||||
SkillCategoryService categoryService = mock(SkillCategoryService.class);
|
||||
SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class), capabilityService,
|
||||
categoryService, mock(ResourceAccessService.class),
|
||||
mock(CategoryPermissionService.class)));
|
||||
doReturn(0L).when(service).count(any(QueryWrapper.class));
|
||||
doReturn(false).when(service).save(any(Skill.class));
|
||||
when(capabilityService.calculateHash(List.of())).thenReturn("4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945");
|
||||
Skill draft = new Skill();
|
||||
draft.setDisplayName("演示 Skill");
|
||||
draft.setSkillContent("""
|
||||
---
|
||||
name: demo-skill
|
||||
description: Demonstration skill
|
||||
---
|
||||
# Instructions
|
||||
""");
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(20));
|
||||
account.setTenantId(BigInteger.valueOf(10));
|
||||
|
||||
BusinessException exception;
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
exception = assertThrows(BusinessException.class, () -> service.saveDraft(draft));
|
||||
}
|
||||
|
||||
assertEquals(500, exception.getHttpStatus());
|
||||
verify(categoryService).lockAndValidateUsableCategory(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仅注入当前测试依赖的服务实例。
|
||||
*
|
||||
* @param contentStore 内容仓库
|
||||
* @param capabilityService 能力服务
|
||||
* @param categoryService 分类服务
|
||||
* @param accessService 资源权限服务
|
||||
* @param categoryPermissionService 分类权限服务
|
||||
* @return Skill 服务
|
||||
*/
|
||||
private SkillServiceImpl service(DBSkillContentStore contentStore,
|
||||
SkillCapabilityBindingService capabilityService,
|
||||
SkillCategoryService categoryService,
|
||||
ResourceAccessService accessService,
|
||||
CategoryPermissionService categoryPermissionService) {
|
||||
return new SkillServiceImpl(categoryService, mock(SkillResourceService.class), capabilityService,
|
||||
contentStore, accessService, categoryPermissionService, new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建含未知 frontmatter、文本、二进制和能力配置的源 Skill。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @return 源 Skill
|
||||
*/
|
||||
private Skill sourceSkill(BigInteger id) {
|
||||
Skill source = skill(id, BigInteger.ONE, "demo-skill");
|
||||
source.setSkillContent("""
|
||||
---
|
||||
name: demo-skill
|
||||
description: Demonstrates copying
|
||||
nested:
|
||||
value: keep-me
|
||||
---
|
||||
# Demo
|
||||
""");
|
||||
SkillResource text = new SkillResource();
|
||||
text.setPath("references/guide.md");
|
||||
text.setIsText(true);
|
||||
text.setTextContent("guide");
|
||||
text.setMetadataJson(Map.of());
|
||||
SkillResource binary = new SkillResource();
|
||||
binary.setPath("assets/image.png");
|
||||
binary.setIsText(false);
|
||||
binary.setContentRef("sha256:" + "a".repeat(64));
|
||||
binary.setContentHash("a".repeat(64));
|
||||
binary.setMetadataJson(Map.of());
|
||||
source.setResources(List.of(text, binary));
|
||||
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setCapabilityType("WORKFLOW");
|
||||
binding.setTargetId(BigInteger.valueOf(77));
|
||||
binding.setTargetLogicalRef("workflow:demo");
|
||||
binding.setRuntimeName("demo_tool");
|
||||
binding.setEnabled(true);
|
||||
binding.setOptionsJson(Map.of("timeoutMs", 2_000));
|
||||
source.setCapabilityBindings(List.of(binding));
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建最小 Skill 实体。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @param tenantId 租户 ID
|
||||
* @param name Skill 名称
|
||||
* @return Skill 实体
|
||||
*/
|
||||
private Skill skill(BigInteger id, BigInteger tenantId, String name) {
|
||||
Skill skill = new Skill();
|
||||
skill.setId(id);
|
||||
skill.setTenantId(tenantId);
|
||||
skill.setName(name);
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
@@ -90,12 +90,13 @@ public class DBSkillContentStoreTest {
|
||||
* 验证新内容先提交恢复意图,再在业务事务中写文件、激活索引并原子删除意图。
|
||||
*/
|
||||
@Test
|
||||
public void putInputStreamCommitsRecoverableWriteIntentAndActiveIndex() {
|
||||
public void stagedContentCommitsRecoverableWriteIntentAndActiveIndex() {
|
||||
byte[] bytes = "stream-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
String contentRef = SkillHashes.sha256Ref(bytes);
|
||||
FileStorageWriteHandle handle = stubNewContentWrite(bytes, "/attachment/stream.bin");
|
||||
|
||||
String actual = contentStore.put(new ByteArrayInputStream(bytes), bytes.length);
|
||||
SkillContentStage stage = contentStore.stage(new ByteArrayInputStream(bytes), bytes.length);
|
||||
String actual = contentStore.commit(stage);
|
||||
|
||||
assertEquals(contentRef, actual);
|
||||
verify(transactionManager, atLeastOnce()).getTransaction(any(TransactionDefinition.class));
|
||||
|
||||
Reference in New Issue
Block a user