feat(access): add SUPER_ADMIN platform role support

- Add platformRoles parameter to VisibilityChecker.canAccess() for platform-level access control
- SUPER_ADMIN can access all skills regardless of visibility or publication status
- Add archived namespace check to SkillQueryService.getSkillDetail()
- Extract platformRoles from AuthContext in SkillController
- Replace VisibilityChecker mock with real instance in SkillQueryServiceTest
- Add 5 new tests for SUPER_ADMIN access scenarios
- Add version-status-badge.tsx component for frontend status display

Tests: 347 domain tests + 16 app tests passing
This commit is contained in:
xiose 2026-04-14 12:07:25 +08:00
parent 9a6f649e6f
commit 9801b549fc
9 changed files with 183 additions and 41 deletions

View file

@ -70,10 +70,12 @@ 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) java.util.Set<String> platformRoles) {
SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail(
namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of());
namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of(),
platformRoles != null ? platformRoles : java.util.Set.<String>of());
SkillDetailResponse response = new SkillDetailResponse(
detail.id(),

View file

@ -73,6 +73,7 @@ public class AuthContextFilter extends OncePerRequestFilter {
return;
}
request.setAttribute("userId", principal.userId());
request.setAttribute("platformRoles", principal.platformRoles() != null ? principal.platformRoles() : java.util.Set.of());
Map<Long, NamespaceRole> userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream()
.collect(Collectors.toMap(
NamespaceMember::getNamespaceId,

View file

@ -22,6 +22,7 @@ import java.util.Map;
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.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -148,7 +149,8 @@ class SkillControllerTest {
eq("team"),
eq("demo"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>of()),
org.mockito.ArgumentMatchers.<java.util.Set<String>>any()))
.thenReturn(new SkillQueryService.SkillDetailDTO(
1L,
"demo",
@ -195,7 +197,8 @@ class SkillControllerTest {
eq("team"),
eq("demo"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>of()),
org.mockito.ArgumentMatchers.<java.util.Set<String>>any()))
.thenThrow(new DomainForbiddenException("error.namespace.archived", "team"));
mockMvc.perform(get("/api/web/skills/team/demo"))

View file

@ -4,6 +4,9 @@ import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import org.mockito.ArgumentMatchers;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
@ -69,7 +72,7 @@ class SkillPublishControllerTest {
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
ArgumentMatchers.<List<PackageEntry>>any(),
eq("usr_1"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),
@ -120,7 +123,7 @@ class SkillPublishControllerTest {
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
ArgumentMatchers.<List<PackageEntry>>any(),
eq("usr_1"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import java.util.Map;
import java.util.Set;
/**
* Evaluates whether a caller may read a skill based on publication state, visibility, ownership,
@ -11,6 +12,13 @@ import java.util.Map;
public class VisibilityChecker {
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
return canAccess(skill, currentUserId, userNamespaceRoles, Set.of());
}
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles, Set<String> platformRoles) {
if (isSuperAdmin(platformRoles)) {
return true;
}
if (skill.isHidden()) {
return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId()));
}
@ -31,4 +39,8 @@ public class VisibilityChecker {
private boolean isAdminOrAbove(NamespaceRole role) {
return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER;
}
private boolean isSuperAdmin(Set<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
}

View file

@ -30,6 +30,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -149,12 +150,28 @@ 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);
// Archived namespace: only members (or super admins) may view
if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED
&& !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)
&& !isSuperAdmin(platformRoles)) {
throw new DomainForbiddenException("error.namespace.archived", namespaceSlug);
}
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles, platformRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
@ -186,7 +203,7 @@ public class SkillQueryService {
skill.getNamespaceId(),
skill.getCreatedAt(),
skill.getUpdatedAt(),
canManageRestrictedSkill(skill, currentUserId, userNsRoles),
canManageRestrictedSkill(skill, currentUserId, userNsRoles, platformRoles),
canSubmitPromotion(namespace, skill, publishedVersion, currentUserId, userNsRoles),
headlineVersion == null || "PUBLISHED".equals(headlineVersion.status()),
currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId),
@ -630,6 +647,13 @@ public class SkillQueryService {
}
private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
return canManageRestrictedSkill(skill, currentUserId, userNsRoles, Set.of());
}
private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles, Set<String> platformRoles) {
if (platformRoles != null && platformRoles.contains("SUPER_ADMIN")) {
return true;
}
if (currentUserId == null) {
return false;
}
@ -671,6 +695,10 @@ public class SkillQueryService {
return currentUserId != null && userNsRoles.containsKey(namespaceId);
}
private boolean isSuperAdmin(Set<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
private String resolveOwnerPreviewReviewComment(SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion) {
if (ownerPreviewVersion == null || !"REJECTED".equals(ownerPreviewVersion.status())) {
return null;

View file

@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
@ -158,4 +159,34 @@ class VisibilityCheckerTest {
boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles);
assertTrue(canAccess);
}
@Test
void testSuperAdminCanAccessPrivateSkill() {
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
assertTrue(canAccess);
}
@Test
void testSuperAdminCanAccessHiddenSkill() {
boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
assertTrue(canAccess);
}
@Test
void testSuperAdminCanAccessUnpublishedSkill() {
boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
assertTrue(canAccess);
}
@Test
void testNonSuperAdminPlatformRolesDoNotGrantAccess() {
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("REVIEWER"));
assertFalse(canAccess);
}
@Test
void testEmptyPlatformRolesDoNotGrantAccess() {
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of());
assertFalse(canAccess);
}
}

View file

@ -30,6 +30,7 @@ import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@ -50,7 +51,6 @@ class SkillQueryServiceTest {
private SkillTagRepository skillTagRepository;
@Mock
private ObjectStorageService objectStorageService;
@Mock
private VisibilityChecker visibilityChecker;
@Mock
private PromotionRequestRepository promotionRequestRepository;
@ -65,6 +65,7 @@ class SkillQueryServiceTest {
@BeforeEach
void setUp() {
visibilityChecker = new VisibilityChecker();
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository);
service = new SkillQueryService(
@ -105,7 +106,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
when(userAccountRepository.findById(userId)).thenReturn(Optional.of(new UserAccount(userId, "Alice", "alice@example.com", null)));
@ -148,7 +148,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill));
when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -176,7 +175,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
// Act & Assert
assertThrows(DomainForbiddenException.class, () ->
@ -215,13 +213,11 @@ class SkillQueryServiceTest {
setId(namespace, 1L);
Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC);
setId(skill1, 1L);
Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE);
Skill skill2 = new Skill(1L, "skill2", "user-200", SkillVisibility.PRIVATE);
setId(skill2, 2L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2));
when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true);
when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false);
// Act
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@ -267,8 +263,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill));
when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true);
when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false);
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@ -296,8 +290,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(visibleSkill, hiddenSkill));
when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true);
when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false);
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@ -325,7 +317,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1));
when(objectStorageService.exists("key1")).thenReturn(true);
@ -358,7 +349,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, callerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
assertThrows(DomainBadRequestException.class, () ->
@ -385,7 +375,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file));
when(objectStorageService.exists(file.getStorageKey())).thenReturn(true);
@ -419,7 +408,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(availableFile, missingFile));
when(objectStorageService.exists("skills/1/1/SKILL.md")).thenReturn(true);
@ -482,7 +470,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
@ -516,7 +503,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
when(objectStorageService.exists("storage-key")).thenReturn(true);
@ -555,7 +541,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected));
Page<SkillVersion> result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20));
@ -589,7 +574,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(version100, version110));
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110));
@ -631,7 +615,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
@ -664,7 +647,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -691,7 +673,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty());
@ -723,7 +704,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class)));
@ -753,7 +733,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED))
@ -784,7 +763,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -813,7 +791,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(pending));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of());
@ -853,7 +830,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
@ -889,7 +865,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(rejected));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of());
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected));
@ -925,7 +900,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
@ -960,7 +934,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
when(objectStorageService.exists("storage-key")).thenReturn(true);
@ -992,7 +965,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
assertThrows(DomainBadRequestException.class, () ->
@ -1024,7 +996,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
Page<SkillVersion> result = service.listVersions(
@ -1059,7 +1030,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));
Page<SkillVersion> result = service.listVersions(

View file

@ -0,0 +1,92 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/shared/lib/utils'
type VersionStatus =
| 'DRAFT'
| 'SCANNING'
| 'SCAN_FAILED'
| 'UPLOADED'
| 'PENDING_REVIEW'
| 'PUBLISHED'
| 'REJECTED'
| 'YANKED'
const statusStyles: Record<VersionStatus, string> = {
PUBLISHED:
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
UPLOADED:
'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400',
PENDING_REVIEW:
'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400',
REJECTED:
'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400',
SCANNING:
'border-purple-500/30 bg-purple-500/10 text-purple-700 dark:text-purple-400',
SCAN_FAILED:
'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400',
YANKED:
'border-border/60 bg-secondary/40 text-muted-foreground',
DRAFT:
'border-border/60 bg-secondary/40 text-muted-foreground',
}
const i18nKeys: Record<VersionStatus, string> = {
DRAFT: 'skillDetail.versionStatusDraft',
SCANNING: 'skillDetail.versionStatusScanning',
SCAN_FAILED: 'skillDetail.versionStatusScanFailed',
UPLOADED: 'skillDetail.versionStatusUploaded',
PENDING_REVIEW: 'skillDetail.versionStatusPendingReview',
PUBLISHED: 'skillDetail.versionStatusPublished',
REJECTED: 'skillDetail.versionStatusRejected',
YANKED: 'skillDetail.versionStatusYanked',
}
/** Color-coded row styles (left-border + subtle background) for version cards. */
export const versionRowStyles: Record<VersionStatus, string> = {
UPLOADED:
'border-l-[3px] !border-l-blue-500 bg-blue-500/[0.03]',
PENDING_REVIEW:
'border-l-[3px] !border-l-amber-500 bg-amber-500/[0.03]',
REJECTED:
'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]',
SCANNING:
'border-l-[3px] !border-l-purple-500 bg-purple-500/[0.03]',
SCAN_FAILED:
'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]',
PUBLISHED: '',
YANKED: '',
DRAFT: '',
}
export function getVersionRowStyle(status?: string): string {
if (!status) return ''
return versionRowStyles[status as VersionStatus] ?? ''
}
export function VersionStatusBadge({
status,
className,
}: {
status?: string
className?: string
}) {
const { t } = useTranslation()
if (!status) return null
const style = statusStyles[status as VersionStatus] ?? statusStyles.DRAFT
const label = i18nKeys[status as VersionStatus]
? t(i18nKeys[status as VersionStatus])
: status
return (
<span
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
style,
className,
)}
>
{label}
</span>
)
}