fix(namespace): preserve super admin skill reads

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-22 17:34:14 +08:00
parent daba212271
commit 2fa7a53e2b
12 changed files with 607 additions and 238 deletions

View file

@ -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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
Page<SkillVersion> versions = skillQueryService.listVersions(
namespace,
slug,
userId,
userNsRoles != null ? userNsRoles : Map.of(),
PageRequest.of(page, size));
PageRequest.of(page, size),
normalizePlatformRoles(platformRoles));
PageResponse<SkillVersionResponse> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
List<SkillFile> files = skillQueryService.listFiles(
namespace,
slug,
version,
userId,
userNsRoles != null ? userNsRoles : Map.of()
userNsRoles != null ? userNsRoles : Map.of(),
normalizePlatformRoles(platformRoles)
);
List<SkillFileResponse> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
List<SkillFile> files = skillQueryService.listFilesByTag(
namespace,
slug,
tagName,
userId,
userNsRoles != null ? userNsRoles : Map.of()
userNsRoles != null ? userNsRoles : Map.of(),
normalizePlatformRoles(platformRoles)
);
List<SkillFileResponse> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<String> normalizePlatformRoles(Set<String> platformRoles) {
return platformRoles != null ? platformRoles : Set.of();
}
private boolean shouldRedirectToPresignedUrl(HttpServletRequest request, String presignedUrl) {
if (presignedUrl == null || presignedUrl.isBlank()) {
return false;

View file

@ -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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>of()),
anySet()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L,
"team",
@ -150,7 +155,8 @@ class SkillControllerTest {
eq("team"),
eq("demo"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>of()),
anySet()))
.thenReturn(new SkillQueryService.SkillDetailDTO(
1L,
"demo",
@ -198,7 +204,8 @@ class SkillControllerTest {
eq("team"),
eq("demo"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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<String> platformRoles = Set.of("SUPER_ADMIN");
when(skillQueryService.getSkillDetail(
eq("team"),
eq("demo"),
eq("super-1"),
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>of()),
anySet()))
.thenThrow(new DomainBadRequestException("error.skill.version.compare.same"));
mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare")

View file

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

View file

@ -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",

View file

@ -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<Long, NamespaceRole> 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);
}

View file

@ -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<Long, NamespaceRole> userNsRoles) {
return downloadLatest(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of());
}
public DownloadResult downloadLatest(
String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return downloadVersion(namespaceSlug, skillSlug, versionStr, currentUserId, userNsRoles, Set.of());
}
public DownloadResult downloadVersion(
String namespaceSlug,
String skillSlug,
String versionStr,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return downloadByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of());
}
public DownloadResult downloadByTag(
String namespaceSlug,
String skillSlug,
String tagName,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
return skillSlugResolutionService.resolve(
namespaceId,

View file

@ -198,15 +198,34 @@ public class SkillQueryService {
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of());
}
public SkillDetailDTO getSkillDetail(
String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return getVersionDetail(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of());
}
public SkillVersionDetailDTO getVersionDetail(
String namespaceSlug,
String skillSlug,
String version,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return listFiles(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of());
}
public List<SkillFile> listFiles(
String namespaceSlug,
String skillSlug,
String version,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return listFilesByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of());
}
public List<SkillFile> listFilesByTag(
String namespaceSlug,
String skillSlug,
String tagName,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
return listVersions(namespaceSlug, skillSlug, currentUserId, userNsRoles, pageable, Set.of());
}
public Page<SkillVersion> listVersions(String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable,
Set<String> platformRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles);
List<SkillVersion> visibleVersions;
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
@ -551,13 +641,25 @@ public class SkillQueryService {
String hash,
String currentUserId,
Map<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
return currentUserId != null && userNsRoles.containsKey(namespaceId);
}

View file

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

View file

@ -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<Long, NamespaceRole> userNsRoles = Map.of();
Set<String> 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");

View file

@ -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<SkillVersion> 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";

View file

@ -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),
}),

View file

@ -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()
}
})
})