|
|
@@ -0,0 +1,301 @@
|
|
|
+package com.zsjz.ai.module.knowledge.service;
|
|
|
+
|
|
|
+import com.zsjz.ai.common.exception.ServerException;
|
|
|
+import com.zsjz.ai.common.utils.LocalFileStorageUtil;
|
|
|
+import com.zsjz.ai.module.agent.service.AgentService;
|
|
|
+import com.zsjz.ai.module.knowledge.KnowledgeDocIndexer;
|
|
|
+import com.zsjz.ai.module.knowledge.KnowledgeProperties;
|
|
|
+import com.zsjz.ai.module.knowledge.dto.SaveKnowledgeBaseDTO;
|
|
|
+import com.zsjz.ai.module.knowledge.entity.KnowledgeBase;
|
|
|
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDoc;
|
|
|
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDocDownloadLog;
|
|
|
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper;
|
|
|
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocDownloadLogMapper;
|
|
|
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocMapper;
|
|
|
+import com.zsjz.ai.module.knowledge.vo.KnowledgeBaseVO;
|
|
|
+import com.zsjz.ai.module.knowledge.vo.KnowledgeDocVO;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.io.ByteArrayInputStream;
|
|
|
+import java.io.Serializable;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.doAnswer;
|
|
|
+import static org.mockito.Mockito.doReturn;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.spy;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 知识库/文档两层隔离规则:内置只读 / 归属校验 / 文档按上传者隔离 / 下载审计 / 删除级联。
|
|
|
+ *
|
|
|
+ * <p>DAO 层(getById/save/update/remove)与查询缝(loadAllBases / list(Wrapper))全部 stub,
|
|
|
+ * 本测试只验证业务规则本身,不依赖数据库。
|
|
|
+ */
|
|
|
+class KnowledgeIsolationTest {
|
|
|
+
|
|
|
+ private static final Long ME = 7L;
|
|
|
+ private static final Long OTHER = 99L;
|
|
|
+ private static final Long ADMIN = KnowledgeBaseService.ADMIN_USER_ID;
|
|
|
+
|
|
|
+ private KnowledgeBaseService kbService;
|
|
|
+ private KnowledgeDocService docService;
|
|
|
+
|
|
|
+ private KnowledgeDocMapper docMapper;
|
|
|
+ private KnowledgeDocDownloadLogMapper downloadLogMapper;
|
|
|
+ private KnowledgeDocIndexer docIndexer;
|
|
|
+ private LocalFileStorageUtil storageUtil;
|
|
|
+ private AgentService agentService;
|
|
|
+
|
|
|
+ /** getById 的返回目标(模拟库里那行) */
|
|
|
+ private KnowledgeBase kbById;
|
|
|
+ private KnowledgeDoc docById;
|
|
|
+
|
|
|
+ /** save/updateById 捕获的实体 */
|
|
|
+ private KnowledgeBase capturedKb;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ docMapper = mock(KnowledgeDocMapper.class);
|
|
|
+ downloadLogMapper = mock(KnowledgeDocDownloadLogMapper.class);
|
|
|
+ docIndexer = mock(KnowledgeDocIndexer.class);
|
|
|
+ storageUtil = mock(LocalFileStorageUtil.class);
|
|
|
+ agentService = mock(AgentService.class);
|
|
|
+
|
|
|
+ kbService = spy(new KnowledgeBaseService(
|
|
|
+ docMapper, downloadLogMapper, docIndexer, storageUtil,
|
|
|
+ new KnowledgeProperties(), agentService));
|
|
|
+
|
|
|
+ KnowledgeBaseService kbMock = mock(KnowledgeBaseService.class);
|
|
|
+ docService = spy(new KnowledgeDocService(
|
|
|
+ kbMock, downloadLogMapper, docIndexer, mock(com.zsjz.ai.module.knowledge.KnowledgeDocParser.class),
|
|
|
+ storageUtil, new KnowledgeProperties(),
|
|
|
+ mock(org.springframework.context.ApplicationEventPublisher.class)));
|
|
|
+ // requireVisible 走真实逻辑(校验规则本身是被测对象),把内部 getById 打到 kbById
|
|
|
+ ReflectionTestUtils.setField(docService, "kbService", kbService);
|
|
|
+
|
|
|
+ // DAO 缝
|
|
|
+ doAnswer(inv -> kbById).when(kbService).getById(any());
|
|
|
+ doAnswer(inv -> docById).when(docService).getById(any());
|
|
|
+ doAnswer(inv -> {
|
|
|
+ capturedKb = inv.getArgument(0);
|
|
|
+ capturedKb.setId(88L); // 模拟 DB 回填雪花主键
|
|
|
+ return true;
|
|
|
+ }).when(kbService).save(any(KnowledgeBase.class));
|
|
|
+ doReturn(true).when(kbService).updateById(any(KnowledgeBase.class));
|
|
|
+ doReturn(true).when(kbService).removeById(any(Serializable.class));
|
|
|
+ doReturn(true).when(docService).removeById(any(Serializable.class));
|
|
|
+ doReturn(true).when(docService).updateById(any(KnowledgeDoc.class));
|
|
|
+ doReturn(true).when(docService).update(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static KnowledgeBase kb(Long id, String name, Long userId, int builtin) {
|
|
|
+ KnowledgeBase e = new KnowledgeBase();
|
|
|
+ e.setId(id);
|
|
|
+ e.setName(name);
|
|
|
+ e.setUserId(userId);
|
|
|
+ e.setIsBuiltin(builtin);
|
|
|
+ return e;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static KnowledgeDoc doc(Long id, Long kbId, Long userId, String fileName) {
|
|
|
+ KnowledgeDoc d = new KnowledgeDoc();
|
|
|
+ d.setId(id);
|
|
|
+ d.setKbId(kbId);
|
|
|
+ d.setUserId(userId);
|
|
|
+ d.setFileName(fileName);
|
|
|
+ d.setFilePath("knowledge/" + kbId + "/doc/" + id + ".pdf");
|
|
|
+ d.setIndexStatus("READY");
|
|
|
+ d.setChunkCount(3);
|
|
|
+ d.setDownloadCount(0);
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 知识库列表隔离 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("列表 = 内置(全局)+ 自己的;别人的不可见(后端过滤,不是前端隐藏)")
|
|
|
+ void listFiltersToBuiltinAndMine() {
|
|
|
+ KnowledgeBase builtin = kb(1L, "系统通用知识库", ADMIN, 1);
|
|
|
+ KnowledgeBase mine = kb(2L, "我的库", ME, 0);
|
|
|
+ KnowledgeBase others = kb(3L, "别人的库", OTHER, 0);
|
|
|
+ doReturn(List.of(builtin, others, mine)).when(kbService).loadAllBases();
|
|
|
+ doReturn(List.of()).when(docMapper).countByUserGroupByKb(any());
|
|
|
+ doReturn(List.of()).when(docMapper).countPendingGroupByKb(any());
|
|
|
+
|
|
|
+ List<KnowledgeBaseVO> list = kbService.list(ME);
|
|
|
+
|
|
|
+ assertEquals(2, list.size(), "内置 + 我的,别人的必须被过滤");
|
|
|
+ assertEquals("1", list.get(0).getId());
|
|
|
+ assertTrue(list.get(0).isBuiltin());
|
|
|
+ assertFalse(list.get(0).isMine());
|
|
|
+ assertEquals("2", list.get(1).getId());
|
|
|
+ assertFalse(list.get(1).isBuiltin());
|
|
|
+ assertTrue(list.get(1).isMine());
|
|
|
+ assertTrue(list.stream().noneMatch(v -> "别人的库".equals(v.getName())));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 内置不可改删 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("内置知识库:编辑与删除都被拒绝(400),且不触碰 DAO")
|
|
|
+ void builtinRejectsEditAndDelete() {
|
|
|
+ kbById = kb(1L, "系统通用知识库", ADMIN, 1);
|
|
|
+ SaveKnowledgeBaseDTO dto = new SaveKnowledgeBaseDTO();
|
|
|
+ dto.setName("改名");
|
|
|
+
|
|
|
+ ServerException edit = assertThrows(ServerException.class,
|
|
|
+ () -> kbService.update(ME, 1L, dto));
|
|
|
+ assertEquals(400, edit.getCode());
|
|
|
+ assertTrue(edit.getMsg().contains("内置知识库不可编辑"), edit.getMsg());
|
|
|
+
|
|
|
+ ServerException del = assertThrows(ServerException.class,
|
|
|
+ () -> kbService.delete(ME, 1L));
|
|
|
+ assertEquals(400, del.getCode());
|
|
|
+ assertTrue(del.getMsg().contains("内置知识库不可删除"), del.getMsg());
|
|
|
+ verify(kbService, never()).removeById(any(Serializable.class));
|
|
|
+ verify(agentService, never()).detachKnowledgeBase(anyLong());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("自建知识库:不能改删别人的(无权操作)")
|
|
|
+ void cannotTouchOthersKb() {
|
|
|
+ kbById = kb(3L, "别人的库", OTHER, 0);
|
|
|
+ SaveKnowledgeBaseDTO dto = new SaveKnowledgeBaseDTO();
|
|
|
+ dto.setName("x");
|
|
|
+
|
|
|
+ ServerException ex = assertThrows(ServerException.class, () -> kbService.update(ME, 3L, dto));
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+ assertTrue(ex.getMsg().contains("无权"), ex.getMsg());
|
|
|
+ assertThrows(ServerException.class, () -> kbService.delete(ME, 3L));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 上传权限(内置库仅管理员可维护) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("requireUploadable:内置库仅管理员(userId=1)可上传;自建库库主人可传")
|
|
|
+ void uploadPermissionOnBuiltinKb() {
|
|
|
+ KnowledgeBase builtin = kb(1L, "系统通用知识库", ADMIN, 1);
|
|
|
+
|
|
|
+ ServerException ex = assertThrows(ServerException.class, () -> kbService.requireUploadable(ME, builtin));
|
|
|
+ assertTrue(ex.getMsg().contains("仅管理员"), ex.getMsg());
|
|
|
+ kbService.requireUploadable(ADMIN, builtin); // 不抛即通过
|
|
|
+
|
|
|
+ KnowledgeBase mine = kb(2L, "我的库", ME, 0);
|
|
|
+ kbService.requireUploadable(ME, mine); // 不抛即通过
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 可见性(绑定校验复用) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("requireVisible:内置谁都能看;别人的自建库 400;不存在 404")
|
|
|
+ void requireVisibleRules() {
|
|
|
+ kbById = kb(1L, "系统通用知识库", ADMIN, 1);
|
|
|
+ assertEquals(1L, kbService.requireVisible(ME, 1L).getId());
|
|
|
+
|
|
|
+ kbById = kb(3L, "别人的库", OTHER, 0);
|
|
|
+ ServerException ex = assertThrows(ServerException.class, () -> kbService.requireVisible(ME, 3L));
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+
|
|
|
+ kbById = null;
|
|
|
+ assertThrows(ServerException.class, () -> kbService.requireVisible(ME, 404L));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 删除级联 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("删除自建知识库:文件/向量/下载记录/文档行级联清理 + 解绑引用它的方案")
|
|
|
+ void deleteCascades() {
|
|
|
+ kbById = kb(2L, "我的库", ME, 0);
|
|
|
+ KnowledgeDoc d1 = doc(11L, 2L, ME, "a.pdf");
|
|
|
+ KnowledgeDoc d2 = doc(12L, 2L, OTHER, "b.pdf"); // 同库别人的文档也要一并清
|
|
|
+ doReturn(List.of(d1, d2)).when(docMapper)
|
|
|
+ .selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
|
|
+
|
|
|
+ kbService.delete(ME, 2L);
|
|
|
+
|
|
|
+ verify(storageUtil).delete(d1.getFilePath());
|
|
|
+ verify(storageUtil).delete(d2.getFilePath());
|
|
|
+ verify(docIndexer).deleteKbVectors(2L);
|
|
|
+ verify(downloadLogMapper).deleteByKbId(2L);
|
|
|
+ verify(docMapper).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
|
|
+ verify(kbService).removeById(2L);
|
|
|
+ verify(agentService).detachKnowledgeBase(2L);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 文档层隔离(只对上传者开放) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("文档列表:只返回 user_id=当前用户 的文档(库可见性先校验)")
|
|
|
+ void listMineOnlyOwnDocs() {
|
|
|
+ kbById = kb(1L, "系统通用知识库", ADMIN, 1);
|
|
|
+ KnowledgeDoc mine = doc(11L, 1L, ME, "a.pdf");
|
|
|
+ doReturn(List.of(mine)).when(docService).loadMineDocs(ME, 1L);
|
|
|
+
|
|
|
+ List<KnowledgeDocVO> list = docService.listMine(ME, 1L);
|
|
|
+
|
|
|
+ assertEquals(1, list.size());
|
|
|
+ assertEquals("a.pdf", list.get(0).getFileName());
|
|
|
+ verify(kbService).requireVisible(ME, 1L);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("下载他人上传的文档被拒绝;下载自己的写审计 + 计数自增")
|
|
|
+ void downloadOwnOnly() {
|
|
|
+ docById = doc(11L, 1L, OTHER, "别人的文档.pdf");
|
|
|
+ ServerException ex = assertThrows(ServerException.class, () -> docService.download(ME, 11L));
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+ assertTrue(ex.getMsg().contains("只能操作自己上传的文档"), ex.getMsg());
|
|
|
+
|
|
|
+ docById = doc(12L, 1L, ME, "我的文档.pdf");
|
|
|
+ doReturn(new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8)))
|
|
|
+ .when(storageUtil).download(anyString());
|
|
|
+
|
|
|
+ KnowledgeDocService.DownloadResult result = docService.download(ME, 12L);
|
|
|
+
|
|
|
+ assertEquals("我的文档.pdf", result.fileName());
|
|
|
+ verify(downloadLogMapper).insert(any(KnowledgeDocDownloadLog.class));
|
|
|
+ verify(docService).update(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("删除/重试他人上传的文档被拒绝(404/400 明确区分)")
|
|
|
+ void docOpsOwnOnly() {
|
|
|
+ docById = null;
|
|
|
+ ServerException missing = assertThrows(ServerException.class, () -> docService.delete(ME, 404L));
|
|
|
+ assertEquals(404, missing.getCode());
|
|
|
+
|
|
|
+ docById = doc(11L, 1L, OTHER, "b.pdf");
|
|
|
+ ServerException ex = assertThrows(ServerException.class, () -> docService.reindex(ME, 11L));
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+ verify(docIndexer, never()).deleteDocVectors(anyLong(), anyLong());
|
|
|
+ verify(docService, never()).removeById(any(Serializable.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("删除自己的文档:文件 + 向量行 + 下载记录级联清理")
|
|
|
+ void deleteOwnDocCascades() {
|
|
|
+ docById = doc(12L, 1L, ME, "我的文档.pdf");
|
|
|
+
|
|
|
+ docService.delete(ME, 12L);
|
|
|
+
|
|
|
+ verify(storageUtil).delete("knowledge/1/doc/12.pdf");
|
|
|
+ verify(docIndexer).deleteDocVectors(1L, 12L);
|
|
|
+ verify(downloadLogMapper).deleteByDocId(12L);
|
|
|
+ verify(docService).removeById(12L);
|
|
|
+ }
|
|
|
+}
|