diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 7c0df9c9..ac2a02d7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -37,6 +37,7 @@ import java.io.InputStream; import java.net.URI; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -74,10 +75,15 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); SkillDetailResponse response = new SkillDetailResponse( detail.id(), @@ -121,14 +127,16 @@ public class SkillController extends BaseApiController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { Page versions = skillQueryService.listVersions( namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of(), - PageRequest.of(page, size)); + PageRequest.of(page, size), + normalizePlatformRoles(platformRoles)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), @@ -154,14 +162,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String version, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillVersionDetailDTO detail = skillQueryService.getVersionDetail( namespace, slug, version, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); SkillVersionDetailResponse response = new SkillVersionDetailResponse( @@ -185,7 +195,8 @@ public class SkillController extends BaseApiController { @RequestParam("from") String from, @RequestParam("to") String to, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillVersionCompareDTO compare = skillQueryService.compareVersions( namespace, @@ -193,7 +204,8 @@ public class SkillController extends BaseApiController { from, to, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ok("response.success.read", toCompareResponse(compare)); @@ -209,14 +221,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String version, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { List files = skillQueryService.listFiles( namespace, slug, version, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); List response = files.stream() @@ -238,14 +252,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String tagName, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { List files = skillQueryService.listFilesByTag( namespace, slug, tagName, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); List response = files.stream() @@ -272,7 +288,8 @@ public class SkillController extends BaseApiController { @PathVariable String version, @RequestParam("path") String path, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { InputStream content = skillQueryService.getFileContent( namespace, @@ -280,7 +297,8 @@ public class SkillController extends BaseApiController { version, path, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ResponseEntity.ok() @@ -295,7 +313,8 @@ public class SkillController extends BaseApiController { @PathVariable String tagName, @RequestParam("path") String path, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { InputStream content = skillQueryService.getFileContentByTag( namespace, @@ -303,7 +322,8 @@ public class SkillController extends BaseApiController { tagName, path, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ResponseEntity.ok() @@ -323,7 +343,8 @@ public class SkillController extends BaseApiController { @RequestParam(required = false) String tag, @RequestParam(required = false) String hash, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( namespace, @@ -332,7 +353,8 @@ public class SkillController extends BaseApiController { tag, hash, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); ResolveVersionResponse response = new ResolveVersionResponse( @@ -356,10 +378,15 @@ public class SkillController extends BaseApiController { @PathVariable String slug, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -372,10 +399,16 @@ public class SkillController extends BaseApiController { @PathVariable String version, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion( - namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + version, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -388,10 +421,16 @@ public class SkillController extends BaseApiController { @PathVariable String tagName, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag( - namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + tagName, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -418,6 +457,10 @@ public class SkillController extends BaseApiController { .body(new InputStreamResource(result.openContent())); } + private Set normalizePlatformRoles(Set platformRoles) { + return platformRoles != null ? platformRoles : Set.of(); + } + private boolean shouldRedirectToPresignedUrl(HttpServletRequest request, String presignedUrl) { if (presignedUrl == null || presignedUrl.isBlank()) { return false; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index ce622374..5599d475 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -20,11 +20,13 @@ import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.TimeZone; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -57,7 +59,8 @@ class SkillControllerTest { eq("demo"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionDetailDTO( 10L, "1.0.0", @@ -87,7 +90,8 @@ class SkillControllerTest { eq("demo"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionDetailDTO( 10L, "1.0.0", @@ -122,7 +126,8 @@ class SkillControllerTest { eq("latest"), eq(null), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.ResolvedVersionDTO( 1L, "team", @@ -150,7 +155,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillDetailDTO( 1L, "demo", @@ -198,7 +204,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); mockMvc.perform(get("/api/web/skills/team/demo")) @@ -206,6 +213,31 @@ class SkillControllerTest { .andExpect(jsonPath("$.code").value(403)); } + @Test + void getSkillDetailShouldForwardPlatformRoles() throws Exception { + Set platformRoles = Set.of("SUPER_ADMIN"); + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq("super-1"), + eq(Map.of()), + eq(platformRoles))) + .thenThrow(new DomainForbiddenException("test.platformRoles.forwarded")); + + mockMvc.perform(get("/api/web/skills/team/demo") + .requestAttr("userId", "super-1") + .requestAttr("userNsRoles", Map.of()) + .requestAttr("platformRoles", platformRoles)) + .andExpect(status().isForbidden()); + + verify(skillQueryService).getSkillDetail( + "team", + "demo", + "super-1", + Map.of(), + platformRoles); + } + @Test void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.listFilesByTag( @@ -213,7 +245,8 @@ class SkillControllerTest { eq("demo"), eq("latest"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(List.of(new SkillFile(20L, "README.md", 32L, "text/markdown", "hash", "key"))); mockMvc.perform(get("/api/v1/skills/team/demo/tags/latest/files")) @@ -232,7 +265,8 @@ class SkillControllerTest { eq("demo"), eq((String) null), eq(Map.of()), - any())) + any(), + anySet())) .thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(version))); when(skillQueryService.isDownloadAvailable(version)).thenReturn(false); @@ -249,7 +283,8 @@ class SkillControllerTest { eq("1.0.0"), eq("1.1.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionCompareDTO( "1.0.0", "1.1.0", @@ -292,7 +327,8 @@ class SkillControllerTest { eq("1.0.0"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenThrow(new DomainBadRequestException("error.skill.version.compare.same")); mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java index 42f0e360..b1ff4dea 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.portal; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; @@ -22,6 +23,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.ratelimit.RateLimiter; import java.io.ByteArrayInputStream; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; @@ -59,7 +61,7 @@ class DownloadRateLimitControllerTest { @Test void anonymousDownloadUsesIpAndSignedCookieBuckets() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of())) + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of(), Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -101,6 +103,7 @@ class DownloadRateLimitControllerTest { .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.code").value(429)); - verify(skillDownloadService, never()).downloadVersion(anyString(), anyString(), anyString(), anyString(), any()); + verify(skillDownloadService, never()).downloadVersion( + anyString(), anyString(), anyString(), anyString(), any(), anySet()); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index 8945d2a9..ad0c7834 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -59,7 +59,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_redirectsToPresignedUrlWhenAvailable() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -80,7 +81,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_streamsWhenPresignedUrlIsInsecureForHttpsRequest() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -102,7 +104,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -123,7 +126,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_allowsAnonymousForGlobalSkill() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", null, java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -143,7 +147,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_forbidsAnonymousWhenServiceRejectsSkill() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("team-ai", "demo-skill", "1.0.0", null, java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "team-ai", "demo-skill", "1.0.0", null, java.util.Map.of(), java.util.Set.of())) .willThrow(new DomainForbiddenException("error.skill.access.denied", "demo-skill")); mockMvc.perform(get("/api/v1/skills/team-ai/demo-skill/versions/1.0.0/download") @@ -155,7 +160,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_redirectDoesNotOpenContentStream() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> { throw new AssertionError("content stream should not be opened for redirects"); @@ -178,7 +184,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_usesPerVersionRateLimitKey() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index 30b52a66..9d6b8b6d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -33,6 +33,26 @@ public class VisibilityChecker { }; } + /** + * Applies portal namespace-read semantics without turning the caller into a namespace member. + * The override exposes published namespace-visible skills, but never private, hidden, or + * unpublished skills. + */ + public boolean canAccessForNamespaceRead( + Skill skill, + String currentUserId, + Map userNamespaceRoles, + boolean namespaceReadAllowed) { + if (canAccess(skill, currentUserId, userNamespaceRoles)) { + return true; + } + return namespaceReadAllowed + && !skill.isHidden() + && skill.getStatus() == SkillStatus.ACTIVE + && skill.getLatestVersionId() != null + && skill.getVisibility() == SkillVisibility.NAMESPACE_ONLY; + } + private boolean isOwner(Skill skill, String currentUserId) { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 294563ad..c2433fce 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -22,6 +22,7 @@ import java.time.Duration; import java.util.Comparator; import java.util.Map; import java.util.List; +import java.util.Set; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -91,10 +92,19 @@ public class SkillDownloadService { String skillSlug, String currentUserId, Map userNsRoles) { + return downloadLatest(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadLatest( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); if (skill.getLatestVersionId() == null) { throw new DomainBadRequestException("error.skill.version.latest.unavailable", skillSlug); @@ -116,10 +126,20 @@ public class SkillDownloadService { String versionStr, String currentUserId, Map userNsRoles) { + return downloadVersion(namespaceSlug, skillSlug, versionStr, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadVersion( + String namespaceSlug, + String skillSlug, + String versionStr, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); @@ -136,10 +156,20 @@ public class SkillDownloadService { String tagName, String currentUserId, Map userNsRoles) { + return downloadByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); @@ -278,15 +308,20 @@ public class SkillDownloadService { private void assertCanDownload(Namespace namespace, Skill skill, String currentUserId, - Map userNsRoles) { + Map userNsRoles, + Set platformRoles) { if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + boolean canAccess = isSuperAdmin(platformRoles) + ? visibilityChecker.canAccessForNamespaceRead(skill, currentUserId, userNsRoles, true) + : visibilityChecker.canAccess(skill, currentUserId, userNsRoles); + if (!canAccess) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (namespace.getStatus() == NamespaceStatus.ARCHIVED - && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); } } @@ -299,6 +334,10 @@ public class SkillDownloadService { return currentUserId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); } + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { return skillSlugResolutionService.resolve( namespaceId, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 66c5e319..df7070df 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -198,15 +198,34 @@ public class SkillQueryService { String skillSlug, String currentUserId, Map userNsRoles) { + return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); + } + + public SkillDetailDTO getSkillDetail( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED - && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespaceSlug); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + if (skill.getStatus() != SkillStatus.ACTIVE + && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); + } + + if (!visibilityChecker.canAccessForNamespaceRead( + skill, + currentUserId, + userNsRoles, + isSuperAdmin(platformRoles))) { throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } @@ -251,15 +270,6 @@ public class SkillQueryService { ); } - public SkillDetailDTO getSkillDetail( - String namespaceSlug, - String skillSlug, - String currentUserId, - Map userNsRoles, - Set platformRoles) { - return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles); - } - /** * Lists skills within a namespace after filtering out records the caller is * not allowed to discover. @@ -296,9 +306,19 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { + return getVersionDetail(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of()); + } + + public SkillVersionDetailDTO getVersionDetail( + String namespaceSlug, + String skillSlug, + String version, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -322,13 +342,32 @@ public class SkillQueryService { String toVersion, String currentUserId, Map userNsRoles) { + return compareVersions( + namespaceSlug, + skillSlug, + fromVersion, + toVersion, + currentUserId, + userNsRoles, + Set.of() + ); + } + + public SkillVersionCompareDTO compareVersions( + String namespaceSlug, + String skillSlug, + String fromVersion, + String toVersion, + String currentUserId, + Map userNsRoles, + Set platformRoles) { if (Objects.equals(fromVersion, toVersion)) { throw new DomainBadRequestException("error.skill.version.compare.same"); } Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion from = findVersion(skill, fromVersion); SkillVersion to = findVersion(skill, toVersion); @@ -385,9 +424,19 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { + return listFiles(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of()); + } + + public List listFiles( + String namespaceSlug, + String skillSlug, + String version, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -401,9 +450,19 @@ public class SkillQueryService { String tagName, String currentUserId, Map userNsRoles) { + return listFilesByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of()); + } + + public List listFilesByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); return availableFiles(skillVersion.getId()); } @@ -419,9 +478,20 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { + return getFileContent(namespaceSlug, skillSlug, version, filePath, currentUserId, userNsRoles, Set.of()); + } + + public InputStream getFileContent( + String namespaceSlug, + String skillSlug, + String version, + String filePath, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -438,9 +508,20 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { + return getFileContentByTag(namespaceSlug, skillSlug, tagName, filePath, currentUserId, userNsRoles, Set.of()); + } + + public InputStream getFileContentByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String filePath, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); return readFileContent(file); @@ -451,9 +532,18 @@ public class SkillQueryService { String currentUserId, Map userNsRoles, Pageable pageable) { + return listVersions(namespaceSlug, skillSlug, currentUserId, userNsRoles, pageable, Set.of()); + } + + public Page listVersions(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Pageable pageable, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); List visibleVersions; if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() @@ -551,13 +641,25 @@ public class SkillQueryService { String hash, String currentUserId, Map userNsRoles) { + return resolveVersion(namespaceSlug, skillSlug, version, tag, hash, currentUserId, userNsRoles, Set.of()); + } + + public ResolvedVersionDTO resolveVersion( + String namespaceSlug, + String skillSlug, + String version, + String tag, + String hash, + String currentUserId, + Map userNsRoles, + Set platformRoles) { if (version != null && !version.isBlank() && tag != null && !tag.isBlank()) { throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict"); } Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); assertInstallableVersion(resolved, resolved.getVersion()); String fingerprint = computeFingerprint(resolved); @@ -811,7 +913,18 @@ public class SkillQueryService { Skill skill, String currentUserId, Map userNsRoles) { - if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles)) { + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, Set.of()); + } + + private void assertPublishedAccessible( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles, + Set platformRoles) { + if (namespace.getStatus() == NamespaceStatus.ARCHIVED + && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); } if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { @@ -820,7 +933,11 @@ public class SkillQueryService { if (skill.isHidden() && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + if (!visibilityChecker.canAccessForNamespaceRead( + skill, + currentUserId, + userNsRoles, + isSuperAdmin(platformRoles))) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } } @@ -863,6 +980,10 @@ public class SkillQueryService { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { return currentUserId != null && userNsRoles.containsKey(namespaceId); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index af26901a..79c2fbbc 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -189,4 +189,24 @@ class VisibilityCheckerTest { boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of()); assertFalse(canAccess); } + + @Test + void testNamespaceReadOverrideAllowsOnlyPublishedNamespaceVisibleSkills() { + Skill archivedNamespaceOnlySkill = new Skill( + NAMESPACE_ID, "archived-namespace-skill", OWNER_ID, SkillVisibility.NAMESPACE_ONLY); + archivedNamespaceOnlySkill.setLatestVersionId(14L); + archivedNamespaceOnlySkill.setStatus(SkillStatus.ARCHIVED); + + assertTrue(checker.canAccessForNamespaceRead(namespaceOnlySkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(privateSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(hiddenPublicSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(archivedNamespaceOnlySkill, OTHER_USER_ID, Map.of(), true)); + } + + @Test + void testNamespaceReadOverrideDoesNotChangeOrdinaryAccessWhenDisabled() { + assertFalse(checker.canAccessForNamespaceRead(namespaceOnlySkill, OTHER_USER_ID, Map.of(), false)); + assertTrue(checker.canAccessForNamespaceRead(publicSkill, OTHER_USER_ID, Map.of(), false)); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 89a2e4cb..0ee04ceb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.zip.ZipInputStream; import static org.junit.jupiter.api.Assertions.*; @@ -214,6 +215,56 @@ class SkillDownloadServiceTest { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadLatest_ShouldAllowSuperAdminForArchivedNamespaceOnlySkillWithoutMembership() throws Exception { + String namespaceSlug = "archived"; + String skillSlug = "namespace-only-skill"; + Map userNsRoles = Map.of(); + Set platformRoles = Set.of("SUPER_ADMIN"); + + Namespace namespace = new Namespace(namespaceSlug, "Archived", "owner-1"); + setId(namespace, 1L); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setDisplayName("Namespace Only Skill"); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + ObjectMetadata metadata = new ObjectMetadata(4L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccessForNamespaceRead(skill, "super-1", userNsRoles, true)).thenReturn(true); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(true); + when(objectStorageService.getMetadata("packages/1/10/bundle.zip")).thenReturn(metadata); + when(objectStorageService.getObject("packages/1/10/bundle.zip")) + .thenReturn(new ByteArrayInputStream("test".getBytes())); + when(objectStorageService.generatePresignedUrl( + eq("packages/1/10/bundle.zip"), + any(), + eq("Namespace Only Skill-1.0.0.zip"))) + .thenReturn(null); + + SkillDownloadService.DownloadResult result = service.downloadLatest( + namespaceSlug, + skillSlug, + "super-1", + userNsRoles, + platformRoles + ); + + assertEquals("Namespace Only Skill-1.0.0.zip", result.filename()); + assertNotNull(result.openContent()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); + } + @Test void testDownloadLatest_ShouldRejectAnonymousHiddenPrivateAndUnpublishedSkills() throws Exception { Namespace namespace = new Namespace("global", "Global", "owner-1"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 02cf02f1..e75c9ee0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -202,6 +202,63 @@ class SkillQueryServiceTest { service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of())); } + @Test + void testGetSkillDetail_ShouldAllowSuperAdminToReadArchivedNamespaceOnlySkillWithoutMembership() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "namespace-only-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + when(userAccountRepository.findById("owner-1")).thenReturn(Optional.empty()); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + Set.of("SUPER_ADMIN") + ); + + assertEquals(skillSlug, result.slug()); + assertFalse(result.canManageLifecycle()); + } + + @Test + void testGetSkillDetail_ShouldDenySuperAdminArchivedPublicSkillWithoutMembership() throws Exception { + String namespaceSlug = "active-team"; + String skillSlug = "archived-public-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Active Team", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ARCHIVED); + skill.setLatestVersionId(11L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> service.getSkillDetail( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + Set.of("SUPER_ADMIN") + )); + } + @Test void testListSkillsByNamespace() throws Exception { // Arrange @@ -561,6 +618,41 @@ class SkillQueryServiceTest { result.getContent().stream().map(SkillVersion::getVersion).toList()); } + @Test + void testListVersions_ShouldAllowSuperAdminReadWithoutGrantingLifecycleManagement() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "namespace-only-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 10L); + published.setStatus(SkillVersionStatus.PUBLISHED); + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(published)); + + Page result = service.listVersions( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + PageRequest.of(0, 20), + Set.of("SUPER_ADMIN") + ); + + assertEquals(List.of("1.0.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + verify(skillVersionRepository, never()).findBySkillId(1L); + } + @Test void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 71cd9ebd..9e9ddf42 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -61,6 +61,7 @@ export interface SeedSkillOptions { name?: string description?: string version?: string + visibility?: 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE' readmeHeading?: string readmeBody?: string extraFiles?: Array<{ @@ -575,7 +576,7 @@ export class E2eTestDataBuilder { mimeType: 'application/zip', buffer: zipBuffer, }, - visibility: 'PUBLIC', + visibility: options?.visibility ?? 'PUBLIC', }, headers: await csrfHeaders(this.page), }), diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts index 1f3fdccf..c8286402 100644 --- a/web/e2e/my-namespaces-super-admin-actions.spec.ts +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -1,186 +1,122 @@ -import { expect, test, type Route } from '@playwright/test' +import { expect, test, type BrowserContext } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' +import { csrfHeaders } from './helpers/csrf' +import { E2eTestDataBuilder } from './helpers/test-data-builder' -function apiEnvelope(data: unknown) { - return { - code: 0, - msg: 'OK', - data, - timestamp: '2026-07-15T00:00:00Z', - requestId: 'e2e-super-admin-namespaces', - } -} +test.describe('My Namespaces super admin actions (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) -async function fulfillJson(route: Route, data: unknown) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(apiEnvelope(data)), - }) -} - -test.describe('My Namespaces super admin actions', () => { test.beforeEach(async ({ page }) => { await setEnglishLocale(page) - - await page.route('**/api/v1/auth/me', (route) => fulfillJson(route, { - userId: 'super-admin', - username: 'super-admin', - displayName: 'Super Admin', - platformRoles: ['SUPER_ADMIN'], - })) - - await page.route('**/api/web/notifications/sse', (route) => route.fulfill({ - status: 204, - body: '', - })) - - await page.route('**/api/web/notifications/unread-count', (route) => fulfillJson(route, { count: 0 })) - - await page.route('**/api/web/skills/*/star', (route) => fulfillJson(route, false)) - - await page.route('**/api/web/me/namespaces/page?**', (route) => fulfillJson(route, { - items: [ - { - id: 101, - slug: 'visible-no-role', - displayName: 'Visible Without Membership', - description: 'Returned by SUPER_ADMIN namespace visibility', - type: 'TEAM', - status: 'ACTIVE', - createdAt: '2026-07-15T00:00:00Z', - immutable: false, - canFreeze: false, - canUnfreeze: false, - canArchive: false, - canRestore: false, - canDelete: false, - }, - { - id: 102, - slug: 'owned-team', - displayName: 'Owned Team', - description: 'Namespace where the user is a member', - type: 'TEAM', - status: 'ACTIVE', - createdAt: '2026-07-15T00:00:00Z', - currentUserRole: 'OWNER', - immutable: false, - canFreeze: true, - canUnfreeze: false, - canArchive: true, - canRestore: false, - canDelete: true, - }, - { - id: 103, - slug: 'archived-no-role', - displayName: 'Archived Without Membership', - description: 'Archived namespace visible to SUPER_ADMIN', - type: 'TEAM', - status: 'ARCHIVED', - createdAt: '2026-07-15T00:00:00Z', - immutable: false, - canFreeze: false, - canUnfreeze: false, - canArchive: false, - canRestore: false, - canDelete: false, - }, - ], - page: 0, - size: 20, - total: 3, - })) - - await page.route('**/api/web/namespaces/visible-no-role', (route) => fulfillJson(route, { - id: 101, - slug: 'visible-no-role', - displayName: 'Visible Without Membership', - description: 'Returned by SUPER_ADMIN namespace visibility', - type: 'TEAM', - status: 'ACTIVE', - createdAt: '2026-07-15T00:00:00Z', - })) - - await page.route('**/api/web/namespaces/archived-no-role', (route) => fulfillJson(route, { - id: 103, - slug: 'archived-no-role', - displayName: 'Archived Without Membership', - description: 'Archived namespace visible to SUPER_ADMIN', - type: 'TEAM', - status: 'ARCHIVED', - createdAt: '2026-07-15T00:00:00Z', - })) - - await page.route('**/api/web/skills?**', (route) => { - const url = new URL(route.request().url()) - if (url.searchParams.get('namespace') === 'archived-no-role') { - return fulfillJson(route, { - items: [ - { - id: 301, - slug: 'archived-skill', - displayName: 'Archived Namespace Skill', - summary: 'Visible when archived namespace read semantics are consistent', - visibility: 'PUBLIC', - status: 'ACTIVE', - namespace: 'archived-no-role', - downloadCount: 0, - starCount: 0, - ratingCount: 0, - updatedAt: '2026-07-15T00:00:00Z', - publishedVersion: { id: 401, version: '1.0.0', status: 'PUBLISHED' }, - }, - ], - page: 0, - size: 20, - total: 1, - }) - } - return fulfillJson(route, { - items: [], - page: 0, - size: 20, - total: 0, - }) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', }) }) - test('keeps namespace-scoped actions hidden and opens detail for visible namespaces without membership', async ({ page }) => { - await page.goto('/dashboard/namespaces') + test('opens and downloads a published namespace-only skill in an archived non-member namespace', async ({ page, browser }, testInfo) => { + let adminBuilder: E2eTestDataBuilder | undefined + let ownerContext: BrowserContext | undefined + let ownerBuilder: E2eTestDataBuilder | undefined + let namespaceSlug: string | undefined + let namespaceArchived = false - const visibleCard = page.getByTestId('namespace-card-visible-no-role') - await expect(visibleCard.getByText('@visible-no-role')).toBeVisible() - await expect(visibleCard.getByText('Current role: Unknown')).toBeVisible() - await expect(visibleCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) - await expect(visibleCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + try { + adminBuilder = new E2eTestDataBuilder(page, testInfo) + await adminBuilder.init() + const namespace = await adminBuilder.createNamespace('e2e-super-admin-read') + namespaceSlug = namespace.slug - const ownedCard = page.getByTestId('namespace-card-owned-team') - await expect(ownedCard.getByRole('button', { name: 'Manage Members' })).toBeVisible() - await expect(ownedCard.getByRole('button', { name: 'Review Tasks' })).toBeVisible() + ownerContext = await browser.newContext({ + extraHTTPHeaders: { + 'X-Mock-User-Id': 'local-user', + }, + }) + const ownerPage = await ownerContext.newPage() + ownerBuilder = new E2eTestDataBuilder(ownerPage, testInfo) + await ownerBuilder.init() - await visibleCard.click() + await adminBuilder.addNamespaceMember(namespace.slug, 'local-user') + const transferResponse = await page.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/transfer-ownership`, + { + data: { newOwnerId: 'local-user' }, + headers: await csrfHeaders(page), + }, + ) + expect(transferResponse.ok()).toBe(true) - await expect(page).toHaveURL(/\/space\/visible-no-role$/) - await expect(page.getByRole('heading', { name: 'Visible Without Membership' })).toBeVisible() - await expect(page.getByText('@visible-no-role')).toBeVisible() - }) + const removeAdminResponse = await ownerPage.context().request.delete( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/members/local-admin`, + { headers: await csrfHeaders(ownerPage) }, + ) + expect(removeAdminResponse.ok()).toBe(true) - test('opens archived non-member namespaces without showing a false empty skill list', async ({ page }) => { - await page.goto('/dashboard/namespaces') + const skillName = `super-admin-read-${Date.now().toString(36)}` + const skill = await ownerBuilder.publishSkill(namespace.slug, { + name: skillName, + description: 'Published namespace-only skill for the SUPER_ADMIN read-chain regression', + visibility: 'NAMESPACE_ONLY', + readmeHeading: skillName, + }) - const archivedCard = page.getByTestId('namespace-card-archived-no-role') - await expect(archivedCard.getByText('@archived-no-role')).toBeVisible() - await expect(archivedCard.getByText('Current role: Unknown')).toBeVisible() - await expect(archivedCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) - await expect(archivedCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version) + await adminBuilder.approveReview(reviewTaskId) - await archivedCard.click() + const archiveResponse = await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/archive`, + { + data: { reason: 'Validate SUPER_ADMIN archived namespace reads' }, + headers: await csrfHeaders(ownerPage), + }, + ) + expect(archiveResponse.ok()).toBe(true) + namespaceArchived = true - await expect(page).toHaveURL(/\/space\/archived-no-role$/) - await expect(page.getByRole('heading', { name: 'Archived Without Membership' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Archived Namespace Skill' })).toBeVisible() - await expect(page.getByText('namespace.emptyTitle')).toHaveCount(0) + await page.goto('/dashboard/namespaces') + + const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) + await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible() + await expect(namespaceCard.getByText('Current role: Unknown')).toBeVisible() + await expect(namespaceCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) + await expect(namespaceCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + + await namespaceCard.click() + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}$`)) + await expect(page.getByRole('heading', { name: namespace.displayName })).toBeVisible() + + const skillHeading = page.getByRole('heading', { name: skillName, exact: true }) + await expect(skillHeading).toBeVisible() + await skillHeading.click() + + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByRole('heading', { name: skillName, exact: true }).first()).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download', exact: true }).click() + const download = await downloadPromise + expect(await download.failure()).toBeNull() + expect(download.suggestedFilename()).toContain(skill.version) + } finally { + if (namespaceArchived && namespaceSlug && ownerContext) { + const ownerPage = ownerContext.pages()[0] + await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespaceSlug)}/restore`, + { headers: await csrfHeaders(ownerPage) }, + ).catch(() => undefined) + } + await ownerBuilder?.cleanup() + if (namespaceSlug && ownerContext) { + const ownerPage = ownerContext.pages()[0] + await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespaceSlug)}/archive`, + { + data: { reason: 'E2E cleanup' }, + headers: await csrfHeaders(ownerPage), + }, + ).catch(() => undefined) + } + await adminBuilder?.cleanup() + await ownerContext?.close() + } }) })