mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
fix(namespace): preserve super admin skill reads
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
daba212271
commit
2fa7a53e2b
12 changed files with 607 additions and 238 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue