From 441dc9e0e724d9597c6f9fc91814b80ccc8babd9 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 7 Apr 2026 16:24:15 +0800 Subject: [PATCH 1/2] fix(security): close unauthorized metrics and compat access paths --- docker-compose.yml | 2 +- .../compat/ClawHubCompatAppService.java | 38 +++++-- .../compat/ClawHubCompatController.java | 11 +- .../compat/ClawHubRegistryFacade.java | 3 +- .../compat/CompatSkillLookupService.java | 26 ++++- .../portal/NamespaceController.java | 8 +- .../NamespacePortalQueryAppService.java | 37 ++++++- .../src/main/resources/application.yml | 4 +- .../compat/ClawHubCompatAppServiceTest.java | 80 ++++++++++++++ .../ClawHubCompatControllerSecurityTest.java | 102 ++++++++++++++++++ .../compat/CompatSkillLookupServiceTest.java | 78 ++++++++++++++ .../NamespacePortalControllerTest.java | 13 +-- .../metrics/PrometheusEndpointTest.java | 5 +- .../NamespacePortalQueryAppServiceTest.java | 36 +++++++ .../policy/RouteSecurityPolicyRegistry.java | 8 +- .../RouteSecurityPolicyRegistryTest.java | 15 +++ 16 files changed, 425 insertions(+), 41 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java diff --git a/docker-compose.yml b/docker-compose.yml index 7bf18ffc..77dc264b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: redis: image: ${REDIS_IMAGE:-redis:7-alpine} ports: - - "6379:6379" + - "127.0.0.1:6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index 051e9566..4a22dd53 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -94,7 +94,8 @@ public ClawHubResolveResponse resolveByQuery(String slug, String hash, String userId, Map userNsRoles) { - SkillCoordinate coord = resolveQueryCoordinate(slug); + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); + Map roles = normalizeRoles(userNsRoles); SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( coord.namespace(), @@ -103,7 +104,7 @@ public ClawHubResolveResponse resolveByQuery(String slug, "latest".equals(version) ? "latest" : null, hash, userId, - userNsRoles != null ? userNsRoles : Map.of() + roles ); return toResolveResponse(resolved); } @@ -132,23 +133,37 @@ public String downloadLocationByPath(String canonicalSlug, String version) { : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; } - public String downloadLocationByQuery(String slug, String version) { - SkillCoordinate coord = resolveQueryCoordinate(slug); + public String downloadLocationByQuery(String slug, + String version, + String userId, + Map userNsRoles) { + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); return "latest".equals(version) ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; } - private SkillCoordinate resolveQueryCoordinate(String slug) { + private SkillCoordinate resolveQueryCoordinate(String slug, + String userId, + Map userNsRoles) { if (slug != null && slug.contains("--")) { return mapper.fromCanonical(slug); } + CompatSkillLookupService.CompatSkillContext context; try { - CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug); - return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug()); + context = compatSkillLookupService.findByLegacySlug(slug); } catch (DomainNotFoundException ex) { return mapper.fromCanonical(slug); } + Map roles = normalizeRoles(userNsRoles); + if (!compatSkillLookupService.canAccess(context.skill(), userId, roles)) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug()); + } + + private Map normalizeRoles(Map userNsRoles) { + return userNsRoles != null ? userNsRoles : Map.of(); } public ClawHubSkillListResponse listSkills(int page, @@ -182,11 +197,18 @@ public ClawHubSkillListResponse listSkills(int page, } public ClawHubSkillResponse getSkill(String canonicalSlug, String userId) { + return getSkill(canonicalSlug, userId, Map.of()); + } + + public ClawHubSkillResponse getSkill(String canonicalSlug, + String userId, + Map userNsRoles) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( coord.namespace(), coord.slug(), - userId + userId, + userNsRoles != null ? userNsRoles : Map.of() ); SkillVersion latestVersionEntity = context.latestVersion().orElse(null); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index a66a7f0d..82c2d559 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -82,8 +82,10 @@ public ResponseEntity downloadByPath(@PathVariable String canonicalSlug, @RateLimit(category = "download", authenticated = 60, anonymous = 20) @GetMapping("/download") public ResponseEntity downloadByQuery(@RequestParam String slug, - @RequestParam(defaultValue = "latest") String version) { - return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version)); + @RequestParam(defaultValue = "latest") String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version, userId, userNsRoles)); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @@ -99,8 +101,9 @@ public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @GetMapping("/skills/{canonicalSlug}") public ClawHubSkillResponse getSkill(@PathVariable String canonicalSlug, - @RequestAttribute(value = "userId", required = false) String userId) { - return clawHubCompatAppService.getSkill(canonicalSlug, userId); + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return clawHubCompatAppService.getSkill(canonicalSlug, userId, userNsRoles); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java index 68dd32fc..8c02af39 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -83,7 +83,8 @@ public ClawHubRegistrySkillResponse getSkill( CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( coordinate.namespace(), coordinate.slug(), - userId + userId, + normalizeRoles(userNsRoles) ); Skill skill = context.skill(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java index a151ed8f..9da699a2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java @@ -1,13 +1,16 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Service; @@ -22,15 +25,18 @@ public class CompatSkillLookupService { private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; private final SkillSlugResolutionService skillSlugResolutionService; + private final VisibilityChecker visibilityChecker; public CompatSkillLookupService(SkillRepository skillRepository, NamespaceRepository namespaceRepository, SkillVersionRepository skillVersionRepository, - SkillSlugResolutionService skillSlugResolutionService) { + SkillSlugResolutionService skillSlugResolutionService, + VisibilityChecker visibilityChecker) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; this.skillSlugResolutionService = skillSlugResolutionService; + this.visibilityChecker = visibilityChecker; } public CompatSkillContext findByLegacySlug(String slug) { @@ -41,10 +47,28 @@ public CompatSkillContext findByLegacySlug(String slug) { return new CompatSkillContext(namespace, skill, findLatestVersion(skill)); } + public boolean canAccess(Skill skill, String currentUserId, Map userNsRoles) { + if (skill == null) { + return false; + } + Map roles = userNsRoles != null ? userNsRoles : Map.of(); + return visibilityChecker.canAccess(skill, currentUserId, roles); + } + public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) { + return resolveVisible(namespaceSlug, skillSlug, currentUserId, Map.of()); + } + + public CompatSkillContext resolveVisible(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles) { Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug)); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + if (!canAccess(skill, currentUserId, userNsRoles)) { + throw new DomainNotFoundException("error.skill.notFound", skillSlug); + } return new CompatSkillContext(namespace, skill, findLatestVersion(skill)); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index e18fa681..2f2c603e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -55,8 +55,10 @@ public NamespaceController(NamespacePortalQueryAppService namespacePortalQueryAp } @GetMapping("/namespaces") - public ApiResponse> listNamespaces(Pageable pageable) { - return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable)); + public ApiResponse> listNamespaces( + Pageable pageable, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles)); } @GetMapping("/me/namespaces") @@ -68,7 +70,7 @@ public ApiResponse> listMyNamespaces( @GetMapping("/namespaces/{slug}") public ApiResponse getNamespace(@PathVariable String slug, - @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { return ok("response.success.read", namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles)); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 17dd9b99..1e0d9486 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; @@ -16,6 +17,8 @@ import java.util.List; import java.util.Map; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,9 +46,31 @@ public NamespacePortalQueryAppService(NamespaceRepository namespaceRepository, } @Transactional(readOnly = true) - public PageResponse listNamespaces(Pageable pageable) { - Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); - return PageResponse.from(namespaces.map(NamespaceResponse::from)); + public PageResponse listNamespaces(Pageable pageable, Map userNamespaceRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + Page empty = new PageImpl<>( + List.of(), + PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()), + 0 + ); + return PageResponse.from(empty); + } + + List scopedNamespaces = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .filter(namespace -> namespace.getStatus() == NamespaceStatus.ACTIVE) + .sorted(Comparator.comparing(Namespace::getSlug)) + .toList(); + int fromIndex = Math.min((int) pageable.getOffset(), scopedNamespaces.size()); + int toIndex = Math.min(fromIndex + pageable.getPageSize(), scopedNamespaces.size()); + Page page = new PageImpl<>( + scopedNamespaces.subList(fromIndex, toIndex).stream() + .map(NamespaceResponse::from) + .toList(), + pageable, + scopedNamespaces.size() + ); + return PageResponse.from(page); } @Transactional(readOnly = true) @@ -66,10 +91,14 @@ public List listMyNamespaces(Map userN @Transactional(readOnly = true) public NamespaceResponse getNamespace(String slug, String userId, Map userNamespaceRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); Namespace namespace = namespaceService.getNamespaceBySlugForRead( slug, userId, - userNamespaceRoles != null ? userNamespaceRoles : Map.of()); + namespaceRoles); + if (!namespaceRoles.containsKey(namespace.getId())) { + throw new DomainForbiddenException("error.namespace.membership.required"); + } return NamespaceResponse.from(namespace); } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 8a3872af..f278b6fc 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -171,7 +171,7 @@ management: endpoints: web: exposure: - include: health,info,prometheus,metrics + include: health,info endpoint: health: show-details: when-authorized @@ -180,4 +180,4 @@ management: application: skillhub export: prometheus: - enabled: true + enabled: false diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java new file mode 100644 index 00000000..c5921f59 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -0,0 +1,80 @@ +package com.iflytek.skillhub.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.controller.support.MultipartPackageExtractor; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.social.SkillStarService; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ClawHubCompatAppServiceTest { + + private final SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class); + private final SkillQueryService skillQueryService = mock(SkillQueryService.class); + private final SkillPublishService skillPublishService = mock(SkillPublishService.class); + private final ZipPackageExtractor zipPackageExtractor = mock(ZipPackageExtractor.class); + private final MultipartPackageExtractor multipartPackageExtractor = mock(MultipartPackageExtractor.class); + private final AuditLogService auditLogService = mock(AuditLogService.class); + private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class); + private final SkillStarService skillStarService = mock(SkillStarService.class); + + private final ClawHubCompatAppService service = new ClawHubCompatAppService( + new CanonicalSlugMapper(), + skillSearchAppService, + skillQueryService, + skillPublishService, + zipPackageExtractor, + multipartPackageExtractor, + auditLogService, + compatSkillLookupService, + skillStarService + ); + + @Test + void downloadLocationByQuery_throwsNotFound_whenLegacySkillIsPrivateForAnonymousCaller() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE); + CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext( + namespace, + privateSkill, + Optional.empty() + ); + + when(compatSkillLookupService.findByLegacySlug("priv")).thenReturn(context); + when(compatSkillLookupService.canAccess(privateSkill, null, Map.of())).thenReturn(false); + + assertThatThrownBy(() -> service.downloadLocationByQuery("priv", "latest", null, null)) + .isInstanceOf(DomainNotFoundException.class); + } + + @Test + void downloadLocationByQuery_returnsCanonicalPath_whenLegacySkillIsVisible() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC); + CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext( + namespace, + publicSkill, + Optional.empty() + ); + + when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context); + when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true); + + String location = service.downloadLocationByQuery("my-skill", "latest", null, null); + + assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java new file mode 100644 index 00000000..eb731e82 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java @@ -0,0 +1,102 @@ +package com.iflytek.skillhub.compat; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ClawHubCompatControllerSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private ClawHubCompatAppService clawHubCompatAppService; + + @Test + void getSkill_returnsNotFound_whenAnonymousCannotAccessPrivateSkill() throws Exception { + when(clawHubCompatAppService.getSkill(eq("priv"), isNull(), isNull())) + .thenThrow(new DomainNotFoundException("error.skill.notFound", "priv")); + + mockMvc.perform(get("/api/v1/skills/priv")) + .andExpect(status().isNotFound()); + } + + @Test + void getSkill_returnsSkill_whenCallerHasNamespacePermission() throws Exception { + var roles = Map.of(1L, NamespaceRole.ADMIN); + var response = new ClawHubSkillResponse( + new ClawHubSkillResponse.SkillInfo( + "team-ai--priv", + "Private Skill", + "summary", + Map.of(), + Map.of(), + 0L, + 0L + ), + null, + null, + new ClawHubSkillResponse.ModerationInfo(false, false, "clean", new String[0], null, null, null) + ); + when(clawHubCompatAppService.getSkill("team-ai--priv", "admin-1", roles)).thenReturn(response); + + mockMvc.perform(get("/api/v1/skills/team-ai--priv") + .requestAttr("userId", "admin-1") + .requestAttr("userNsRoles", roles)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.skill.slug").value("team-ai--priv")); + + verify(clawHubCompatAppService).getSkill("team-ai--priv", "admin-1", roles); + } + + @Test + void downloadQuery_returnsNotFound_whenAnonymousCannotAccessPrivateLegacySlug() throws Exception { + when(clawHubCompatAppService.downloadLocationByQuery(eq("priv"), eq("latest"), isNull(), isNull())) + .thenThrow(new DomainNotFoundException("error.skill.notFound", "priv")); + + mockMvc.perform(get("/api/v1/download") + .param("slug", "priv") + .param("version", "latest")) + .andExpect(status().isNotFound()); + } + + @Test + void downloadQuery_returnsNotFound_whenUserWithoutNamespaceRoleAccessesPrivateLegacySlug() throws Exception { + when(clawHubCompatAppService.downloadLocationByQuery("priv", "latest", "user-1", Map.of())) + .thenThrow(new DomainNotFoundException("error.skill.notFound", "priv")); + + mockMvc.perform(get("/api/v1/download") + .param("slug", "priv") + .param("version", "latest") + .requestAttr("userId", "user-1") + .requestAttr("userNsRoles", Map.of())) + .andExpect(status().isNotFound()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java new file mode 100644 index 00000000..3500ce04 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class CompatSkillLookupServiceTest { + + private final SkillRepository skillRepository = mock(SkillRepository.class); + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class); + private final VisibilityChecker visibilityChecker = mock(VisibilityChecker.class); + + private final CompatSkillLookupService service = new CompatSkillLookupService( + skillRepository, + namespaceRepository, + skillVersionRepository, + skillSlugResolutionService, + visibilityChecker + ); + + @Test + void resolveVisible_throwsNotFoundWhenCallerCannotAccessSkill() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 1L); + Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE); + ReflectionTestUtils.setField(privateSkill, "id", 7L); + privateSkill.setLatestVersionId(70L); + + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(skillSlugResolutionService.resolve(1L, "priv", null, SkillSlugResolutionService.Preference.PUBLISHED)) + .thenReturn(privateSkill); + when(visibilityChecker.canAccess(privateSkill, null, Map.of())).thenReturn(false); + + assertThatThrownBy(() -> service.resolveVisible("team-a", "priv", null, Map.of())) + .isInstanceOf(DomainNotFoundException.class); + } + + @Test + void resolveVisible_returnsSkillWhenCallerHasNamespaceAccess() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 1L); + Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE); + ReflectionTestUtils.setField(privateSkill, "id", 7L); + privateSkill.setLatestVersionId(70L); + + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(skillSlugResolutionService.resolve(1L, "priv", "admin-1", SkillSlugResolutionService.Preference.PUBLISHED)) + .thenReturn(privateSkill); + when(visibilityChecker.canAccess(privateSkill, "admin-1", Map.of(1L, NamespaceRole.ADMIN))).thenReturn(true); + + CompatSkillLookupService.CompatSkillContext result = service.resolveVisible( + "team-a", + "priv", + "admin-1", + Map.of(1L, NamespaceRole.ADMIN) + ); + + assertThat(result.skill().getId()).isEqualTo(7L); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 76663e99..f2cf8a50 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -89,18 +89,9 @@ void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws } @Test - void getNamespace_hidesArchivedNamespaceFromAnonymousUsers() throws Exception { - Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); - given(namespaceService.getNamespaceBySlugForRead("team-a", null, Map.of())).willThrow( - new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( - "error.namespace.slug.notFound", - "team-a" - ) - ); - + void getNamespace_requiresAuthentication() throws Exception { mockMvc.perform(get("/api/v1/namespaces/team-a")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(400)); + .andExpect(status().isUnauthorized()); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java index 13c0be27..38373f64 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java @@ -35,13 +35,14 @@ class PrometheusEndpointTest { private DeviceAuthService deviceAuthService; @Test - void prometheusEndpoint_exposesCustomMetrics() { + void metricsRegistry_stillRecordsCustomMetrics_whenPrometheusEndpointIsDisabled() { skillHubMetrics.incrementUserRegister(); skillHubMetrics.recordLocalLogin(true); skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW"); assertThat(environment.getProperty("management.endpoints.web.exposure.include")) - .contains("prometheus"); + .doesNotContain("prometheus") + .doesNotContain("metrics"); assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d); assertThat(meterRegistry.get("skillhub.auth.login") .tag("method", "local") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 61e6a458..5c79f687 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -13,7 +14,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -60,6 +63,39 @@ void listMyNamespaces_sortsBySlugAndProjectsRoleCapabilities() { assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN); } + @Test + void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() { + Namespace teamA = namespace(1L, "team-a"); + Namespace teamB = namespace(2L, "team-b"); + Namespace archived = namespace(3L, "archived"); + archived.setStatus(NamespaceStatus.ARCHIVED); + + when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(teamB, archived, teamA)); + + var response = service.listNamespaces( + PageRequest.of(0, 10), + Map.of( + 1L, NamespaceRole.MEMBER, + 2L, NamespaceRole.ADMIN, + 3L, NamespaceRole.OWNER + ) + ); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).slug()).isEqualTo("team-a"); + assertThat(response.items().get(1).slug()).isEqualTo("team-b"); + } + + @Test + void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() { + Namespace namespace = namespace(1L, "team-a"); + when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of())) + .thenReturn(namespace); + + assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of())) + .isInstanceOf(DomainForbiddenException.class); + } + private Namespace namespace(Long id, String slug) { Namespace namespace = new Namespace(slug, slug, "owner-1"); ReflectionTestUtils.setField(namespace, "id", id); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 498d2010..8235032c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -71,10 +71,10 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"), RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**") ); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 8c227dd3..f6c8d742 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -58,6 +58,21 @@ void authorizationPolicies_shouldKeepPublicLabelsEndpointsAnonymous() { assertTrue(matchedWeb); } + @Test + void authorizationPolicies_shouldRequireAuthenticationForNamespaceDiscovery() { + boolean matchedV1 = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/v1/namespaces".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED); + boolean matchedWeb = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/web/namespaces".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED); + + assertTrue(matchedV1); + assertTrue(matchedWeb); + } + @Test void shouldIgnoreCsrf_forBearerAndApiPaths() { assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null)); From 40807e7fa0ad30419f8156e988282598b0e101fc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 10 Apr 2026 10:12:27 +0800 Subject: [PATCH 2/2] test(app): isolate H2 db per Spring test context --- server/skillhub-app/src/test/resources/application-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index a3eb93b9..55bd499c 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -4,7 +4,7 @@ spring: banner-mode: "off" log-startup-info: false datasource: - url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:testdb-${random.uuid};MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: