diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java index fcac1042..523288e1 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java @@ -5,14 +5,19 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import vip.mate.exception.MateClawException; import vip.mate.wiki.dto.WikiEntityGraphView; import vip.mate.wiki.dto.WikiEntityView; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.service.WikiEntityExtractionService; import vip.mate.wiki.service.WikiEntityGraphService; +import vip.mate.wiki.service.WikiKnowledgeBaseService; import vip.mate.wiki.service.WikiProcessingService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; import java.util.Map; @@ -30,27 +35,37 @@ public class WikiEntityController { private final WikiEntityGraphService graphService; private final WikiEntityExtractionService extractionService; + private final WikiKnowledgeBaseService kbService; /** List entities in a KB, optionally filtered by type, ranked by salience. */ + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/entities") public List listEntities(@PathVariable Long kbId, @RequestParam(required = false) String type, - @RequestParam(defaultValue = "100") int limit) { + @RequestParam(defaultValue = "100") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return graphService.listEntities(kbId, type, limit); } /** Whole-KB entity graph: top entities by salience plus the edges among them. */ + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/entity-graph") public WikiEntityGraphView kbEntityGraph(@PathVariable Long kbId, - @RequestParam(defaultValue = "150") int limit) { + @RequestParam(defaultValue = "150") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return graphService.graph(kbId, limit); } /** Ego-graph around a single entity: neighbors, edges, and mentioning pages. */ + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/entities/{entityId}/graph") public WikiEntityGraphView entityGraph(@PathVariable Long kbId, @PathVariable Long entityId, - @RequestParam(defaultValue = "50") int limit) { + @RequestParam(defaultValue = "50") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return graphService.ego(kbId, entityId, limit); } @@ -60,9 +75,12 @@ public class WikiEntityController { * * @param force when true, re-extract chunks that already have mentions */ + @RequireWorkspaceRole("member") @PostMapping("/kb/{kbId}/entities/extract") public Map extract(@PathVariable Long kbId, - @RequestParam(defaultValue = "false") boolean force) { + @RequestParam(defaultValue = "false") boolean force, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); WikiProcessingService.WIKI_EXECUTOR.submit(() -> { try { int count = extractionService.extractForKb(kbId, force); @@ -73,4 +91,17 @@ public class WikiEntityController { }); return Map.of("status", "started", "kbId", kbId); } + + // ==================== Workspace Verification ==================== + + private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null) { + throw new MateClawException(404, "Knowledge base not found"); + } + long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L; + if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) { + throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java index d309e598..5048f2d8 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java @@ -6,14 +6,20 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.bind.annotation.*; +import vip.mate.exception.MateClawException; import vip.mate.wiki.dto.*; import vip.mate.wiki.job.WikiProcessingJobService; import vip.mate.wiki.job.event.WikiJobCreatedEvent; import vip.mate.wiki.repository.WikiProcessingJobMapper; import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiChunkMapper; import vip.mate.wiki.repository.WikiPageCitationMapper; import vip.mate.wiki.service.*; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.HashMap; import java.util.List; @@ -37,52 +43,88 @@ public class WikiRelationController { private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final WikiEmbeddingService embeddingService; + private final WikiKnowledgeBaseService kbService; + private final WikiRawMaterialService rawService; + private final WikiChunkMapper chunkMapper; // ==================== RFC-029: Relations ==================== + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/pages/{slug}/related") public List relatedPages( @PathVariable Long kbId, @PathVariable String slug, - @RequestParam(defaultValue = "5") int topK) { + @RequestParam(defaultValue = "5") int topK, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return relationService.relatedPages(kbId, slug, Math.min(topK, 20)); } + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/pages/{slugA}/relation/{slugB}") public RelationExplanation explainRelation( @PathVariable Long kbId, @PathVariable String slugA, - @PathVariable String slugB) { + @PathVariable String slugB, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return relationService.explain(kbId, slugA, slugB); } + @RequireWorkspaceRole("viewer") @GetMapping("/raw/{rawId}/pages") - public List pagesByRawId(@PathVariable Long rawId) { + public List pagesByRawId( + @PathVariable Long rawId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyRawWorkspace(rawId, workspaceId); return relationService.pagesByRawId(rawId); } + @RequireWorkspaceRole("viewer") @GetMapping("/chunks/{chunkId}/pages") - public List pagesByChunkId(@PathVariable Long chunkId) { + public List pagesByChunkId( + @PathVariable Long chunkId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyChunkWorkspace(chunkId, workspaceId); return relationService.pagesByChunkId(chunkId); } // ==================== RFC-029: Citations ==================== + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/pages/{pageId}/citations") public List pageCitations( @PathVariable Long kbId, - @PathVariable Long pageId) { + @PathVariable Long pageId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + // Guard against a partial IDOR: kbId is workspace-checked above, but + // pageId is an independent path variable that could point at a page in + // another KB. Require the resolved page to actually belong to this kbId + // (same pattern as the getJobs(rawId) cross-KB filter). + WikiPageEntity page = pageService.getById(pageId); + if (page == null || !kbId.equals(page.getKbId())) { + return List.of(); + } return citationMapper.listWithRawByPageId(pageId); } // ==================== RFC-030: Jobs ==================== + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/jobs") public List getJobs( @PathVariable Long kbId, - @RequestParam(required = false) Long rawId) { + @RequestParam(required = false) Long rawId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); if (rawId != null) { return jobMapper.findLatestByRawId(rawId) + // Guard against a partial IDOR: kbId is workspace-checked + // above, but rawId is an independent query param that could + // point at another KB's material. Require the resolved job + // to actually belong to this kbId. + .filter(j -> kbId.equals(j.getKbId())) .map(List::of).orElse(List.of()); } return jobMapper.listQueued(kbId, 20); @@ -90,8 +132,12 @@ public class WikiRelationController { // ==================== RFC-030/033: KB Stats ==================== + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/stats") - public Map kbStats(@PathVariable Long kbId) { + public Map kbStats( + @PathVariable Long kbId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); int pageCount = pageService.countByKbId(kbId); // Count enriched pages (those containing [[wikilinks]]) long enrichedCount = pageService.listByKbIdWithContent(kbId).stream() @@ -128,8 +174,13 @@ public class WikiRelationController { // ==================== RFC-031: Enrichment & Repair ==================== + @RequireWorkspaceRole("member") @PostMapping("/kb/{kbId}/pages/{slug}/enrich") - public Map enrichPage(@PathVariable Long kbId, @PathVariable String slug) { + public Map enrichPage( + @PathVariable Long kbId, + @PathVariable String slug, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); WikiPageEntity page = pageService.getBySlug(kbId, slug); if (page == null) return Map.of("error", "Page not found: " + slug); @@ -146,8 +197,13 @@ public class WikiRelationController { return Map.of("jobId", job.getId()); } + @RequireWorkspaceRole("member") @PostMapping("/kb/{kbId}/pages/{slug}/repair") - public Map repairPage(@PathVariable Long kbId, @PathVariable String slug) { + public Map repairPage( + @PathVariable Long kbId, + @PathVariable String slug, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); WikiPageEntity page = pageService.getBySlug(kbId, slug); if (page == null) return Map.of("error", "Page not found: " + slug); @@ -166,13 +222,54 @@ public class WikiRelationController { // ==================== RFC-032: Search preview ==================== + @RequireWorkspaceRole("viewer") @PostMapping("/kb/{kbId}/search-preview") public List searchPreview( @PathVariable Long kbId, - @RequestBody Map body) { + @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); String query = (String) body.getOrDefault("query", ""); String mode = (String) body.getOrDefault("mode", "hybrid"); int topK = body.containsKey("topK") ? ((Number) body.get("topK")).intValue() : 5; return hybridRetriever.search(kbId, query, mode, Math.min(topK, 20)); } + + // ==================== Workspace Verification ==================== + + private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null) { + throw new MateClawException(404, "Knowledge base not found"); + } + long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L; + if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) { + throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区"); + } + } + + /** + * Resolve the owning KB of a raw material and check it belongs to the + * caller's workspace. Raw materials don't carry workspaceId directly; + * they reference a KB which does. + */ + private void verifyRawWorkspace(Long rawId, Long headerWorkspaceId) { + WikiRawMaterialEntity raw = rawService.getById(rawId); + if (raw == null || raw.getKbId() == null) { + throw new MateClawException(404, "Raw material not found"); + } + verifyKBWorkspace(raw.getKbId(), headerWorkspaceId); + } + + /** + * Resolve the owning KB of a chunk and check it belongs to the caller's + * workspace. Like raw materials, chunks reference a KB, not a workspace. + */ + private void verifyChunkWorkspace(Long chunkId, Long headerWorkspaceId) { + WikiChunkEntity chunk = chunkMapper.selectById(chunkId); + if (chunk == null || chunk.getKbId() == null) { + throw new MateClawException(404, "Chunk not found"); + } + verifyKBWorkspace(chunk.getKbId(), headerWorkspaceId); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiEntityControllerIdorTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiEntityControllerIdorTest.java new file mode 100644 index 00000000..91dcece2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiEntityControllerIdorTest.java @@ -0,0 +1,102 @@ +package vip.mate.wiki.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.service.WikiEntityExtractionService; +import vip.mate.wiki.service.WikiEntityGraphService; +import vip.mate.wiki.service.WikiKnowledgeBaseService; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the IDOR guard added for ISSUE #438 on + * {@link WikiEntityController}: entity-graph endpoints must reject requests + * whose target KB belongs to a different workspace than the caller's + * {@code X-Workspace-Id} header. + */ +class WikiEntityControllerIdorTest { + + private WikiKnowledgeBaseService kbService; + private WikiEntityController controller; + + @BeforeEach + void setUp() { + kbService = mock(WikiKnowledgeBaseService.class); + controller = new WikiEntityController( + mock(WikiEntityGraphService.class), + mock(WikiEntityExtractionService.class), + kbService); + } + + @Test + @DisplayName("listEntities on another workspace's KB → 403") + void listEntitiesCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.listEntities(10L, null, 100, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("kbEntityGraph on another workspace's KB → 403") + void kbEntityGraphCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.kbEntityGraph(10L, 150, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("entityGraph (ego) on another workspace's KB → 403") + void entityEgoGraphCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.entityGraph(10L, 77L, 50, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("extract (member-level write) on another workspace's KB → 403") + void extractCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.extract(10L, false, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("unknown kbId → 404") + void unknownKbReturns404() { + when(kbService.getById(999L)).thenReturn(null); + + assertThatThrownBy(() -> controller.listEntities(999L, null, 100, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("KB in the caller's workspace passes the guard (no MateClawException)") + void sameWorkspaceAllowed() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + + // Guard passed → no exception. (graphService is stubbed to return an + // empty list so the method returns normally.) + assertThatCode(() -> controller.listEntities(10L, null, 100, 1L)) + .doesNotThrowAnyException(); + } + + // ---------------- helpers ---------------- + + private static WikiKnowledgeBaseEntity kb(long id, long workspaceId) { + WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity(); + entity.setId(id); + entity.setWorkspaceId(workspaceId); + return entity; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiRelationControllerIdorTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiRelationControllerIdorTest.java new file mode 100644 index 00000000..c49f478e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiRelationControllerIdorTest.java @@ -0,0 +1,299 @@ +package vip.mate.wiki.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.exception.MateClawException; +import vip.mate.wiki.dto.PageSearchResult; +import vip.mate.wiki.job.WikiProcessingJobService; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiChunkMapper; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.repository.WikiProcessingJobMapper; +import vip.mate.wiki.service.HybridRetriever; +import vip.mate.wiki.service.WikiEmbeddingService; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiPageService; +import vip.mate.wiki.service.WikiRawMaterialService; +import vip.mate.wiki.service.WikiRelationService; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the IDOR guard added for ISSUE #438: {@link WikiRelationController} + * endpoints must reject requests whose target KB belongs to a different + * workspace than the caller's {@code X-Workspace-Id} header. + * + *

These are unit-level checks of the {@code verifyKBWorkspace} / + * {@code verifyRawWorkspace} / {@code verifyChunkWorkspace} helpers — the + * same cross-check pattern that closed the WebChat approval IDOR (#415). + * The {@code @RequireWorkspaceRole} annotation layer is validated separately + * via the interceptor; here we assert the resource-ownership guard. + */ +class WikiRelationControllerIdorTest { + + private WikiKnowledgeBaseService kbService; + private WikiRawMaterialService rawService; + private WikiChunkMapper chunkMapper; + private WikiProcessingJobMapper jobMapper; + private HybridRetriever hybridRetriever; + private WikiPageService pageService; + private WikiPageCitationMapper citationMapper; + private WikiRelationController controller; + + @BeforeEach + void setUp() { + kbService = mock(WikiKnowledgeBaseService.class); + rawService = mock(WikiRawMaterialService.class); + chunkMapper = mock(WikiChunkMapper.class); + jobMapper = mock(WikiProcessingJobMapper.class); + hybridRetriever = mock(HybridRetriever.class); + pageService = mock(WikiPageService.class); + citationMapper = mock(WikiPageCitationMapper.class); + when(hybridRetriever.search(anyLong(), anyString(), anyString(), anyInt())) + .thenReturn(List.of()); + controller = new WikiRelationController( + mock(WikiRelationService.class), + mock(WikiProcessingJobService.class), + jobMapper, + pageService, + citationMapper, + hybridRetriever, + mock(ApplicationEventPublisher.class), + new ObjectMapper(), + mock(WikiEmbeddingService.class), + kbService, + rawService, + chunkMapper); + } + + // ---------------- kbId endpoints ---------------- + + @Test + @DisplayName("search-preview in the caller's workspace succeeds") + void searchPreviewSameWorkspaceAllowed() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + + assertThatCode(() -> + controller.searchPreview(10L, Map.of("query", "x"), 1L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("search-preview on another workspace's KB → 403") + void searchPreviewCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB belongs to ws 2 + + assertThatThrownBy(() -> + controller.searchPreview(10L, Map.of("query", "x"), 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("工作区"); + } + + @Test + @DisplayName("stats on another workspace's KB → 403") + void statsCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.kbStats(10L, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("enrich (member-level write) on another workspace's KB → 403") + void enrichCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.enrichPage(10L, "some-slug", 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("repair on another workspace's KB → 403") + void repairCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.repairPage(10L, "some-slug", 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("unknown kbId → 404 (does not leak existence via workspace mismatch)") + void unknownKbReturns404() { + when(kbService.getById(999L)).thenReturn(null); + + assertThatThrownBy(() -> controller.kbStats(999L, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + // ---------------- getJobs rawId cross-KB filter ---------------- + + @Test + @DisplayName("getJobs: rawId belonging to the same KB is returned") + void getJobsRawIdSameKbReturned() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + WikiProcessingJobEntity job = new WikiProcessingJobEntity(); + job.setId(1L); + job.setKbId(10L); // same KB as the path → allowed + when(jobMapper.findLatestByRawId(50L)).thenReturn(Optional.of(job)); + + List result = controller.getJobs(10L, 50L, 1L); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("getJobs: rawId pointing at another KB's job is filtered out") + void getJobsRawIdCrossKbFiltered() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); // caller's KB + WikiProcessingJobEntity foreignJob = new WikiProcessingJobEntity(); + foreignJob.setId(2L); + foreignJob.setKbId(99L); // job belongs to a different KB → dropped + when(jobMapper.findLatestByRawId(50L)).thenReturn(Optional.of(foreignJob)); + + List result = controller.getJobs(10L, 50L, 1L); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("null X-Workspace-Id header falls back to default ws=1") + void missingHeaderFallsBackToDefaultWorkspace() { + // KB in default workspace (id=1), no header → should pass the guard. + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + + assertThatCode(() -> + controller.searchPreview(10L, Map.of("query", "x"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("KB with null workspaceId is not rejected (legacy / shared KBs)") + void nullWorkspaceKbAllowed() { + WikiKnowledgeBaseEntity legacy = new WikiKnowledgeBaseEntity(); + legacy.setId(10L); + legacy.setWorkspaceId(null); // pre-workspace KB + when(kbService.getById(10L)).thenReturn(legacy); + + assertThatCode(() -> + controller.searchPreview(10L, Map.of("query", "x"), 99L)) + .doesNotThrowAnyException(); + } + + // ---------------- pageCitations cross-KB filter (review on #439) ---------------- + + @Test + @DisplayName("pageCitations: pageId belonging to the same KB is returned") + void pageCitationsSameKbReturned() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + WikiPageEntity page = new WikiPageEntity(); + page.setId(77L); + page.setKbId(10L); // same KB as the path → allowed + when(pageService.getById(77L)).thenReturn(page); + when(citationMapper.listWithRawByPageId(77L)).thenReturn(List.of()); + + var result = controller.pageCitations(10L, 77L, 1L); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("pageCitations: pageId pointing at another KB is filtered out") + void pageCitationsCrossKbFiltered() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); // caller's KB + WikiPageEntity foreignPage = new WikiPageEntity(); + foreignPage.setId(88L); + foreignPage.setKbId(99L); // page belongs to a different KB → dropped + when(pageService.getById(88L)).thenReturn(foreignPage); + + var result = controller.pageCitations(10L, 88L, 1L); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("pageCitations: unknown pageId → empty list") + void pageCitationsUnknownPageReturnsEmpty() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + when(pageService.getById(999L)).thenReturn(null); + + var result = controller.pageCitations(10L, 999L, 1L); + + assertThat(result).isEmpty(); + } + + // ---------------- rawId / chunkId endpoints (resolve owning KB) ---------------- + + @Test + @DisplayName("pagesByRawId resolves KB and rejects cross-workspace") + void pagesByRawIdCrossWorkspaceRejected() { + WikiRawMaterialEntity raw = new WikiRawMaterialEntity(); + raw.setId(50L); + raw.setKbId(10L); + when(rawService.getById(50L)).thenReturn(raw); + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB in ws 2 + + assertThatThrownBy(() -> controller.pagesByRawId(50L, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("pagesByChunkId resolves KB and rejects cross-workspace") + void pagesByChunkIdCrossWorkspaceRejected() { + WikiChunkEntity chunk = new WikiChunkEntity(); + chunk.setId(60L); + chunk.setKbId(10L); + when(chunkMapper.selectById(60L)).thenReturn(chunk); + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB in ws 2 + + assertThatThrownBy(() -> controller.pagesByChunkId(60L, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("unknown rawId → 404") + void unknownRawReturns404() { + when(rawService.getById(999L)).thenReturn(null); + + assertThatThrownBy(() -> controller.pagesByRawId(999L, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("unknown chunkId → 404") + void unknownChunkReturns404() { + when(chunkMapper.selectById(999L)).thenReturn(null); + + assertThatThrownBy(() -> controller.pagesByChunkId(999L, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + // ---------------- helpers ---------------- + + private static WikiKnowledgeBaseEntity kb(long id, long workspaceId) { + WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity(); + entity.setId(id); + entity.setWorkspaceId(workspaceId); + return entity; + } +}