feat: 完善 Skill 管理与发布治理

- 实现标准资源存储、能力绑定及双格式导入导出

- 接入分类、可见范围、审批发布与资源权限校验

- 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
2026-07-27 18:54:20 +08:00
parent aedefe6b5e
commit 2a9e882ac6
165 changed files with 23737 additions and 1088 deletions

View File

@@ -0,0 +1,730 @@
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();
}
}

View File

@@ -0,0 +1,204 @@
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;
}
}

View File

@@ -0,0 +1,242 @@
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;
}
}

View File

@@ -0,0 +1,442 @@
package tech.easyflow.skill.file;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
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.SkillResource;
import tech.easyflow.skill.service.SkillResourceService;
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.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
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.anyString;
import static org.mockito.ArgumentMatchers.eq;
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 SkillFileServiceImpl} 上传事务入口与失败回滚契约测试。
*/
public class SkillFileServiceImplTransactionTest {
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
private static final String NEW_CONTENT_REF = "sha256:" + "a".repeat(64);
private SkillService skillService;
private SkillResourceService skillResourceService;
private DBSkillContentStore contentStore;
private SkillFileServiceImpl service;
private MockedStatic<SaTokenUtil> saToken;
/**
* 初始化上传服务。
*/
@Before
public void setUp() {
skillService = mock(SkillService.class);
skillResourceService = mock(SkillResourceService.class);
contentStore = mock(DBSkillContentStore.class);
service = new SkillFileServiceImpl(
skillService,
skillResourceService,
contentStore,
mock(ResourceAccessService.class));
Skill 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(skillService.getOne(any(QueryWrapper.class))).thenReturn(skill);
when(skillResourceService.list(any(QueryWrapper.class))).thenReturn(List.of());
when(skillResourceService.listDescriptors(any(BigInteger.class), any(BigInteger.class)))
.thenReturn(List.of());
when(contentStore.put(any(MultipartFile.class), anyString())).thenReturn(NEW_CONTENT_REF);
}
/**
* 释放静态登录态 Mock。
*/
@After
public void tearDown() {
saToken.close();
}
/**
* 验证资源持久化失败时不手工 release 新引用,引用计数应由同一外层事务回滚。
*/
@Test
public void failedUploadDoesNotDoubleReleaseNewReference() {
when(skillResourceService.save(any(SkillResource.class))).thenReturn(false);
assertThrows(BusinessException.class, () -> service.uploadAsset(
SKILL_ID, "assets/file.bin", multipart("file.bin", "content")));
verify(contentStore).put(any(MultipartFile.class), anyString());
verify(contentStore, never()).release(NEW_CONTENT_REF);
}
/**
* 验证 uploadAsset 自身成为事务代理入口,不依赖类内调用 uploadResource 的注解。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void uploadAssetIsTransactionalEntry() throws Exception {
Method method = SkillFileServiceImpl.class.getMethod(
"uploadAsset", BigInteger.class, String.class, MultipartFile.class);
assertTrue(method.isAnnotationPresent(Transactional.class));
}
/**
* 验证空 Skill 的文件树仍稳定返回三个标准目录。
*/
@Test
public void treeAlwaysContainsStandardDirectories() {
List<SkillFileNode> roots = service.tree(SKILL_ID);
assertEquals(List.of("SKILL.md", "references", "scripts", "assets"),
roots.stream().map(SkillFileNode::getPath).toList());
assertEquals(List.of("SKILL", "DIRECTORY", "DIRECTORY", "DIRECTORY"),
roots.stream().map(SkillFileNode::getType).toList());
}
/**
* 验证脚本上传按严格 UTF-8 文本保存,不进入二进制内容仓库。
*/
@Test
public void scriptUploadStoresCanonicalTextRepresentation() {
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(9));
savedResource.set(resource);
return true;
});
SkillFileContent result = service.uploadResource(
SKILL_ID, "scripts/tool.py", multipart("tool.py", "print('ok')\n"));
SkillResource resource = savedResource.get();
assertTrue(resource.getIsText());
assertEquals("SCRIPT", resource.getKind());
assertEquals("PYTHON", resource.getLanguage());
assertEquals("print('ok')\n", resource.getTextContent());
assertNull(resource.getContentRef());
assertTrue(result.getIsText());
verify(contentStore, never()).put(any(MultipartFile.class), anyString());
}
/**
* 验证脚本上传拒绝非法 UTF-8且失败前不会写入资源或二进制仓库。
*/
@Test
public void scriptUploadRejectsMalformedUtf8() {
MultipartFile file = new TestMultipartFile("bad.py", new byte[]{(byte) 0xC3, (byte) 0x28});
BusinessException exception = assertThrows(BusinessException.class,
() -> service.uploadResource(SKILL_ID, "scripts/bad.py", file));
assertTrue(exception.getMessage().contains("严格 UTF-8"));
verify(skillResourceService, never()).save(any(SkillResource.class));
verify(contentStore, never()).put(any(MultipartFile.class), anyString());
}
/**
* 验证二进制资源重命名到 scripts 后转为文本,并释放原内容引用。
*/
@Test
public void binaryRenameToScriptConvertsAndReleasesContent() {
String oldRef = "sha256:" + "b".repeat(64);
String sourceHash = "b".repeat(64);
SkillResource resource = resource(
"assets/tool.bin", false, null, oldRef, sourceHash, 12L);
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.open(oldRef)).thenReturn(
new ByteArrayInputStream("print('ok')\n".getBytes(StandardCharsets.UTF_8)));
SkillFileRenameRequest request = renameRequest(
"assets/tool.bin", "scripts/tool.py", sourceHash);
SkillFileContent result = service.renameFile(request);
assertTrue(resource.getIsText());
assertEquals("scripts/tool.py", resource.getNormalizedPath());
assertEquals("SCRIPT", resource.getKind());
assertEquals("PYTHON", resource.getLanguage());
assertNull(resource.getContentRef());
assertEquals("print('ok')\n", resource.getTextContent());
assertTrue(result.getIsText());
verify(contentStore).release(oldRef);
}
/**
* 验证文本资源重命名到 assets 后转为二进制内容引用。
*/
@Test
public void textRenameToAssetConvertsToBinaryRepresentation() {
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));
}
/**
* 验证 assets 路径不能通过文本创建入口形成非规范表示。
*/
@Test
public void createTextAssetIsRejected() {
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("assets/readme.txt");
request.setContent("text");
BusinessException exception = assertThrows(BusinessException.class,
() -> service.createTextFile(request));
assertTrue(exception.getMessage().contains("二进制文件管理"));
verify(skillResourceService, never()).save(any(SkillResource.class));
}
/**
* 验证未知脚本扩展名仍可保真保存,并退化为无语言高亮的文本脚本。
*/
@Test
public void unrecognizedScriptExtensionUsesPlainTextRepresentation() {
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(10));
savedResource.set(resource);
return true;
});
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("scripts/run.rb");
request.setContent("puts 'ok'\n");
SkillFileContent result = service.createTextFile(request);
assertEquals("SCRIPT", savedResource.get().getKind());
assertTrue(savedResource.get().getIsText());
assertNull(savedResource.get().getLanguage());
assertEquals("text/plain", savedResource.get().getMediaType());
assertEquals("puts 'ok'\n", result.getContent());
}
/**
* 验证非 Markdown Reference 保存后仍保留按扩展名识别的媒体类型。
*/
@Test
public void jsonReferenceSavePreservesJsonRepresentation() {
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));
when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true);
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("references/data.json");
request.setContent("{\"ok\":true}\n");
request.setExpectedContentHash(sourceHash);
SkillFileContent result = service.saveContent(request);
assertEquals("REFERENCE", resource.getKind());
assertEquals("application/json", resource.getMediaType());
assertNull(resource.getLanguage());
assertEquals("application/json", result.getMediaType());
assertEquals("{\"ok\":true}\n", result.getContent());
}
/**
* 验证保存 SKILL.md 时不会提前修改当前会话中的持久化实体,避免一级缓存导致版本误判。
*/
@Test
public void skillMarkdownSaveUsesDetachedUpdateForOptimisticCheck() {
String oldContent = "---\nname: demo\ndescription: old\n---\n\n# Old\n";
String newContent = "---\nname: demo\ndescription: new\n---\n\n# New\n";
String oldHash = com.easyagents.skill.util.SkillHashes.sha256Hex(
oldContent.getBytes(StandardCharsets.UTF_8));
Skill persisted = new Skill();
persisted.setId(SKILL_ID);
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)))
.thenAnswer(invocation -> {
updateRef.set(invocation.getArgument(0));
return persisted;
});
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("SKILL.md");
request.setContent(newContent);
request.setExpectedContentHash(oldHash);
service.saveContent(request);
Skill update = updateRef.get();
assertNotSame(persisted, update);
assertEquals(oldContent, persisted.getSkillContent());
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());
}
/**
* 创建内存上传文件。
*
* @param filename 文件名
* @param content 文件内容
* @return MultipartFile
*/
private MultipartFile multipart(String filename, String content) {
return new TestMultipartFile(filename, content.getBytes(StandardCharsets.UTF_8));
}
/**
* 创建测试资源。
*
* @param path 路径
* @param text 是否文本
* @param textContent 文本内容
* @param contentRef 内容引用
* @param contentHash 内容 hash
* @param size 字节数
* @return 资源实体
*/
private SkillResource resource(String path,
boolean text,
String textContent,
String contentRef,
String contentHash,
long size) {
SkillResource resource = new SkillResource();
resource.setId(BigInteger.valueOf(8));
resource.setTenantId(BigInteger.ONE);
resource.setSkillId(SKILL_ID);
resource.setPath(path);
resource.setNormalizedPath(path);
resource.setIsText(text);
resource.setTextContent(textContent);
resource.setContentRef(contentRef);
resource.setContentHash(contentHash);
resource.setSize(size);
return resource;
}
/**
* 创建重命名请求。
*
* @param path 原路径
* @param newPath 新路径
* @param hash 预期内容 hash
* @return 重命名请求
*/
private SkillFileRenameRequest renameRequest(String path, String newPath, String hash) {
SkillFileRenameRequest request = new SkillFileRenameRequest();
request.setSkillId(SKILL_ID);
request.setPath(path);
request.setNewPath(newPath);
request.setExpectedContentHash(hash);
return request;
}
/**
* 简单内存 MultipartFile 测试替身。
*/
private static final class TestMultipartFile implements MultipartFile {
private final String filename;
private final byte[] bytes;
/**
* 创建测试文件。
*
* @param filename 文件名
* @param 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/octet-stream"; }
@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);
}
}
}

View File

@@ -0,0 +1,174 @@
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 中央目录");
}
}

View File

@@ -0,0 +1,84 @@
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));
}
}

View File

@@ -0,0 +1,406 @@
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);
}
}

View File

@@ -0,0 +1,192 @@
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();
}
}

View File

@@ -0,0 +1,227 @@
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);
}
}
}

View File

@@ -0,0 +1,258 @@
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());
}
}

View File

@@ -0,0 +1,430 @@
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();
}
}

View File

@@ -0,0 +1,389 @@
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();
}
}

View File

@@ -0,0 +1,235 @@
package tech.easyflow.skill.imports;
import com.alicp.jetcache.AutoReleaseLock;
import com.alicp.jetcache.Cache;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
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.SkillImportStage;
import tech.easyflow.skill.mapper.SkillImportStageMapper;
import java.math.BigInteger;
import java.util.Date;
import java.util.concurrent.TimeUnit;
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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link SkillImportStageStore} 单次令牌、归属和清理边界测试。
*/
public class SkillImportStageStoreTest {
private static final String TOKEN = "a".repeat(32);
private static final BigInteger TENANT_ID = BigInteger.ONE;
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
/**
* 验证归属正确的待处理令牌只能原子进入处理中状态。
*/
@Test
public void consumeMarksOwnedPendingStageAsProcessing() {
Fixture fixture = fixture();
SkillImportStage stage = pendingStage();
when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(stage);
when(fixture.mapper.consume(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID),
any(Date.class), any(Date.class))).thenReturn(1);
SkillImportStage consumed;
try (MockedStatic<SaTokenUtil> login = login()) {
consumed = fixture.store.consume(TOKEN);
}
assertEquals("PROCESSING", consumed.getStatus());
assertTrue(consumed.getExpiresAt().after(new Date()));
verify(fixture.cache).remove("skill:import:" + TOKEN);
}
/**
* 验证其他用户或租户不能探测并消费已有令牌。
*/
@Test
public void consumeRejectsStageOwnedByAnotherAccount() {
Fixture fixture = fixture();
when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
when(fixture.mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
BusinessException exception;
try (MockedStatic<SaTokenUtil> login = login()) {
exception = assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN));
}
assertEquals(403, exception.getHttpStatus());
verify(fixture.mapper, never()).consume(anyString(), any(), any(), any(), any());
}
/**
* 验证过期令牌在状态更新前被拒绝。
*/
@Test
public void consumeRejectsExpiredStage() {
Fixture fixture = fixture();
SkillImportStage stage = pendingStage();
stage.setExpiresAt(new Date(System.currentTimeMillis() - 1));
when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(stage);
try (MockedStatic<SaTokenUtil> login = login()) {
assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN));
}
verify(fixture.mapper, never()).consume(anyString(), any(), any(), any(), any());
}
/**
* 验证数据库原子更新失败时按重复或过期消费处理。
*/
@Test
public void consumeRejectsAlreadyConsumedStage() {
Fixture fixture = fixture();
when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage());
when(fixture.mapper.consume(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID),
any(Date.class), any(Date.class))).thenReturn(0);
BusinessException exception;
try (MockedStatic<SaTokenUtil> login = login()) {
exception = assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN));
}
assertTrue(exception.getMessage().contains("已过期或已被使用"));
}
/**
* 验证取消待处理会话会删除临时文件、数据库索引和缓存索引。
*/
@Test
public void cancelCleansOwnedPendingStage() {
Fixture fixture = fixture();
when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage());
when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1);
when(fixture.mapper.finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID)).thenReturn(1);
try (MockedStatic<SaTokenUtil> login = login()) {
fixture.store.cancel(TOKEN);
}
verify(fixture.fileStorage).delete("skill-imports/demo.zip");
verify(fixture.mapper).beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class));
verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID);
verify(fixture.cache).remove("skill:import:" + TOKEN);
org.mockito.InOrder order = inOrder(fixture.mapper, fixture.fileStorage);
order.verify(fixture.mapper).beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class));
order.verify(fixture.fileStorage).delete("skill-imports/demo.zip");
order.verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID);
}
/**
* 验证文件删除失败时令牌已不可再消费,过期 PROCESSING 索引留给定时任务重试。
*/
@Test
public void cancelFileFailureLeavesExpiredNonConsumableStage() {
Fixture fixture = fixture();
when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage());
when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1);
org.mockito.Mockito.doThrow(new RuntimeException("storage unavailable"))
.when(fixture.fileStorage).delete("skill-imports/demo.zip");
try (MockedStatic<SaTokenUtil> login = login()) {
assertThrows(BusinessException.class, () -> fixture.store.cancel(TOKEN));
}
verify(fixture.mapper, never()).finishCancel(anyString(), any(), any());
verify(fixture.cache).remove("skill:import:" + TOKEN);
}
/**
* 验证重试取消时物理文件已经不存在也能继续删除遗留索引。
*/
@Test
public void cancelTreatsAlreadyAbsentFileAsSuccessfulCleanup() {
Fixture fixture = fixture();
when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage());
when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1);
when(fixture.mapper.finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID)).thenReturn(1);
org.mockito.Mockito.doThrow(new RuntimeException(
"already absent", new java.nio.file.NoSuchFileException("skill-imports/demo.zip")))
.when(fixture.fileStorage).delete("skill-imports/demo.zip");
try (MockedStatic<SaTokenUtil> login = login()) {
fixture.store.cancel(TOKEN);
}
verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID);
verify(fixture.cache).remove("skill:import:" + TOKEN);
}
/**
* 导入会话索引写入失败属于服务端持久化故障,应返回 5xx。
*/
@Test
public void createPersistenceFailureUsesServerErrorStatus() {
Fixture fixture = fixture();
when(fixture.mapper.insert(any(SkillImportStage.class))).thenReturn(0);
BusinessException exception;
try (MockedStatic<SaTokenUtil> login = login()) {
exception = assertThrows(BusinessException.class,
() -> fixture.store.create("skill-imports/demo.zip", "demo.zip", SkillImportFormat.STANDARD));
}
assertEquals(500, exception.getHttpStatus());
verify(fixture.cache, never()).put(anyString(), any(), anyLong(), any(TimeUnit.class));
}
private Fixture fixture() {
@SuppressWarnings("unchecked")
Cache<String, Object> cache = mock(Cache.class);
AutoReleaseLock lock = mock(AutoReleaseLock.class);
when(cache.tryLock(anyString(), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(lock);
SkillImportStageMapper mapper = mock(SkillImportStageMapper.class);
FileStorageService fileStorage = mock(FileStorageService.class);
return new Fixture(cache, mapper, fileStorage,
new SkillImportStageStore(cache, mapper, fileStorage));
}
private SkillImportStage pendingStage() {
SkillImportStage stage = new SkillImportStage();
stage.setImportToken(TOKEN);
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;
}
private MockedStatic<SaTokenUtil> login() {
LoginAccount account = new LoginAccount();
account.setId(ACCOUNT_ID);
account.setTenantId(TENANT_ID);
MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class);
login.when(SaTokenUtil::getLoginAccount).thenReturn(account);
return login;
}
private record Fixture(Cache<String, Object> cache,
SkillImportStageMapper mapper,
FileStorageService fileStorage,
SkillImportStageStore store) {
}
}

View File

@@ -0,0 +1,57 @@
package tech.easyflow.skill.mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.Test;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.skill.service.impl.SkillCategoryServiceImpl;
import java.lang.reflect.Method;
import java.math.BigInteger;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertEquals;
/**
* {@link SkillCategoryMapper} 分类树并发锁 SQL 契约测试。
*/
public class SkillCategoryMapperLockContractTest {
/**
* 验证租户分类树按稳定主键顺序执行排他锁定。
*
* @throws Exception 反射读取 Mapper 方法失败
*/
@Test
public void tenantTreeMutationShouldLockRowsInStableOrder() throws Exception {
Method method = SkillCategoryMapper.class.getMethod("selectTenantTreeForUpdate", BigInteger.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value())
.replaceAll("\\s+", " ")
.toUpperCase();
assertTrue(sql.contains("WHERE TENANT_ID=#{TENANTID}"));
assertTrue(sql.contains("ORDER BY ID FOR UPDATE"));
assertTrue(sql.contains("TENANT_ID AS TENANTID"));
assertTrue(sql.contains("PARENT_ID AS PARENTID"));
assertTrue(sql.contains("CATEGORY_NAME AS CATEGORYNAME"));
assertTrue(sql.contains("LEVEL_NO AS LEVELNO"));
assertTrue(sql.contains("SORT_NO AS SORTNO"));
assertTrue(sql.contains("CREATED_BY AS CREATEDBY"));
assertTrue(sql.contains("MODIFIED_BY AS MODIFIEDBY"));
}
/**
* Skill 归类锁必须加入调用方写事务,避免锁在 category_id 写入前提前释放。
*
* @throws Exception 反射读取服务方法失败
*/
@Test
public void skillAssignmentLockShouldRequireExistingTransaction() throws Exception {
Method method = SkillCategoryServiceImpl.class.getMethod(
"lockAndValidateUsableCategory", BigInteger.class);
Transactional transactional = method.getAnnotation(Transactional.class);
assertTrue(transactional != null);
assertEquals(Propagation.MANDATORY, transactional.propagation());
}
}

View File

@@ -0,0 +1,178 @@
package tech.easyflow.skill.mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.Test;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.Set;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* {@link SkillContentMapper} 原子状态转换 SQL 契约测试。
*/
public class SkillContentMapperSqlTest {
/**
* 验证注解 SQL 方法名可以作为唯一的 MyBatis statement id避免应用启动时重复注册。
*/
@Test
public void annotatedStatementsHaveUniqueMethodNames() {
Set<String> statementIds = new HashSet<>();
for (Method method : SkillContentMapper.class.getDeclaredMethods()) {
boolean annotated = method.isAnnotationPresent(Select.class)
|| method.isAnnotationPresent(Insert.class)
|| method.isAnnotationPresent(Update.class)
|| method.isAnnotationPresent(Delete.class);
if (annotated) {
assertTrue("Mapper 注解 SQL 方法不允许重载: " + method.getName(),
statementIds.add(method.getName()));
}
}
}
/**
* 验证占位记录以零引用写入,并在物理路径完成后原子激活首个引用。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void reservationStartsInvisibleAndFinishesWithFirstReference() throws Exception {
Method reserve = SkillContentMapper.class.getMethod(
"reserve", String.class, String.class, String.class, String.class, long.class);
Method finish = SkillContentMapper.class.getMethod("finishReservation", String.class, String.class);
String reserveSql = String.join(" ", reserve.getAnnotation(Insert.class).value());
String finishSql = String.join(" ", finish.getAnnotation(Update.class).value());
assertTrue(reserveSql.contains("#{size},0,CURRENT_TIMESTAMP"));
assertTrue(finishSql.contains("ref_count=1"));
assertTrue(finishSql.contains("ref_count=0"));
assertTrue(finishSql.contains("file_path LIKE '__PENDING__:%'"));
}
/**
* 验证引用增加仅作用于已完成且仍可见的内容。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void retainExcludesPendingAndZeroReferenceRows() throws Exception {
Method retain = SkillContentMapper.class.getMethod("retain", String.class);
String sql = String.join(" ", retain.getAnnotation(Update.class).value());
assertTrue(sql.contains("ref_count > 0"));
assertTrue(sql.contains("file_path NOT LIKE '__PENDING__:%'"));
}
/**
* 验证按内容大小复用时同时检查哈希一致性、正式路径与 locator 兼容状态。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void retainMatchingRequiresSizeHashAndActiveLocation() throws Exception {
Method retain = SkillContentMapper.class.getMethod("retainMatching", String.class, long.class);
String sql = String.join(" ", retain.getAnnotation(Update.class).value());
assertTrue(sql.contains("size=#{size}"));
assertTrue(sql.contains("CONCAT('sha256:',content_hash)=#{contentRef}"));
assertTrue(sql.contains("file_path IS NOT NULL"));
assertTrue(sql.contains("file_path<>''"));
assertTrue(sql.contains("file_path NOT LIKE '__PENDING__:%'"));
assertTrue(sql.contains("storage_locator IS NULL OR storage_locator<>''"));
}
/**
* 验证引用状态转换使用锁定当前读,并且旧版恢复严格限制为已校验的零引用无 locator 行。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void currentReadAndLegacyResurrectionAreStateSafe() throws Exception {
Method current = SkillContentMapper.class.getMethod("selectForUpdate", String.class);
Method resurrect = SkillContentMapper.class.getMethod(
"resurrectVerifiedLegacy", String.class, String.class, String.class, long.class);
String currentSql = String.join(" ", current.getAnnotation(Select.class).value());
String resurrectSql = String.join(" ", resurrect.getAnnotation(Update.class).value());
assertTrue(currentSql.endsWith("FOR UPDATE"));
assertTrue(resurrectSql.contains("ref_count=1"));
assertTrue(resurrectSql.contains("ref_count=0"));
assertTrue(resurrectSql.contains("storage_locator IS NULL"));
assertTrue(resurrectSql.contains("content_hash=#{contentHash}"));
assertTrue(resurrectSql.contains("file_path=#{filePath}"));
assertTrue(resurrectSql.contains("size=#{size}"));
}
/**
* 验证新内容直接以首个正式引用写入,并拒绝空 locator 或哈希不一致参数。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void insertActiveRequiresStableLocatorAndMatchingHash() throws Exception {
Method insert = SkillContentMapper.class.getMethod("insertActive", String.class, String.class,
String.class, String.class, String.class, long.class);
String sql = String.join(" ", insert.getAnnotation(Insert.class).value());
assertTrue(sql.contains("INSERT INTO tb_skill_content"));
assertFalse(sql.contains("INSERT IGNORE"));
assertTrue(sql.contains("storage_locator"));
assertTrue(sql.contains("1,CURRENT_TIMESTAMP"));
assertTrue(sql.contains("#{storageLocator} IS NOT NULL"));
assertTrue(sql.contains("#{storageLocator}<>''"));
assertTrue(sql.contains("CONCAT('sha256:',#{contentHash})=#{contentRef}"));
}
/**
* 验证新释放流程在状态转换与索引删除时均精确匹配 locator。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void releaseMutationsMatchStorageLocatorExactly() throws Exception {
Method mark = SkillContentMapper.class.getMethod(
"markReleased", String.class, String.class, String.class);
Method delete = SkillContentMapper.class.getMethod(
"deleteReleased", String.class, String.class, String.class);
String markSql = String.join(" ", mark.getAnnotation(Update.class).value());
String deleteSql = String.join(" ", delete.getAnnotation(Delete.class).value());
assertTrue(markSql.contains("storage_locator<=>#{storageLocator}"));
assertTrue(deleteSql.contains("storage_locator<=>#{storageLocator}"));
assertTrue(markSql.contains("file_path=#{filePath}"));
assertTrue(deleteSql.contains("file_path=#{filePath}"));
}
/**
* 验证所有返回内容实体的显式查询都读取 storage_locator。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void contentEntityQueriesSelectStorageLocator() throws Exception {
Method pending = SkillContentMapper.class.getMethod("findStalePending", java.util.Date.class, int.class);
Method released = SkillContentMapper.class.getMethod("findReleasedBefore", java.util.Date.class, int.class);
String pendingSql = String.join(" ", pending.getAnnotation(Select.class).value());
assertTrue(pendingSql.contains("content_ref AS contentRef"));
assertTrue(pendingSql.contains("content_hash AS contentHash"));
assertTrue(pendingSql.contains("file_path AS filePath"));
assertTrue(pendingSql.contains("storage_locator AS storageLocator"));
assertTrue(pendingSql.contains("media_type AS mediaType"));
assertTrue(pendingSql.contains("ref_count AS refCount"));
String releasedSql = String.join(" ", released.getAnnotation(Select.class).value());
assertTrue(releasedSql.contains("storage_locator AS storageLocator"));
assertTrue(releasedSql.contains("storage_locator IS NOT NULL"));
assertTrue(releasedSql.contains("storage_locator<>''"));
}
}

View File

@@ -0,0 +1,148 @@
package tech.easyflow.skill.mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.Test;
import java.lang.reflect.Method;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* {@link SkillContentWriteIntentMapper} 原子状态转换 SQL 契约测试。
*/
public class SkillContentWriteIntentMapperSqlTest {
private static final String ALL_COLUMNS =
"content_ref AS contentRef,reservation_token AS reservationToken,content_hash AS contentHash," +
"storage_locator AS storageLocator,media_type AS mediaType,size,state,created,modified";
/**
* 验证预留通过普通 INSERT 竞争主键,避免静默吞掉主键之外的数据库错误。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void reserveCreatesValidatedPendingIntent() throws Exception {
Method reserve = SkillContentWriteIntentMapper.class.getMethod("reserve", String.class, String.class,
String.class, String.class, String.class, long.class);
String sql = sql(reserve, Insert.class);
assertTrue(sql.contains("INSERT INTO tb_skill_content_write_intent"));
assertFalse(sql.contains("INSERT IGNORE"));
assertTrue(sql.contains("'PENDING'"));
assertTrue(sql.contains("#{storageLocator} IS NOT NULL"));
assertTrue(sql.contains("#{storageLocator}<>''"));
assertTrue(sql.contains("CONCAT('sha256:',#{contentHash})=#{contentRef}"));
}
/**
* 验证写入声明仅允许同一令牌从 PENDING 原子进入 WRITING。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void claimForWriteMatchesTokenAndPendingState() throws Exception {
Method claim = SkillContentWriteIntentMapper.class.getMethod(
"claimForWrite", String.class, String.class);
String sql = sql(claim, Update.class);
assertTrue(sql.contains("SET state='WRITING'"));
assertTrue(sql.contains("reservation_token=#{reservationToken}"));
assertTrue(sql.contains("state='PENDING'"));
}
/**
* 验证过期扫描覆盖全部未完成状态并返回完整实体字段。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void staleScanCoversAllStatesAndColumns() throws Exception {
Method find = SkillContentWriteIntentMapper.class.getMethod("findStale", java.util.Date.class, int.class);
String sql = sql(find, Select.class);
assertTrue(sql.contains("SELECT " + ALL_COLUMNS));
assertTrue(sql.contains("state IN ('PENDING','WRITING','CLEANING')"));
assertTrue(sql.contains("modified<#{cutoff}"));
assertTrue(sql.contains("ORDER BY modified ASC LIMIT #{limit}"));
}
/**
* 验证清理声明使用 token、观察状态与截止时间做 CAS且活动内容存在时禁止清理。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void cleanupClaimIsConditionalAndProtectsActiveContent() throws Exception {
Method claim = SkillContentWriteIntentMapper.class.getMethod(
"claimForCleanup", String.class, String.class, String.class, java.util.Date.class);
String sql = sql(claim, Update.class);
assertTrue(sql.contains("SET state='CLEANING'"));
assertTrue(sql.contains("reservation_token=#{reservationToken}"));
assertTrue(sql.contains("state=#{expectedState}"));
assertTrue(sql.contains("state IN ('PENDING','WRITING','CLEANING')"));
assertTrue(sql.contains("modified<#{cutoff}"));
assertTrue(sql.contains("NOT EXISTS"));
assertTrue(sql.contains("active_content.ref_count>0"));
}
/**
* 验证清理完成只删除同一 token 的 CLEANING 意图,活动内容则仅清除残留意图。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void intentDeletesAreTokenScopedAndStateSafe() throws Exception {
Method deleteClaimed = SkillContentWriteIntentMapper.class.getMethod(
"deleteClaimed", String.class, String.class);
Method deleteActive = SkillContentWriteIntentMapper.class.getMethod(
"deleteIfActiveExists", String.class, String.class);
Method deletePending = SkillContentWriteIntentMapper.class.getMethod(
"deletePending", String.class, String.class);
String claimedSql = sql(deleteClaimed, Delete.class);
String activeSql = sql(deleteActive, Delete.class);
String pendingSql = sql(deletePending, Delete.class);
assertTrue(claimedSql.contains("reservation_token=#{reservationToken}"));
assertTrue(claimedSql.contains("state='CLEANING'"));
assertTrue(activeSql.contains("reservation_token=#{reservationToken}"));
assertTrue(activeSql.contains("EXISTS"));
assertTrue(activeSql.contains("active_content.ref_count>0"));
assertTrue(pendingSql.contains("reservation_token=#{reservationToken}"));
assertTrue(pendingSql.contains("state='PENDING'"));
}
/**
* 验证单条意图查询读取完整字段。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void getIntentSelectsEveryMappedColumn() throws Exception {
Method get = SkillContentWriteIntentMapper.class.getMethod("getIntent", String.class);
String sql = sql(get, Select.class);
assertTrue(sql.contains("SELECT " + ALL_COLUMNS));
assertTrue(sql.contains("content_ref=#{contentRef}"));
}
/**
* 读取方法上的单个 SQL 注解值。
*
* @param method Mapper 方法
* @param annotationType SQL 注解类型
* @return SQL 文本
* @throws ReflectiveOperationException 注解 value 方法不可访问
*/
private String sql(Method method, Class<?> annotationType) throws ReflectiveOperationException {
Object annotation = method.getAnnotation(annotationType.asSubclass(java.lang.annotation.Annotation.class));
String[] values = (String[]) annotationType.getMethod("value").invoke(annotation);
return String.join(" ", values);
}
}

View File

@@ -0,0 +1,74 @@
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;
/**
* V31 Skill 内容写入意图迁移契约测试。
*/
public class SkillContentWriteIntentMigrationContractTest {
/**
* 验证 storage_locator 通过 information_schema 守卫幂等添加。
*
* @throws Exception 迁移文件不可读
*/
@Test
public void storageLocatorAlterIsIdempotent() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("FROM information_schema.columns"));
assertTrue(sql.contains("table_name = 'tb_skill_content'"));
assertTrue(sql.contains("column_name = 'storage_locator'"));
assertTrue(sql.contains("ADD COLUMN `storage_locator` VARCHAR(2048) NULL"));
assertTrue(sql.contains("PREPARE skill_content_storage_locator_stmt"));
assertFalse(sql.contains("ADD COLUMN IF NOT EXISTS"));
}
/**
* 验证写入意图表与正式内容表分离,并具备状态清理索引和完整审计时间。
*
* @throws Exception 迁移文件不可读
*/
@Test
public void writeIntentTableHasRequiredRecoveryColumns() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `tb_skill_content_write_intent`"));
assertTrue(sql.contains("`content_ref` VARCHAR(128) NOT NULL"));
assertTrue(sql.contains("`reservation_token` VARCHAR(128) NOT NULL"));
assertTrue(sql.contains("`content_hash` VARCHAR(128) NOT NULL"));
assertTrue(sql.contains("`storage_locator` VARCHAR(2048) NOT NULL"));
assertTrue(sql.contains("`state` VARCHAR(16) NOT NULL COMMENT 'PENDING/WRITING/CLEANING'"));
assertTrue(sql.contains("PRIMARY KEY (`content_ref`)"));
assertTrue(sql.contains("`idx_skill_content_write_intent_state_modified` (`state`, `modified`)"));
assertTrue(sql.contains("`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"));
assertTrue(sql.contains("`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"));
}
/**
* 读取工作区中的 V31 MySQL 迁移。
*
* @return 迁移 SQL
* @throws Exception 迁移文件不存在或不可读
*/
private String migrationSql() throws Exception {
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
while (root != null) {
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
+ "db/migration/mysql/V31__mysql_skill_content_write_intent.sql");
if (Files.isRegularFile(migration)) {
return Files.readString(migration, StandardCharsets.UTF_8);
}
root = root.getParent();
}
throw new IllegalStateException("找不到 V31 Skill 内容写入意图迁移");
}
}

View File

@@ -0,0 +1,143 @@
package tech.easyflow.skill.mapper;
import com.easyagents.skill.util.SkillHashes;
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;
/**
* V27 旧 Skill 数据迁移的失败前置与摘要初值契约测试。
*/
public class SkillMigrationGuardContractTest {
/**
* 验证分类重复和旧资源冲突检查均位于业务表 DDL 之前。
*
* @throws Exception 读取迁移文件失败
*/
@Test
public void dataGuardsShouldRunBeforePersistentDdl() throws Exception {
String sql = migrationSql();
int firstPersistentDdl = sql.indexOf("ALTER TABLE `tb_skill`");
assertTrue(firstPersistentDdl > 0);
assertTrue(sql.indexOf("tmp_skill_category_migration_guard") < firstPersistentDdl);
assertTrue(sql.indexOf("HAVING COUNT(1) > 1") < firstPersistentDdl);
assertTrue(sql.indexOf("tmp_skill_resource_owner_guard") < firstPersistentDdl);
assertTrue(sql.indexOf("tmp_skill_content_migration_guard") < firstPersistentDdl);
assertTrue(sql.indexOf("tmp_skill_resource_migration_source") < firstPersistentDdl);
assertTrue(sql.indexOf("UNION ALL") < firstPersistentDdl);
}
/**
* 验证旧 Skill 的空能力配置 hash 与运行时算法一致,且迁移不改业务审计列。
*
* @throws Exception 读取迁移文件失败
*/
@Test
public void emptyCapabilityHashShouldMatchRuntimeCanonicalValue() throws Exception {
String sql = migrationSql();
String emptyHash = SkillHashes.sha256Hex("[]".getBytes(StandardCharsets.UTF_8));
assertTrue(sql.contains("`capability_hash` = '" + emptyHash + "'"));
assertTrue(sql.contains("`modified` = `modified`"));
assertTrue(sql.contains("`modified_by` = `modified_by`"));
}
/**
* 验证旧资源迁移不会使用 INSERT IGNORE 静默吞掉冲突数据。
*
* @throws Exception 读取迁移文件失败
*/
@Test
public void resourceMigrationShouldNeverSilentlyIgnoreConflicts() throws Exception {
String sql = migrationSql();
assertFalse(sql.contains("INSERT IGNORE INTO `tb_skill_resource`"));
assertFalse(sql.contains("INSERT IGNORE INTO `tb_skill_content`"));
assertTrue(sql.contains("tmp_skill_target_migration_guard"));
assertTrue(sql.contains("WHERE NOT EXISTS ("));
assertTrue(sql.contains("information_schema.statistics"));
assertFalse(sql.contains("ADD COLUMN IF NOT EXISTS"));
}
/**
* 验证旧文本 hash、二进制大小与引用数都从可验证真相源修复。
*
* @throws Exception 读取迁移文件失败
*/
@Test
public void legacyContentSummaryShouldBeRepairedFromCanonicalSources() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("LOWER(SHA2(COALESCE(reference.`content`, ''), 256))"));
assertTrue(sql.contains("LOWER(SHA2(COALESCE(script.`content`, ''), 256))"));
assertTrue(sql.contains("content.`content_hash`,"));
assertTrue(sql.contains("SELECT MAX(asset.`size`)"));
assertTrue(sql.contains("SELECT COUNT(1) FROM `tb_skill_asset` asset"));
assertTrue(sql.contains("tmp_skill_snapshot_content_ref"));
assertTrue(sql.contains("+ COALESCE((SELECT snapshot_ref.`ref_count`"));
assertFalse(sql.contains("GREATEST(COALESCE(content.`ref_count`, 0)"));
}
/**
* 验证新旧快照字段同时存在时优先读取 resources避免兼容字段重复计数。
*
* @throws Exception 读取迁移文件失败
*/
@Test
public void snapshotResourcesShouldTakePrecedenceOverLegacyAssets() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("JSON_EXTRACT(skill.`published_snapshot_json`, '$.resources')"));
assertTrue(sql.contains("JSON_EXTRACT(approval.`snapshot_json`, '$.resourceSnapshot.resources')"));
assertTrue(sql.contains("approval.`status` IN ('PENDING', 'PROCESSING')"));
assertTrue(sql.contains("<> 'ARRAY'"));
assertTrue(sql.contains("= 'ARRAY'"));
}
/**
* 验证迁移后所有资源可被租户查询,历史计数与根分类语义也同步归一。
*
* @throws Exception 读取迁移文件失败
*/
@Test
public void tenantCountsAndRootCategoryShouldBeNormalizedWithoutAuditPollution() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("COALESCE(reference.`tenant_id`, skill.`tenant_id`)"));
assertTrue(sql.contains("COALESCE(script.`tenant_id`, skill.`tenant_id`)"));
assertTrue(sql.contains("COALESCE(asset.`tenant_id`, skill.`tenant_id`)"));
assertTrue(sql.contains("`tenant_id` BIGINT NOT NULL COMMENT '租户ID'"));
assertTrue(sql.contains("resource.`tenant_id` <> skill.`tenant_id`"));
assertTrue(sql.contains("SET `parent_id` = NULL, `modified` = `modified`, `modified_by` = `modified_by`"));
assertTrue(sql.contains("`reference_count` = (SELECT COUNT(1)"));
assertTrue(sql.contains("`script_count` = (SELECT COUNT(1)"));
assertTrue(sql.contains("`asset_count` = (SELECT COUNT(1)"));
}
/**
* 读取工作区中的 V27 MySQL 迁移。
*
* @return 迁移 SQL
* @throws Exception 迁移文件不存在或不可读
*/
private String migrationSql() throws Exception {
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
while (root != null) {
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
+ "db/migration/mysql/V27__mysql_skill_resource_capability.sql");
if (Files.isRegularFile(migration)) {
return Files.readString(migration, StandardCharsets.UTF_8);
}
root = root.getParent();
}
throw new IllegalStateException("未找到 V27 Skill MySQL 迁移文件");
}
}

View File

@@ -0,0 +1,94 @@
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.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* Skill 操作权限迁移的最小授权与升级兼容契约测试。
*/
public class SkillPermissionMigrationContractTest {
/**
* 验证 V28 只向超级管理员角色授予新增权限,不扩大其他角色权限。
*
* @throws Exception 读取迁移文件失败
*/
@Test
public void operationPermissionsShouldDefaultToSuperAdminOnly() throws Exception {
String sql = migrationSql("V28__mysql_skill_operation_permissions.sql");
int roleGrantStart = sql.indexOf("INSERT INTO `tb_sys_role_menu`");
String roleGrants = sql.substring(roleGrantStart);
assertTrue(roleGrantStart > 0);
assertEquals(5, occurrences(roleGrants, "`role_id` = 1"));
assertFalse(roleGrants.contains("FROM `tb_sys_role`"));
assertFalse(roleGrants.contains("SELECT `id` FROM `tb_sys_role`"));
}
/**
* 验证 V29 先保留历史角色授权,再删除无真实入口的旧菜单。
*
* @throws Exception 读取迁移文件失败
*/
@Test
public void deletePermissionCleanupShouldPreserveExplicitRoleGrants() throws Exception {
String sql = migrationSql("V29__mysql_skill_delete_permission_cleanup.sql");
int duplicateCleanup = sql.indexOf("DELETE legacy_mapping");
int grantMigration = sql.indexOf("UPDATE `tb_sys_role_menu`");
int deadMenuCleanup = sql.indexOf("DELETE FROM `tb_sys_menu`");
assertTrue(duplicateCleanup >= 0);
assertTrue(grantMigration > duplicateCleanup);
assertTrue(deadMenuCleanup > grantMigration);
assertTrue(sql.contains("SET `menu_id` = 367400000000000018"));
assertTrue(sql.contains("WHERE `menu_id` = 367400000000000015"));
assertTrue(sql.contains("`permission_tag` = '/api/v1/skill/remove'"));
assertTrue(sql.contains("`permission_tag` = '/api/v1/skill/submitDeleteApproval'"));
assertFalse(sql.contains("INSERT INTO `tb_sys_role_menu`"));
}
/**
* 统计文本片段出现次数。
*
* @param source 原始文本
* @param target 目标片段
* @return 出现次数
*/
private int occurrences(String source, String target) {
int count = 0;
int index = 0;
while ((index = source.indexOf(target, index)) >= 0) {
count++;
index += target.length();
}
return count;
}
/**
* 读取指定 MySQL 迁移。
*
* @param fileName 迁移文件名
* @return 迁移 SQL
* @throws Exception 迁移文件不存在或不可读
*/
private String migrationSql(String fileName) throws Exception {
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
while (root != null) {
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
+ "db/migration/mysql/" + fileName);
if (Files.isRegularFile(migration)) {
return Files.readString(migration, StandardCharsets.UTF_8);
}
root = root.getParent();
}
throw new IllegalStateException("未找到 Skill MySQL 迁移文件: " + fileName);
}
}

View File

@@ -0,0 +1,62 @@
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());
}
}

View File

@@ -0,0 +1,230 @@
package tech.easyflow.skill.publish;
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.MockedStatic;
import org.mockito.ArgumentCaptor;
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.service.ApprovalInstanceService;
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.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.same;
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 SkillApprovalSubjectHandler} 发布候选与已发布快照引用所有权测试。
*/
public class SkillApprovalSubjectHandlerContentReferenceTest {
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
private static final BigInteger OPERATOR_ID = BigInteger.valueOf(7);
private ApprovalInstanceService approvalInstanceService;
private SkillService skillService;
private SkillMapper skillMapper;
private SkillApprovalSubjectHandler handler;
private MockedStatic<SaTokenUtil> saToken;
/**
* 初始化审批处理器。
*/
@Before
public void setUp() {
approvalInstanceService = mock(ApprovalInstanceService.class);
skillService = mock(SkillService.class);
skillMapper = mock(SkillMapper.class);
LoginAccount account = new LoginAccount();
account.setId(OPERATOR_ID);
account.setTenantId(BigInteger.ONE);
saToken = mockStatic(SaTokenUtil.class);
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);
handler = new SkillApprovalSubjectHandler(
approvalInstanceService,
new ObjectMapper(),
skillService,
skillMapper,
mock(ResourceAccessService.class));
}
/**
* 释放静态登录态 Mock。
*/
@After
public void tearDown() {
saToken.close();
}
/**
* 验证发布候选在提交审批请求时立即持有自己的内容引用。
*/
@Test
public void publishCandidateRetainsSnapshotContentsOnSubmit() {
Skill draft = skill(PublishStatus.DRAFT, Map.of());
Map<String, Object> candidate = snapshot("sha256:candidate");
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
when(skillService.buildPublishSnapshot(draft)).thenReturn(candidate);
ApprovalSubmitRequest request = handler.buildSubmitRequest(
SKILL_ID, ApprovalActionType.PUBLISH.getCode(), OPERATOR_ID);
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(skillMapper).selectOneByQuery(queryCaptor.capture());
assertTrue(queryCaptor.getValue().toSQL().toLowerCase().contains("for update"));
verify(skillService).retainSnapshotContents(candidate);
assertSame(candidate, request.getSnapshotJson().get("resourceSnapshot"));
assertEquals(PublishStatus.DRAFT.getCode(), request.getSnapshotJson().get("previousPublishStatus"));
}
/**
* 验证重新发布时候选引用转为已发布持有,只释放被替换的旧快照。
*/
@Test
public void approvedRepublishReleasesOnlyPreviousPublishedSnapshot() {
Map<String, Object> previous = snapshot("sha256:previous");
Map<String, Object> candidate = snapshot("sha256:candidate");
Skill published = skill(PublishStatus.PUBLISHED, previous);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID);
verify(skillMapper).publish(
eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), any(Date.class),
eq(OPERATOR_ID), isNull());
verify(skillService).releaseSnapshotContents(previous);
verify(skillService, never()).releaseSnapshotContents(candidate);
}
/**
* 验证发布审批驳回或撤回会释放候选快照,且不会释放当前线上快照。
*/
@Test
public void rejectedPublishReleasesCandidateButKeepsPublishedSnapshot() {
BigInteger instanceId = BigInteger.valueOf(99);
Map<String, Object> publishedSnapshot = snapshot("sha256:published");
Map<String, Object> candidate = snapshot("sha256:candidate");
Skill published = skill(PublishStatus.PUBLISH_PENDING, publishedSnapshot);
published.setCurrentApprovalInstanceId(instanceId);
ApprovalInstance instance = new ApprovalInstance();
instance.setActionType(ApprovalActionType.PUBLISH.getCode());
instance.setSnapshotJson(Map.of("resourceSnapshot", candidate));
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
when(approvalInstanceService.getById(instanceId)).thenReturn(instance);
handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED);
verify(skillMapper).updateApprovalState(
SKILL_ID, BigInteger.ONE, PublishStatus.PUBLISHED.getCode(), null);
verify(skillService).releaseSnapshotContents(candidate);
verify(skillService, never()).releaseSnapshotContents(publishedSnapshot);
}
/**
* 验证删除草稿不触发发布级校验,并使用不含凭据的治理快照。
*/
@Test
public void deleteDraftUsesGovernanceSnapshotWithoutPublishValidation() {
Skill draft = skill(PublishStatus.DRAFT, Map.of());
Map<String, Object> governance = Map.of(
"id", SKILL_ID,
"name", "demo-skill",
"capabilityCount", 1);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
when(skillService.buildGovernanceSnapshot(draft)).thenReturn(governance);
ApprovalSubmitRequest request = handler.buildSubmitRequest(
SKILL_ID, ApprovalActionType.DELETE.getCode(), OPERATOR_ID);
assertSame(governance, request.getSnapshotJson().get("resourceSnapshot"));
verify(skillService).buildGovernanceSnapshot(draft);
verify(skillService, never()).buildPublishSnapshot(any());
verify(skillService, never()).retainSnapshotContents(any());
}
/**
* 验证已发布 Skill 仍须先下线,且不会构建任何删除快照。
*/
@Test
public void deletePublishedSkillRequiresOfflineFirst() {
Skill published = skill(PublishStatus.PUBLISHED, snapshot("sha256:published"));
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
assertThrows(BusinessException.class, () -> handler.buildSubmitRequest(
SKILL_ID, ApprovalActionType.DELETE.getCode(), OPERATOR_ID));
verify(skillService, never()).buildGovernanceSnapshot(any());
verify(skillService, never()).buildPublishSnapshot(any());
}
/**
* 删除审批通过或无审批直通时必须使用生命周期专用聚合删除入口。
*/
@Test
public void approvedDeleteUsesLifecycleAggregateRemoval() {
handler.applyApprovedAction(
ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID);
verify(skillService).removeLifecycleAggregate(SKILL_ID);
verify(skillService, never()).removeAggregate(SKILL_ID);
}
/**
* 创建指定生命周期状态的 Skill。
*
* @param status 发布状态
* @param publishedSnapshot 已发布快照
* @return Skill
*/
private Skill skill(PublishStatus status, Map<String, Object> publishedSnapshot) {
Skill skill = new Skill();
skill.setId(SKILL_ID);
skill.setTenantId(BigInteger.ONE);
skill.setName("demo-skill");
skill.setDisplayName("Demo Skill");
skill.setPublishStatus(status.getCode());
skill.setPublishedSnapshotJson(publishedSnapshot);
return skill;
}
/**
* 创建单二进制资源快照。
*
* @param contentRef 内容引用
* @return 快照
*/
private Map<String, Object> snapshot(String contentRef) {
return Map.of("resources", List.of(Map.of(
"path", "assets/file.bin",
"contentRef", contentRef)));
}
}

View File

@@ -0,0 +1,218 @@
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;
}
}

View File

@@ -0,0 +1,89 @@
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",
"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));
}
}
}

View File

@@ -0,0 +1,75 @@
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"));
}
}

View File

@@ -0,0 +1,84 @@
package tech.easyflow.skill.security;
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.entity.Skill;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysDeptService;
import java.math.BigInteger;
import java.util.Locale;
import java.util.Set;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* {@link SkillVisibilityQueryHelper} 的租户边界回归测试。
*/
public class SkillVisibilityQueryHelperTenantTest {
/**
* 验证超级管理员的列表查询仍然限定在当前租户内。
*/
@Test
public void superAdminQueryShouldStillContainCurrentTenantCondition() {
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
SysDeptService sysDeptService = mock(SysDeptService.class);
SkillVisibilityQueryHelper helper = new SkillVisibilityQueryHelper(
categoryPermissionService, sysDeptService);
LoginAccount account = account(7, 42);
when(categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()))
.thenReturn(new RoleCategoryAccessSnapshot(
CategoryResourceType.SKILL.getCode(), account.getId(), true, true, Set.of()));
QueryWrapper query = QueryWrapper.create().from(Skill.class);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
helper.applyReadableAccess(query);
}
assertTrue("超级管理员查询缺少 tenant_id 条件: " + query.toSQL(),
query.toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"));
}
/**
* 验证分类 ALL 范围的列表查询显式包含未分类 Skill。
*/
@Test
public void allCategoryScopeQueryShouldIncludeUnclassifiedSkills() {
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
SysDeptService sysDeptService = mock(SysDeptService.class);
SkillVisibilityQueryHelper helper = new SkillVisibilityQueryHelper(
categoryPermissionService, sysDeptService);
LoginAccount account = account(7, 42);
when(categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()))
.thenReturn(new RoleCategoryAccessSnapshot(
CategoryResourceType.SKILL.getCode(), account.getId(), false, true, Set.of()));
QueryWrapper query = QueryWrapper.create().from(Skill.class);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
helper.applyReadableAccess(query);
}
String sql = query.toSQL().toLowerCase(Locale.ROOT);
assertTrue("ALL 分类查询缺少未分类分支: " + sql,
sql.contains("category_id") && sql.contains("is null"));
}
private LoginAccount account(long accountId, long tenantId) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(accountId));
account.setTenantId(BigInteger.valueOf(tenantId));
account.setDeptId(BigInteger.valueOf(9));
return account;
}
}

View File

@@ -0,0 +1,132 @@
package tech.easyflow.skill.service.impl;
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.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.SkillCategory;
import java.math.BigInteger;
import java.util.List;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
/**
* {@link SkillCategoryServiceImpl} 分类循环、深度和删除约束测试。
*/
public class SkillCategoryServiceImplTest {
private SkillCategoryServiceImpl service;
private MockedStatic<SaTokenUtil> saToken;
/**
* 初始化可隔离父级查询的分类服务。
*/
@Before
public void setUp() {
service = spy(new SkillCategoryServiceImpl());
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.ONE);
saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
}
/**
* 释放静态登录态 Mock。
*/
@After
public void tearDown() {
saToken.close();
}
/**
* 验证分类不能将自身设置为父级。
*/
@Test
public void categoryCannotBeItsOwnParent() {
SkillCategory category = category(1, 1, 1, "");
doReturn(category).when(service).getById(BigInteger.ONE);
assertThrows(BusinessException.class, () -> service.updateById(category));
}
/**
* 验证创建或移动到三级父级下会因形成第四级而被拒绝。
*/
@Test
public void categoryCannotMoveBelowLevelThreeParent() {
SkillCategory category = category(10, 3, 1, "");
SkillCategory parent = category(3, null, 3, "1,2");
doReturn(parent).when(service).getById(BigInteger.valueOf(3));
assertThrows(BusinessException.class, () -> service.updateById(category));
}
/**
* 验证分类不能移动到自己的直接或间接后代下。
*/
@Test
public void categoryCannotMoveUnderDescendant() {
SkillCategory category = category(1, 2, 1, "");
SkillCategory descendant = category(2, 1, 2, "1");
doReturn(descendant).when(service).getById(BigInteger.valueOf(2));
assertThrows(BusinessException.class, () -> service.updateById(category));
}
/**
* 验证移动带子树分类时需要按整个子树的新深度执行三级限制。
*/
@Test
public void movingSubtreeCannotPushDescendantBeyondLevelThree() {
SkillCategory category = category(1, 9, 1, "");
SkillCategory newParent = category(9, null, 1, "");
SkillCategory child = category(2, 1, 2, "1");
SkillCategory grandchild = category(3, 2, 3, "1,2");
doReturn(newParent).when(service).getById(BigInteger.valueOf(9));
doReturn(List.of(child, grandchild)).when(service).list(any(QueryWrapper.class));
assertThrows(BusinessException.class, () -> service.updateById(category));
}
/**
* 验证存在子分类时删除约束由服务层统一执行。
*/
@Test
public void categoryWithChildrenCannotBeDeletedAtServiceLayer() {
doReturn(category(1, null, 1, "")).when(service).getById(BigInteger.ONE);
doReturn(true).when(service).hasChildren(BigInteger.ONE);
assertThrows(BusinessException.class, () -> service.removeById(BigInteger.ONE));
}
/**
* 创建分类测试数据。
*
* @param id 分类 ID
* @param parentId 父级 ID
* @param level 层级
* @param ancestors 祖先路径
* @return 分类
*/
private SkillCategory category(long id, Integer parentId, int level, String ancestors) {
SkillCategory category = new SkillCategory();
category.setId(BigInteger.valueOf(id));
category.setParentId(parentId == null ? null : BigInteger.valueOf(parentId));
category.setCategoryName("category-" + id);
category.setLevelNo(level);
category.setAncestors(ancestors);
category.setStatus(1);
category.setTenantId(BigInteger.ONE);
return category;
}
}

View File

@@ -0,0 +1,145 @@
package tech.easyflow.skill.service.impl;
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.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.SkillCategory;
import tech.easyflow.skill.mapper.SkillCategoryMapper;
import java.math.BigInteger;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.doReturn;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Skill 分类租户边界与循环约束的有效路径测试。
*/
public class SkillCategoryTenantConstraintTest {
/**
* 验证同租户且登录态有效时,自身父级循环仍会被业务规则拒绝。
*/
@Test
public void selfParentShouldBeRejectedWithValidTenantContext() {
SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl());
SkillCategoryMapper mapper = mock(SkillCategoryMapper.class);
doReturn(mapper).when(service).getMapper();
SkillCategory category = category(1, 1, 1);
when(mapper.selectTenantTreeForUpdate(BigInteger.ONE)).thenReturn(List.of(category));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
BusinessException exception = assertThrows(
BusinessException.class, () -> service.updateById(category));
assertEquals("父级分类不能是自身", exception.getMessage());
}
}
/**
* 验证等待并发事务锁后使用最新分类树重新检查循环关系。
*/
@Test
public void concurrentMoveShouldUseLockedLatestTreeAndRejectCycle() {
SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl());
SkillCategoryMapper mapper = mock(SkillCategoryMapper.class);
doReturn(mapper).when(service).getMapper();
SkillCategory movedA = category(1, 2, 1);
movedA.setLevelNo(2);
movedA.setAncestors("2");
SkillCategory rootB = category(2, null, 1);
rootB.setAncestors("");
when(mapper.selectTenantTreeForUpdate(BigInteger.ONE)).thenReturn(List.of(movedA, rootB));
SkillCategory moveBUnderA = category(2, 1, 1);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
BusinessException exception = assertThrows(
BusinessException.class, () -> service.updateById(moveBUnderA));
assertEquals("父级分类不能是当前分类的后代", exception.getMessage());
}
}
/**
* 验证其他租户的分类不能被当前租户用作 Skill 分类。
*/
@Test
public void categoryFromAnotherTenantShouldBeTreatedAsMissing() {
SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl());
doReturn(mock(SkillCategoryMapper.class)).when(service).getMapper();
doReturn(null).when(service).getOne(any(QueryWrapper.class));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.validateUsableCategory(BigInteger.valueOf(9)));
assertEquals("Skill 分类不存在", exception.getMessage());
}
}
/**
* Skill 归类写入必须复用分类结构变更的完整租户树排他锁。
*/
@Test
public void skillCategoryAssignmentShouldLockTenantTree() {
SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl());
SkillCategoryMapper mapper = mock(SkillCategoryMapper.class);
doReturn(mapper).when(service).getMapper();
SkillCategory target = category(9, null, 1);
when(mapper.selectTenantTreeForUpdate(BigInteger.ONE)).thenReturn(List.of(target));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
service.lockAndValidateUsableCategory(target.getId());
}
verify(mapper).selectTenantTreeForUpdate(BigInteger.ONE);
}
/**
* 移出分类同样必须锁树,确保分类删除在移动提交后重新检查占用。
*/
@Test
public void movingSkillToUncategorizedShouldStillLockTenantTree() {
SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl());
SkillCategoryMapper mapper = mock(SkillCategoryMapper.class);
doReturn(mapper).when(service).getMapper();
when(mapper.selectTenantTreeForUpdate(BigInteger.ONE)).thenReturn(List.of());
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
service.lockAndValidateUsableCategory(null);
}
verify(mapper).selectTenantTreeForUpdate(BigInteger.ONE);
}
private SkillCategory category(long id, Integer parentId, long tenantId) {
SkillCategory category = new SkillCategory();
category.setId(BigInteger.valueOf(id));
category.setTenantId(BigInteger.valueOf(tenantId));
category.setParentId(parentId == null ? null : BigInteger.valueOf(parentId));
category.setCategoryName("category-" + id);
category.setLevelNo(1);
category.setStatus(1);
return category;
}
private LoginAccount account(long accountId, long tenantId) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(accountId));
account.setTenantId(BigInteger.valueOf(tenantId));
return account;
}
}

View File

@@ -0,0 +1,163 @@
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());
}
}

View File

@@ -0,0 +1,33 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import java.math.BigInteger;
import java.util.Locale;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* {@link SkillResourceServiceImpl} 轻量资源描述查询契约测试。
*/
public class SkillResourceServiceImplProjectionTest {
/**
* 文件树和管理详情查询应保留摘要字段,同时排除正文与内部内容引用。
*/
@Test
public void descriptorQueryExcludesHeavyAndInternalContentColumns() {
SkillResourceServiceImpl service = new SkillResourceServiceImpl();
QueryWrapper query = service.descriptorQuery(BigInteger.ONE, BigInteger.TWO);
String sql = query.toSQL().toLowerCase(Locale.ROOT);
assertTrue(sql.contains("normalized_path"));
assertTrue(sql.contains("content_hash"));
assertTrue(sql.contains("metadata_json"));
assertFalse(sql.contains("text_content"));
assertFalse(sql.contains("content_ref"));
}
}

View File

@@ -0,0 +1,155 @@
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);
}
}

View File

@@ -0,0 +1,154 @@
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;
}
}

View File

@@ -0,0 +1,305 @@
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;
}
}

View File

@@ -0,0 +1,306 @@
package tech.easyflow.skill.store;
import org.junit.Assume;
import org.junit.Test;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
* {@link DBSkillContentStore} 在真实 MySQL REPEATABLE READ 下的同 hash 并发锁序集成测试。
*
* <p>测试仅在 {@code EASYFLOW_MYSQL_CONCURRENCY_TEST=true} 时运行。每次创建独立随机数据库,
* 并在 finally 中删除,避免接触开发库现有表或数据。</p>
*/
public class DBSkillContentStoreMySqlConcurrencyTest {
private static final String CONTENT_REF = "sha256:" + "7".repeat(64);
private static final String CONTENT_HASH = "7".repeat(64);
/**
* 验证竞争者先等待独立 intent 预留,前一写者仍能插入 active 索引并提交;竞争者随后
* 复用 active 内容并原子删除自己的 intent全程不出现内容 gap lock 等待环。
*
* @throws Exception JDBC、并发等待或清理失败
*/
@Test
public void reserveBeforeRetainAvoidsRepeatableReadGapLockCycle() throws Exception {
Assume.assumeTrue("设置 EASYFLOW_MYSQL_CONCURRENCY_TEST=true 后运行真实 MySQL 并发门禁",
Boolean.parseBoolean(System.getenv("EASYFLOW_MYSQL_CONCURRENCY_TEST")));
String schema = "easyflow_skill_lock_" + UUID.randomUUID().toString().replace("-", "");
String rootUrl = environment("EASYFLOW_MYSQL_TEST_ROOT_URL", "jdbc:mysql://127.0.0.1:33306/");
if (!rootUrl.endsWith("/")) {
rootUrl += "/";
}
String options = "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia%2FShanghai";
String user = environment("EASYFLOW_MYSQL_TEST_USER", "root");
String password = environment("EASYFLOW_MYSQL_TEST_PASSWORD", "root");
try (Connection admin = DriverManager.getConnection(rootUrl + "mysql" + options, user, password)) {
execute(admin, "CREATE DATABASE `" + schema + "` CHARACTER SET utf8mb4");
try {
runLockOrderScenario(rootUrl + schema + options, user, password);
} finally {
execute(admin, "DROP DATABASE IF EXISTS `" + schema + "`");
}
}
}
/**
* 在隔离数据库中运行两个连接的 intent 等待与 active 内容提交场景。
*
* @param url 隔离数据库 JDBC URL
* @param user 数据库账号
* @param password 数据库密码
* @throws Exception JDBC 或并发断言失败
*/
private void runLockOrderScenario(String url, String user, String password) throws Exception {
try (Connection setup = DriverManager.getConnection(url, user, password);
Connection writer = DriverManager.getConnection(url, user, password);
Connection contender = DriverManager.getConnection(url, user, password);
Connection observer = DriverManager.getConnection(url, user, password)) {
createTables(setup);
assertRepeatableRead(writer);
assertRepeatableRead(contender);
execute(contender, "SET SESSION innodb_lock_wait_timeout=5");
String writerToken = "writer-token";
String contenderToken = "contender-token";
insertIntent(writer, writerToken);
writer.setAutoCommit(false);
assertEquals(1, update(writer,
"UPDATE tb_skill_content_write_intent SET state='WRITING' "
+ "WHERE content_ref=? AND reservation_token=? AND state='PENDING'",
CONTENT_REF, writerToken));
long contenderConnectionId = connectionId(contender);
CountDownLatch reserveStarted = new CountDownLatch(1);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<Integer> reserve = executor.submit(() -> {
reserveStarted.countDown();
return insertIntent(contender, contenderToken);
});
assertTrue("竞争者 reserve 未启动", reserveStarted.await(2, TimeUnit.SECONDS));
awaitIntentLockWait(observer, contenderConnectionId);
assertEquals(1, insertActive(writer));
assertEquals(1, update(writer,
"DELETE FROM tb_skill_content_write_intent WHERE content_ref=? "
+ "AND reservation_token=? AND EXISTS (SELECT 1 FROM tb_skill_content c "
+ "WHERE c.content_ref=tb_skill_content_write_intent.content_ref "
+ "AND c.ref_count>0)",
CONTENT_REF, writerToken));
writer.commit();
assertEquals("前一写者删除 intent 后竞争者应取得新预留", 1,
(int) reserve.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
contender.setAutoCommit(false);
assertEquals(1, update(contender,
"UPDATE tb_skill_content SET ref_count=ref_count+1 WHERE content_ref=? "
+ "AND size=? AND ref_count>0",
CONTENT_REF, 32L));
assertEquals(1, update(contender,
"DELETE FROM tb_skill_content_write_intent WHERE content_ref=? "
+ "AND reservation_token=? AND EXISTS (SELECT 1 FROM tb_skill_content c "
+ "WHERE c.content_ref=tb_skill_content_write_intent.content_ref "
+ "AND c.ref_count>0)",
CONTENT_REF, contenderToken));
contender.commit();
assertEquals(2, queryInt(setup,
"SELECT ref_count FROM tb_skill_content WHERE content_ref='" + CONTENT_REF + "'"));
assertEquals(0, queryInt(setup, "SELECT COUNT(*) FROM tb_skill_content_write_intent"));
}
}
/**
* 创建与生产锁关键字段一致的最小测试表。
*
* @param connection 测试数据库连接
* @throws SQLException DDL 失败
*/
private void createTables(Connection connection) throws SQLException {
execute(connection, "CREATE TABLE tb_skill_content ("
+ "content_ref VARCHAR(128) NOT NULL PRIMARY KEY,content_hash VARCHAR(128) NOT NULL,"
+ "file_path VARCHAR(2048) NOT NULL,storage_locator VARCHAR(2048) NULL,"
+ "media_type VARCHAR(128) NULL,size BIGINT NOT NULL,ref_count INT NOT NULL,"
+ "created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
+ "modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
+ ") ENGINE=InnoDB");
execute(connection, "CREATE TABLE tb_skill_content_write_intent ("
+ "content_ref VARCHAR(128) NOT NULL PRIMARY KEY,reservation_token VARCHAR(128) NOT NULL,"
+ "content_hash VARCHAR(128) NOT NULL,storage_locator VARCHAR(2048) NOT NULL,"
+ "media_type VARCHAR(128) NULL,size BIGINT NOT NULL,state VARCHAR(16) NOT NULL,"
+ "created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
+ "modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
+ ") ENGINE=InnoDB");
}
/**
* 插入一条 PENDING 写入意图。
*
* @param connection 执行连接
* @param token 预留令牌
* @return 插入行数
* @throws SQLException 插入失败
*/
private int insertIntent(Connection connection, String token) throws SQLException {
return update(connection,
"INSERT INTO tb_skill_content_write_intent(content_ref,reservation_token,content_hash,"
+ "storage_locator,media_type,size,state) VALUES(?,?,?,?,?,?,'PENDING')",
CONTENT_REF, token, CONTENT_HASH, "test-locator", "application/octet-stream", 32L);
}
/**
* 插入首个活动内容索引。
*
* @param connection 写者事务连接
* @return 插入行数
* @throws SQLException 插入失败
*/
private int insertActive(Connection connection) throws SQLException {
return update(connection,
"INSERT INTO tb_skill_content(content_ref,content_hash,file_path,storage_locator,"
+ "media_type,size,ref_count) VALUES(?,?,?,?,?,?,1)",
CONTENT_REF, CONTENT_HASH, "/attachment/test.bin", "test-locator",
"application/octet-stream", 32L);
}
/**
* 等待 performance_schema 确认竞争连接正在等待 intent 行锁。
*
* @param observer 观察连接
* @param connectionId 竞争连接 ID
* @throws Exception 查询失败或两秒内未观察到锁等待
*/
private void awaitIntentLockWait(Connection observer, long connectionId) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (System.nanoTime() < deadline) {
try (PreparedStatement statement = observer.prepareStatement(
"SELECT COUNT(*) FROM performance_schema.data_lock_waits w "
+ "JOIN performance_schema.threads t "
+ "ON t.THREAD_ID=w.REQUESTING_THREAD_ID WHERE t.PROCESSLIST_ID=?")) {
statement.setLong(1, connectionId);
try (ResultSet result = statement.executeQuery()) {
if (result.next() && result.getInt(1) > 0) {
return;
}
}
}
Thread.sleep(20L);
}
throw new AssertionError("未观察到竞争者对 intent 主键的锁等待");
}
/**
* 断言连接使用 MySQL 默认的 REPEATABLE READ 隔离级别。
*
* @param connection 数据库连接
* @throws SQLException 查询失败
*/
private void assertRepeatableRead(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT @@transaction_isolation")) {
assertTrue(result.next());
assertEquals("REPEATABLE-READ", result.getString(1).toUpperCase(Locale.ROOT));
}
}
/**
* 返回当前 JDBC 连接的 MySQL 连接 ID。
*
* @param connection 数据库连接
* @return MySQL 连接 ID
* @throws SQLException 查询失败
*/
private long connectionId(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT CONNECTION_ID()")) {
if (!result.next()) {
throw new SQLException("无法读取 MySQL CONNECTION_ID");
}
return result.getLong(1);
}
}
/**
* 执行无参数 SQL。
*
* @param connection 数据库连接
* @param sql SQL 文本
* @throws SQLException 执行失败
*/
private void execute(Connection connection, String sql) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute(sql);
}
}
/**
* 执行参数化更新。
*
* @param connection 数据库连接
* @param sql SQL 文本
* @param parameters 绑定参数
* @return 影响行数
* @throws SQLException 执行失败
*/
private int update(Connection connection, String sql, Object... parameters) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.length; index++) {
statement.setObject(index + 1, parameters[index]);
}
return statement.executeUpdate();
}
}
/**
* 查询单个整数。
*
* @param connection 数据库连接
* @param sql SQL 文本
* @return 第一列整数
* @throws SQLException 查询失败
*/
private int queryInt(Connection connection, String sql) throws SQLException {
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) {
if (!result.next()) {
throw new SQLException("查询未返回结果");
}
return result.getInt(1);
}
}
/**
* 读取非空环境变量或返回默认值。
*
* @param name 环境变量名
* @param defaultValue 默认值
* @return 配置值
*/
private String environment(String name, String defaultValue) {
String value = System.getenv(name);
return value == null || value.isBlank() ? defaultValue : value.trim();
}
}

View File

@@ -0,0 +1,567 @@
package tech.easyflow.skill.store;
import com.easyagents.skill.store.SkillContentStage;
import com.easyagents.skill.util.SkillHashes;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InOrder;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.SkillContent;
import tech.easyflow.skill.entity.SkillContentWriteIntent;
import tech.easyflow.skill.mapper.SkillContentMapper;
import tech.easyflow.skill.mapper.SkillContentWriteIntentMapper;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Date;
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.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
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 DBSkillContentStore} 事务、恢复意图、引用计数与清理状态机测试。
*/
public class DBSkillContentStoreTest {
private SkillContentMapper contentMapper;
private SkillContentWriteIntentMapper writeIntentMapper;
private FileStorageService fileStorageService;
private PlatformTransactionManager transactionManager;
private DBSkillContentStore contentStore;
/**
* 初始化隔离的存储依赖。
*/
@Before
public void setUp() {
contentMapper = mock(SkillContentMapper.class);
writeIntentMapper = mock(SkillContentWriteIntentMapper.class);
fileStorageService = mock(FileStorageService.class);
transactionManager = mock(PlatformTransactionManager.class);
when(transactionManager.getTransaction(any(TransactionDefinition.class)))
.thenAnswer(invocation -> new SimpleTransactionStatus());
when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of());
when(contentMapper.findStalePending(any(Date.class), anyInt())).thenReturn(List.of());
when(contentMapper.findReleasedBefore(any(Date.class), anyInt())).thenReturn(List.of());
contentStore = new DBSkillContentStore(
contentMapper, writeIntentMapper, fileStorageService, transactionManager);
}
/**
* 清除测试线程上的事务同步状态。
*/
@After
public void tearDown() {
TransactionSynchronizationManager.clear();
}
/**
* 验证新内容先提交恢复意图,再在业务事务中写文件、激活索引并原子删除意图。
*/
@Test
public void putInputStreamCommitsRecoverableWriteIntentAndActiveIndex() {
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);
assertEquals(contentRef, actual);
verify(transactionManager, atLeastOnce()).getTransaction(any(TransactionDefinition.class));
verify(transactionManager, atLeastOnce()).commit(any(TransactionStatus.class));
verify(writeIntentMapper).reserve(eq(contentRef), anyString(), eq(contentHash(contentRef)),
eq(handle.encodeLocator()), eq("application/octet-stream"), eq((long) bytes.length));
verify(writeIntentMapper).claimForWrite(eq(contentRef), anyString());
verify(fileStorageService).saveRecoverable(any(MultipartFile.class), eq(handle));
verify(contentMapper).insertActive(eq(contentRef), eq(contentHash(contentRef)),
eq("/attachment/stream.bin"), eq(handle.encodeLocator()),
eq("application/octet-stream"), eq((long) bytes.length));
verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString());
}
/**
* 验证已完成内容通过 hash 与大小匹配的原子 retain 复用,不重复写物理文件。
*/
@Test
public void putExistingContentRetainsWithoutDuplicateFile() {
byte[] bytes = "same-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentRef = SkillHashes.sha256Ref(bytes);
FileStorageWriteHandle handle = handleFor(contentRef);
when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
when(writeIntentMapper.reserve(
eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()))
.thenReturn(1);
when(contentMapper.retainMatching(contentRef, bytes.length)).thenReturn(1);
when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1);
assertEquals(contentRef, contentStore.put(bytes));
InOrder order = inOrder(writeIntentMapper, contentMapper);
order.verify(writeIntentMapper).reserve(
eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong());
order.verify(contentMapper).retainMatching(contentRef, bytes.length);
order.verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString());
verify(contentMapper).retainMatching(contentRef, bytes.length);
verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
}
/**
* 验证同一内容引用出现不同大小时显式拒绝,不创建恢复意图。
*/
@Test
public void mismatchedExistingContentIsRejectedBeforePhysicalWrite() {
byte[] bytes = "hash-collision-check".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentRef = SkillHashes.sha256Ref(bytes);
FileStorageWriteHandle handle = handleFor(contentRef);
when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
when(writeIntentMapper.reserve(
eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()))
.thenReturn(1);
when(contentMapper.selectForUpdate(contentRef))
.thenReturn(content(contentRef, "/attachment/existing.bin", null, 1, bytes.length + 1));
when(writeIntentMapper.deletePending(eq(contentRef), anyString())).thenReturn(1);
assertThrows(BusinessException.class, () -> contentStore.put(bytes));
verify(writeIntentMapper).reserve(
eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong());
verify(writeIntentMapper).deletePending(eq(contentRef), anyString());
verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
}
/**
* 验证另一节点已持有写入意图时返回可重试冲突,且不会重复上传。
*/
@Test
public void concurrentWriteIntentPreventsDuplicatePhysicalWrite() {
byte[] bytes = "pending-write".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentRef = SkillHashes.sha256Ref(bytes);
FileStorageWriteHandle handle = handleFor(contentRef);
when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
when(writeIntentMapper.reserve(
eq(contentRef), anyString(), anyString(), anyString(), anyString(), anyLong()))
.thenThrow(new DuplicateKeyException("duplicate intent"));
assertThrows(BusinessException.class, () -> contentStore.put(bytes));
verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
verify(contentMapper).retainMatching(contentRef, bytes.length);
}
/**
* 验证旧版零引用索引只有在物理大小与完整哈希重新校验通过后才可恢复。
*/
@Test
public void verifiedLegacyZeroReferenceContentCanBeResurrected() throws Exception {
byte[] bytes = "verified-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentRef = SkillHashes.sha256Ref(bytes);
String filePath = "/legacy/verified.bin";
FileStorageWriteHandle handle = handleFor(contentRef);
SkillContent legacy = content(contentRef, filePath, null, 0, bytes.length);
when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
when(writeIntentMapper.reserve(
eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()))
.thenReturn(1);
when(contentMapper.selectForUpdate(contentRef)).thenReturn(legacy);
when(fileStorageService.getFileSize(filePath)).thenReturn((long) bytes.length);
when(fileStorageService.readStream(filePath)).thenReturn(new ByteArrayInputStream(bytes));
when(contentMapper.resurrectVerifiedLegacy(
contentRef, contentHash(contentRef), filePath, bytes.length)).thenReturn(1);
when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1);
assertEquals(contentRef, contentStore.put(bytes));
verify(contentMapper).resurrectVerifiedLegacy(
contentRef, contentHash(contentRef), filePath, bytes.length);
verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString());
verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
}
/**
* 验证旧版物理内容哈希不一致时拒绝恢复,并立即删除尚未产生物理写入的 PENDING 意图。
*
* @throws Exception 模拟旧文件读取失败
*/
@Test
public void mismatchedLegacyPhysicalContentIsNotResurrected() throws Exception {
byte[] bytes = "expected-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] changed = "tampered-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentRef = SkillHashes.sha256Ref(bytes);
String filePath = "/legacy/tampered.bin";
FileStorageWriteHandle handle = handleFor(contentRef);
SkillContent legacy = content(contentRef, filePath, null, 0, bytes.length);
when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
when(writeIntentMapper.reserve(
eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()))
.thenReturn(1);
when(contentMapper.selectForUpdate(contentRef)).thenReturn(legacy);
when(fileStorageService.getFileSize(filePath)).thenReturn((long) bytes.length);
when(fileStorageService.readStream(filePath)).thenReturn(new ByteArrayInputStream(changed));
when(writeIntentMapper.deletePending(eq(contentRef), anyString())).thenReturn(1);
assertThrows(BusinessException.class, () -> contentStore.put(bytes));
verify(writeIntentMapper).deletePending(eq(contentRef), anyString());
verify(contentMapper, never()).resurrectVerifiedLegacy(
anyString(), anyString(), anyString(), anyLong());
verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
}
/**
* 验证索引激活失败时不删除恢复意图,事务回滚后可由定时任务精确回收物理对象。
*/
@Test
public void failedActiveInsertLeavesRecoverableIntentForCleanup() {
byte[] bytes = "rollback-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentRef = SkillHashes.sha256Ref(bytes);
FileStorageWriteHandle handle = stubNewContentWrite(bytes, "/attachment/rollback.bin");
when(contentMapper.insertActive(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong()))
.thenReturn(0);
assertThrows(BusinessException.class, () -> contentStore.put(bytes));
verify(transactionManager).rollback(any(TransactionStatus.class));
verify(writeIntentMapper, never()).deleteIfActiveExists(eq(contentRef), anyString());
verify(fileStorageService, never()).deleteRecoverable(handle);
SkillContentWriteIntent stale = intent(contentRef, handle, "reservation", "PENDING", bytes.length);
when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale));
when(writeIntentMapper.deleteIfActiveExists(contentRef, "reservation")).thenReturn(0);
when(writeIntentMapper.claimForCleanup(
eq(contentRef), eq("reservation"), eq("PENDING"), any(Date.class))).thenReturn(1);
when(writeIntentMapper.deleteClaimed(contentRef, "reservation")).thenReturn(1);
contentStore.cleanupStaleContent(new Date(), 100);
verify(fileStorageService).deleteRecoverable(handle);
verify(fileStorageService).existsRecoverable(handle);
verify(writeIntentMapper).deleteClaimed(contentRef, "reservation");
}
/**
* 验证正式内容已存在时只删除残留意图,绝不删除正式物理对象。
*/
@Test
public void staleIntentWithActiveContentOnlyRemovesIntent() {
String contentRef = "sha256:" + "a".repeat(64);
FileStorageWriteHandle handle = handleFor(contentRef);
SkillContentWriteIntent stale = intent(contentRef, handle, "active-token", "WRITING", 10);
when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale));
when(writeIntentMapper.deleteIfActiveExists(contentRef, "active-token")).thenReturn(1);
contentStore.cleanupStaleContent(new Date(), 100);
verify(writeIntentMapper, never()).claimForCleanup(
anyString(), anyString(), anyString(), any(Date.class));
verify(fileStorageService, never()).deleteRecoverable(any());
}
/**
* 验证物理删除失败时保留 CLEANING 意图,后续轮次仍可重试。
*/
@Test
public void failedIntentPhysicalDeleteKeepsClaimedIntent() {
String contentRef = "sha256:" + "b".repeat(64);
FileStorageWriteHandle handle = handleFor(contentRef);
SkillContentWriteIntent stale = intent(contentRef, handle, "retry-token", "CLEANING", 10);
when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale));
when(writeIntentMapper.claimForCleanup(
eq(contentRef), eq("retry-token"), eq("CLEANING"), any(Date.class))).thenReturn(1);
doThrow(new RuntimeException("storage unavailable"))
.when(fileStorageService).deleteRecoverable(handle);
contentStore.cleanupStaleContent(new Date(), 100);
verify(writeIntentMapper, never()).deleteClaimed(contentRef, "retry-token");
}
/**
* 验证 PENDING 与零引用记录不会被读取、判断存在或重新持有。
*/
@Test
public void pendingAndZeroReferenceContentAreInvisible() throws Exception {
String contentRef = "sha256:" + "c".repeat(64);
when(contentMapper.countVisible(contentRef)).thenReturn(0);
when(contentMapper.selectOneById(contentRef))
.thenReturn(content(contentRef, "__PENDING__:reservation", null, 0, 12));
when(contentMapper.retain(contentRef)).thenReturn(0);
assertFalse(contentStore.exists(contentRef));
assertThrows(BusinessException.class, () -> contentStore.open(contentRef));
assertThrows(BusinessException.class, () -> contentStore.retain(contentRef));
verify(fileStorageService, never()).readStream(anyString());
}
/**
* 验证最后一份可恢复内容只在事务提交后删除物理对象与零引用索引。
*/
@Test
public void lastReleasePurgesRecoverableObjectOnlyAfterCommit() {
String contentRef = "sha256:" + "d".repeat(64);
String filePath = "/attachment/final.bin";
FileStorageWriteHandle handle = handleFor(contentRef);
SkillContent content = content(contentRef, filePath, handle.encodeLocator(), 1, 10);
when(contentMapper.releaseShared(contentRef)).thenReturn(0);
when(contentMapper.selectForUpdate(contentRef)).thenReturn(content);
when(contentMapper.markReleased(contentRef, filePath, handle.encodeLocator())).thenReturn(1);
TransactionSynchronizationManager.initSynchronization();
contentStore.release(contentRef);
List<TransactionSynchronization> synchronizations = currentSynchronizations();
verify(fileStorageService, never()).deleteRecoverable(handle);
synchronizations.forEach(TransactionSynchronization::afterCommit);
verify(fileStorageService).deleteRecoverable(handle);
verify(fileStorageService).existsRecoverable(handle);
verify(contentMapper).deleteReleased(contentRef, filePath, handle.encodeLocator());
}
/**
* 验证事务回滚不会触发最后引用的物理删除。
*/
@Test
public void lastReleaseRollbackNeverPurgesPhysicalObject() {
String contentRef = "sha256:" + "e".repeat(64);
FileStorageWriteHandle handle = handleFor(contentRef);
SkillContent content = content(contentRef, "/attachment/rollback.bin", handle.encodeLocator(), 1, 10);
when(contentMapper.releaseShared(contentRef)).thenReturn(0);
when(contentMapper.selectForUpdate(contentRef)).thenReturn(content);
when(contentMapper.markReleased(
contentRef, content.getFilePath(), handle.encodeLocator())).thenReturn(1);
TransactionSynchronizationManager.initSynchronization();
contentStore.release(contentRef);
currentSynchronizations().forEach(synchronization ->
synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
verify(fileStorageService, never()).deleteRecoverable(any());
verify(contentMapper, never()).deleteReleased(anyString(), anyString(), anyString());
}
/**
* 验证缺少 locator 的旧内容释放后保留零引用索引,不执行无法证明正确的 URL 删除。
*/
@Test
public void legacyReleaseWithoutLocatorKeepsTrackedIndex() {
String contentRef = "sha256:" + "f".repeat(64);
SkillContent content = content(contentRef, "/legacy/random.bin", null, 1, 10);
when(contentMapper.releaseShared(contentRef)).thenReturn(0);
when(contentMapper.selectForUpdate(contentRef)).thenReturn(content);
when(contentMapper.markReleased(contentRef, content.getFilePath(), null)).thenReturn(1);
TransactionSynchronizationManager.initSynchronization();
contentStore.release(contentRef);
assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
verify(fileStorageService, never()).delete(anyString());
verify(fileStorageService, never()).deleteRecoverable(any());
verify(contentMapper, never()).deleteReleased(anyString(), anyString(), anyString());
}
/**
* 验证提交后删除失败会保留零引用索引,并由定时清理再次尝试。
*/
@Test
public void failedAfterCommitDeleteIsRetriedByCleanup() {
String contentRef = "sha256:" + "1".repeat(64);
FileStorageWriteHandle handle = handleFor(contentRef);
SkillContent content = content(
contentRef, "/attachment/retry.bin", handle.encodeLocator(), 1, 10);
when(contentMapper.releaseShared(contentRef)).thenReturn(0);
when(contentMapper.selectForUpdate(contentRef)).thenReturn(content);
when(contentMapper.markReleased(
contentRef, content.getFilePath(), handle.encodeLocator())).thenReturn(1);
doThrow(new RuntimeException("storage unavailable"))
.doNothing()
.when(fileStorageService).deleteRecoverable(handle);
TransactionSynchronizationManager.initSynchronization();
contentStore.release(contentRef);
currentSynchronizations().forEach(TransactionSynchronization::afterCommit);
verify(contentMapper, never()).deleteReleased(
contentRef, content.getFilePath(), handle.encodeLocator());
content.setRefCount(0);
when(contentMapper.findReleasedBefore(any(Date.class), anyInt())).thenReturn(List.of(content));
contentStore.cleanupStaleContent(new Date(), 100);
verify(fileStorageService, times(2)).deleteRecoverable(handle);
verify(contentMapper).deleteReleased(contentRef, content.getFilePath(), handle.encodeLocator());
}
/**
* 验证旧版超时 PENDING 占位仍通过条件删除安全回收。
*/
@Test
public void staleLegacyPendingReservationIsCleaned() {
String contentRef = "sha256:" + "2".repeat(64);
String pendingPath = "__PENDING__:stale";
SkillContent pending = content(contentRef, pendingPath, null, 0, 10);
when(contentMapper.findStalePending(any(Date.class), anyInt())).thenReturn(List.of(pending));
contentStore.cleanupStaleContent(new Date(), 100);
verify(contentMapper).deleteStalePending(eq(contentRef), eq(pendingPath), any(Date.class));
}
/**
* 验证提交前会重新校验暂存内容,阻止内容被替换后写入错误 hash。
*
* @throws Exception 文件操作失败
*/
@Test
public void changedStageIsRejectedBeforeCommit() throws Exception {
byte[] original = "original".getBytes(java.nio.charset.StandardCharsets.UTF_8);
SkillContentStage stage = contentStore.stage(new ByteArrayInputStream(original), original.length);
Path stagePath = Path.of(stage.getStageId());
Files.writeString(stagePath, "changed!");
assertThrows(BusinessException.class, () -> contentStore.commit(stage));
assertFalse(Files.exists(stagePath));
verify(contentMapper, never()).retainMatching(anyString(), anyLong());
verify(writeIntentMapper, never()).reserve(
anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
}
/**
* 配置一次成功的新内容意图、物理写入与索引激活。
*
* @param bytes 模拟内容
* @param fileUrl 模拟读取 URL
* @return 确定性恢复句柄
*/
private FileStorageWriteHandle stubNewContentWrite(byte[] bytes, String fileUrl) {
String contentRef = SkillHashes.sha256Ref(bytes);
FileStorageWriteHandle handle = handleFor(contentRef);
when(fileStorageService.prepareRecoverableWrite(
"skill-content/" + contentHash(contentRef).substring(0, 2),
contentHash(contentRef) + ".bin")).thenReturn(handle);
when(writeIntentMapper.reserve(
eq(contentRef), anyString(), eq(contentHash(contentRef)), eq(handle.encodeLocator()),
anyString(), eq((long) bytes.length))).thenReturn(1);
when(writeIntentMapper.claimForWrite(eq(contentRef), anyString())).thenReturn(1);
when(fileStorageService.saveRecoverable(any(MultipartFile.class), eq(handle)))
.thenReturn(new FileStorageWriteResult(fileUrl, handle.encodeLocator()));
when(contentMapper.insertActive(
eq(contentRef), eq(contentHash(contentRef)), eq(fileUrl), eq(handle.encodeLocator()),
anyString(), eq((long) bytes.length))).thenReturn(1);
when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1);
return handle;
}
/**
* 为内容引用创建测试用确定性本地恢复句柄。
*
* @param contentRef 内容引用
* @return 恢复句柄
*/
private FileStorageWriteHandle handleFor(String contentRef) {
String hash = contentHash(contentRef);
return new FileStorageWriteHandle(
"local", "", "/tmp/easyflow-content-test",
"skill-content/" + hash.substring(0, 2), hash + ".bin");
}
/**
* 创建指定状态的内容索引测试对象。
*
* @param contentRef 内容引用
* @param filePath 文件或占位路径
* @param locator 稳定恢复定位符
* @param refCount 引用数
* @param size 内容大小
* @return 内容索引
*/
private SkillContent content(String contentRef, String filePath, String locator, int refCount, long size) {
SkillContent content = new SkillContent();
content.setContentRef(contentRef);
content.setContentHash(contentHash(contentRef));
content.setFilePath(filePath);
content.setStorageLocator(locator);
content.setMediaType("application/octet-stream");
content.setSize(size);
content.setRefCount(refCount);
content.setCreated(new Date());
content.setModified(new Date());
return content;
}
/**
* 创建指定状态的写入意图测试对象。
*
* @param contentRef 内容引用
* @param handle 恢复句柄
* @param token 预留令牌
* @param state 意图状态
* @param size 内容大小
* @return 写入意图
*/
private SkillContentWriteIntent intent(
String contentRef, FileStorageWriteHandle handle, String token, String state, long size) {
SkillContentWriteIntent intent = new SkillContentWriteIntent();
intent.setContentRef(contentRef);
intent.setReservationToken(token);
intent.setContentHash(contentHash(contentRef));
intent.setStorageLocator(handle.encodeLocator());
intent.setMediaType("application/octet-stream");
intent.setSize(size);
intent.setState(state);
intent.setCreated(new Date(0));
intent.setModified(new Date(0));
return intent;
}
/**
* 从标准内容引用取得十六进制哈希。
*
* @param contentRef 内容引用
* @return 十六进制哈希
*/
private String contentHash(String contentRef) {
return contentRef.substring("sha256:".length());
}
/**
* 获取当前测试事务已注册的同步回调。
*
* @return 同步回调副本
*/
private List<TransactionSynchronization> currentSynchronizations() {
List<TransactionSynchronization> synchronizations =
new ArrayList<>(TransactionSynchronizationManager.getSynchronizations());
assertTrue("应注册事务同步回调", !synchronizations.isEmpty());
return synchronizations;
}
}