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,38 @@
package tech.easyflow.admin.controller.skill;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.skill.service.SkillCategoryService;
import static org.mockito.Mockito.mock;
/**
* {@link SkillCategoryController} 查询参数安全契约测试。
*/
public class SkillCategoryControllerContractTest {
/**
* 分类排序只接受固定字段和方向,恶意片段应回退到默认排序。
*/
@Test
public void categorySortUsesStrictAllowlist() {
SkillCategoryController controller = new SkillCategoryController(mock(SkillCategoryService.class));
Assert.assertEquals(controller.resolveOrderBy("categoryName", "desc"),
"category_name desc, id asc");
Assert.assertEquals(controller.resolveOrderBy("sort_no desc; drop table tb_skill", null),
"sort_no asc, id asc");
Assert.assertEquals(controller.resolveOrderBy("id", "unexpected"), "id asc");
}
/**
* 分类控制器不得继承未加租户范围的通用 list、page 和 detail 入口。
*/
@Test
public void categoryControllerDoesNotExposeInheritedCrudQueries() {
Assert.expectThrows(NoSuchMethodException.class,
() -> SkillCategoryController.class.getMethod("detail", String.class));
Assert.assertFalse(java.util.Arrays.stream(SkillCategoryController.class.getMethods())
.anyMatch(method -> "list".equals(method.getName()) || "page".equals(method.getName())));
}
}

View File

@@ -0,0 +1,288 @@
package tech.easyflow.admin.controller.skill;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import org.testng.Assert;
import org.testng.annotations.Test;
import org.mockito.MockedStatic;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.controller.skill.vo.SkillCapabilityBindingRequest;
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
import tech.easyflow.admin.controller.skill.vo.SkillView;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.jsonbody.JsonBodyParser;
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.file.SkillFileService;
import tech.easyflow.skill.imports.SkillExportService;
import tech.easyflow.skill.imports.SkillImportFormat;
import tech.easyflow.skill.imports.SkillImportService;
import tech.easyflow.skill.publish.SkillPublishAppService;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.service.SkillApprovalStateService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.math.BigInteger;
import java.util.List;
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 SkillController} 写入 DTO 与返回视图静态契约测试。
*/
public class SkillControllerContractTest {
/**
* 验证当前 Fastjson 与 JsonBody 解析链路支持 Skill 草稿 record。
*
* @throws Exception DTO 反序列化失败
*/
@Test
public void jsonBodyParserDeserializesSkillDraftRecord() throws Exception {
JSONObject json = JSON.parseObject("""
{
"id": 101,
"categoryId": 9,
"displayName": "演示 Skill",
"skillContent": "---\\nname: demo-skill\\ndescription: Demo\\n---\\n# Demo\\n",
"enabled": true,
"visibilityScope": "PRIVATE"
}
""");
SkillDraftRequest request = (SkillDraftRequest) JsonBodyParser.parseJsonBody(
json, SkillDraftRequest.class, SkillDraftRequest.class, "");
Assert.assertEquals(request.id(), BigInteger.valueOf(101));
Assert.assertEquals(request.categoryId(), BigInteger.valueOf(9));
Assert.assertEquals(request.displayName(), "演示 Skill");
Assert.assertTrue(request.enabled());
Assert.assertEquals(request.visibilityScope(), "PRIVATE");
}
/**
* 验证当前 Fastjson 与 JsonBody 解析链路支持含集合和映射的能力绑定 record。
*
* @throws Exception DTO 反序列化失败
*/
@Test
public void jsonBodyParserDeserializesCapabilityBindingRecord() throws Exception {
JSONObject json = JSON.parseObject("""
{
"capabilityType": "MCP",
"targetId": 77,
"targetLogicalRef": "mcp://demo",
"runtimeName": "demo_mcp",
"enabled": true,
"selectionMode": "SELECTED",
"selectedToolNamesJson": ["search", "fetch"],
"executionMode": "SYNC",
"hitlEnabled": true,
"hitlConfigJson": {"prompt": "确认执行"},
"optionsJson": {"timeoutMs": 3000},
"sortNo": 2
}
""");
SkillCapabilityBindingRequest request = (SkillCapabilityBindingRequest) JsonBodyParser.parseJsonBody(
json, SkillCapabilityBindingRequest.class, SkillCapabilityBindingRequest.class, "");
Assert.assertEquals(request.capabilityType(), "MCP");
Assert.assertEquals(request.targetId(), BigInteger.valueOf(77));
Assert.assertEquals(request.selectedToolNamesJson(), List.of("search", "fetch"));
Assert.assertEquals(request.hitlConfigJson().get("prompt"), "确认执行");
Assert.assertEquals(((Number) request.optionsJson().get("timeoutMs")).intValue(), 3000);
}
/**
* 验证草稿写入口使用 JsonBody 白名单 DTO并返回 SkillView。
*
* @throws Exception 控制器方法反射失败
*/
@Test
public void saveEndpointUsesDraftRequestAndSkillView() throws Exception {
Method method = SkillController.class.getMethod("save", SkillDraftRequest.class);
JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class);
ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType();
Assert.assertNotNull(jsonBody);
Assert.assertEquals(returnType.getRawType(), Result.class);
Assert.assertEquals(returnType.getActualTypeArguments()[0], SkillView.class);
}
/**
* 验证详情视图按 MANAGE 权限隐藏或保留当前环境目标 ID。
*/
@Test
public void detailViewProjectsCapabilityTargetIdByManagePermission() {
SkillCapabilityBinding binding = binding(BigInteger.valueOf(77));
Skill skill = new Skill();
skill.setCapabilityBindings(List.of(binding));
SkillView readOnly = SkillView.from(skill, true, false);
SkillView manageable = SkillView.from(skill, true, true);
Assert.assertNull(readOnly.bindings().get(0).targetId());
Assert.assertEquals(manageable.bindings().get(0).targetId(), BigInteger.valueOf(77));
}
/**
* 验证能力替换响应沿用可编辑投影并保留目标 ID。
*/
@Test
public void replaceResponseProjectionKeepsEditableTargetId() {
SkillView.CapabilityView view = SkillView.CapabilityView.from(binding(BigInteger.valueOf(88)));
Assert.assertEquals(view.targetId(), BigInteger.valueOf(88));
}
/**
* 验证能力列表端点只调用按权限脱敏的读取方法。
*/
@Test
public void capabilityListEndpointUsesPermissionAwareBindingRead() {
BigInteger skillId = BigInteger.valueOf(101);
SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class);
SkillCapabilityBinding redacted = binding(null);
when(bindingService.listVisibleBindings(skillId)).thenReturn(List.of(redacted));
SkillController controller = controller(bindingService);
Result<List<SkillView.CapabilityView>> result = controller.capabilityList(skillId);
Assert.assertNull(result.getData().get(0).targetId());
verify(bindingService).listVisibleBindings(skillId);
verify(bindingService, never()).listBindings(skillId);
}
/**
* 验证复制需要新建和能力绑定双重操作权限。
*
* @throws Exception 控制器方法反射失败
*/
@Test
public void copyEndpointDeclaresIndependentOperationPermissions() throws Exception {
SaCheckPermission copyPermission = SkillController.class
.getMethod("copy", SkillCopyRequest.class).getAnnotation(SaCheckPermission.class);
Assert.assertEquals(copyPermission.value(),
new String[]{"/api/v1/skill/save", "/api/v1/skill/capability"});
}
/**
* 验证正式删除审批入口使用真实操作权限,不引用历史死权限。
*
* @throws Exception 控制器方法反射失败
*/
@Test
public void deleteEndpointUsesCanonicalDeletePermission() throws Exception {
SaCheckPermission submitPermission = SkillController.class
.getMethod("submitDeleteApproval", BigInteger.class).getAnnotation(SaCheckPermission.class);
Assert.assertEquals(submitPermission.value(),
new String[]{"/api/v1/skill/submitDeleteApproval"});
Assert.assertFalse(java.util.Arrays.stream(SkillController.class.getDeclaredMethods())
.map(method -> method.getAnnotation(SaCheckPermission.class))
.filter(java.util.Objects::nonNull)
.flatMap(permission -> java.util.Arrays.stream(permission.value()))
.anyMatch("/api/v1/skill/remove"::equals));
}
/**
* 验证发布预检复用发布权限,并明确调用发布级校验。
*
* @throws Exception 控制器方法反射失败
*/
@Test
public void publishValidationUsesPublishPermissionAndFullValidation() throws Exception {
BigInteger skillId = BigInteger.valueOf(101);
SkillService skillService = mock(SkillService.class);
SkillValidationResult validation = new SkillValidationResult();
validation.setValid(true);
when(skillService.validateSkill(skillId, true)).thenReturn(validation);
SkillController controller = controller(skillService, mock(SkillCapabilityBindingService.class));
Result<SkillValidationResult> result = controller.validatePublish(skillId);
SaCheckPermission permission = SkillController.class
.getMethod("validatePublish", BigInteger.class)
.getAnnotation(SaCheckPermission.class);
Assert.assertSame(result.getData(), validation);
Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/submitPublishApproval"});
verify(skillService).validateSkill(skillId, true);
}
/**
* 验证标准导出不追加能力权限EasyFlow 增强导出必须检查能力绑定查看权限。
*/
@Test
public void enhancedExportRequiresCapabilityPermission() {
SkillController controller = controller(mock(SkillCapabilityBindingService.class));
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
controller.assertEnhancedExportPermission(SkillImportFormat.STANDARD);
stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), never());
controller.assertEnhancedExportPermission(SkillImportFormat.EASYFLOW);
stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), times(1));
}
}
/**
* 创建测试能力绑定。
*
* @param targetId 目标 ID
* @return 能力绑定
*/
private SkillCapabilityBinding binding(BigInteger targetId) {
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setId(BigInteger.ONE);
binding.setCapabilityType("MCP");
binding.setTargetId(targetId);
binding.setTargetLogicalRef("mcp:demo");
binding.setRuntimeName("demo_mcp");
binding.setEnabled(true);
binding.setSelectionMode("ALL");
binding.setHitlEnabled(false);
return binding;
}
/**
* 创建只注入能力服务的控制器测试实例。
*
* @param bindingService 能力绑定服务
* @return 控制器实例
*/
private SkillController controller(SkillCapabilityBindingService bindingService) {
return controller(mock(SkillService.class), bindingService);
}
/**
* 创建注入指定 Skill 与能力服务的控制器测试实例。
*
* @param skillService Skill 服务
* @param bindingService 能力绑定服务
* @return 控制器实例
*/
private SkillController controller(SkillService skillService, SkillCapabilityBindingService bindingService) {
return new SkillController(skillService, mock(SkillApprovalStateService.class),
mock(SkillPublishAppService.class), mock(SkillImportService.class), mock(SkillExportService.class),
mock(SkillFileService.class), bindingService, mock(ResourceAccessService.class),
mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class),
mock(AiResourceCreatorNameSupport.class));
}
}

View File

@@ -0,0 +1,51 @@
package tech.easyflow.admin.controller.skill;
import com.mybatisflex.core.query.QueryWrapper;
import org.testng.Assert;
import org.testng.annotations.Test;
import java.lang.reflect.Method;
import java.util.Locale;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
/**
* Skill 列表轻量投影的权限字段回归测试。
*/
public class SkillControllerProjectionTenantTest {
/**
* 验证列表投影包含内部 tenant_id以便资源权限派生时不会将合法记录误判为不可读。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void descriptorProjectionShouldIncludeTenantId() throws Exception {
SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS);
Method method = SkillController.class.getDeclaredMethod("descriptorQuery");
method.setAccessible(true);
QueryWrapper query = (QueryWrapper) method.invoke(controller);
Assert.assertTrue(query.toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"),
"Skill descriptor projection 缺少 tenant_id: " + query.toSQL());
}
/**
* 验证列表投影不会加载正文或发布快照等重字段。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void descriptorProjectionShouldExcludeHeavyContent() throws Exception {
SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS);
Method method = SkillController.class.getDeclaredMethod("descriptorQuery");
method.setAccessible(true);
String sql = ((QueryWrapper) method.invoke(controller)).toSQL().toLowerCase(Locale.ROOT);
Assert.assertFalse(sql.contains("skill_content"), "列表投影不应加载 SKILL.md 正文: " + sql);
Assert.assertFalse(sql.contains("published_snapshot_json"), "列表投影不应加载发布快照: " + sql);
}
}