fix(wiki): close IDOR in WikiRelationController & WikiEntityController (cross-KB id binding)

Every endpoint now binds its independent id param to an authorized KB: rawId/chunkId resolve-then-workspace-check, pageId is asserted to belong to the path kbId, and slugs stay kbId-scoped. Adds unit tests for same-KB/cross-KB/unknown cases.
This commit is contained in:
倪程伟 2026-06-28 14:41:19 +08:00 committed by GitHub
parent 04197d7ba9
commit 2f46619e5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 543 additions and 14 deletions

View File

@ -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<WikiEntityView> 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<String, Object> 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, "资源不属于当前工作区");
}
}
}

View File

@ -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<RelatedPageResult> 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<WikiPageLite> pagesByRawId(@PathVariable Long rawId) {
public List<WikiPageLite> 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<WikiPageLite> pagesByChunkId(@PathVariable Long chunkId) {
public List<WikiPageLite> 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<PageCitationWithRaw> 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<WikiProcessingJobEntity> 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<String, Object> kbStats(@PathVariable Long kbId) {
public Map<String, Object> 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<String, Object> enrichPage(@PathVariable Long kbId, @PathVariable String slug) {
public Map<String, Object> 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<String, Object> repairPage(@PathVariable Long kbId, @PathVariable String slug) {
public Map<String, Object> 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<PageSearchResult> searchPreview(
@PathVariable Long kbId,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> 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);
}
}

View File

@ -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;
}
}

View File

@ -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.
*
* <p>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.<PageSearchResult>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;
}
}