feat: 完善 Skill 管理与发布治理
- 实现标准资源存储、能力绑定及双格式导入导出 - 接入分类、可见范围、审批发布与资源权限校验 - 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user