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