diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 7c1b4a88..aab4b04e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -89,19 +91,21 @@ public class SkillSearchAppService { .map(Skill::getNamespaceId) .distinct() .toList(); - Map namespaceSlugsById = namespaceIds.isEmpty() + Map namespacesById = namespaceIds.isEmpty() ? Map.of() : namespaceRepository.findByIdIn(namespaceIds).stream() - .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, - com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + .collect(Collectors.toMap(Namespace::getId, Function.identity())); + Map namespaceSlugsById = namespacesById.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug())); List skills = result.skillIds().stream() .map(skillsById::get) .filter(java.util.Objects::nonNull) + .filter(skill -> namespaceVisible(skill.getNamespaceId(), namespacesById, userId, userNsRoles)) .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) .toList(); - return new SearchResponse(skills, result.total(), result.page(), result.size()); + return new SearchResponse(skills, skills.size(), result.page(), result.size()); } private Long resolveNamespaceId(String namespaceSlug) { @@ -158,4 +162,18 @@ public class SkillSearchAppService { skill.getUpdatedAt() ); } + + private boolean namespaceVisible( + Long namespaceId, + Map namespacesById, + String userId, + Map userNsRoles) { + NamespaceStatus status = java.util.Optional.ofNullable(namespacesById.get(namespaceId)) + .map(Namespace::getStatus) + .orElse(NamespaceStatus.ACTIVE); + if (status != NamespaceStatus.ARCHIVED) { + return true; + } + return userId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index d34698f0..f1a4c2d7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; @@ -138,6 +139,20 @@ class SkillControllerTest { .andExpect(jsonPath("$.data.canInteract").value(false)); } + @Test + void getSkillDetailShouldReturnForbiddenForArchivedNamespace() throws Exception { + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq((String) null), + eq(Map.of()))) + .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); + + mockMvc.perform(get("/api/web/skills/team/demo")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + @Test void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.listFilesByTag( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java index d0252b70..8531bf79 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -12,6 +12,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -40,11 +41,11 @@ class SkillSearchControllerTest { eq("newest"), eq(0), eq(20), - eq((String) null), - eq(null))) + any(), + any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); - mockMvc.perform(get("/api/v1/skills") + mockMvc.perform(get("/api/web/skills") .param("q", "review") .param("namespace", "global")) .andExpect(status().isOk()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java new file mode 100644 index 00000000..7d34088f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.search.SearchQueryService; +import com.iflytek.skillhub.search.SearchResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSearchAppServiceTest { + + @Mock + private SearchQueryService searchQueryService; + + @Mock + private SkillRepository skillRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + private SkillSearchAppService service; + + @BeforeEach + void setUp() { + service = new SkillSearchAppService(searchQueryService, skillRepository, namespaceRepository, skillVersionRepository); + } + + @Test + void search_shouldExcludeArchivedNamespaceSkillsForAnonymousUsers() { + Skill archivedSkill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC); + setField(archivedSkill, "id", 10L); + + Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1"); + setField(archivedNamespace, "id", 1L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + + when(searchQueryService.search(org.mockito.ArgumentMatchers.any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(archivedSkill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(archivedNamespace)); + + SkillSearchAppService.SearchResponse response = service.search("archive", null, "newest", 0, 20, null, null); + + assertEquals(0, response.items().size()); + assertEquals(0, response.total()); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index d03565f5..b63e0053 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -170,8 +171,9 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId); @@ -194,8 +196,9 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId); @@ -209,8 +212,9 @@ public class SkillQueryService { String tagName, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); return skillFileRepository.findByVersionId(skillVersion.getId()); } @@ -222,8 +226,9 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId); @@ -240,8 +245,9 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); return readFileContent(file); @@ -252,8 +258,9 @@ public class SkillQueryService { String currentUserId, Map userNsRoles, Pageable pageable) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); List visibleVersions; if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() @@ -295,8 +302,9 @@ public class SkillQueryService { throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict"); } - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); String fingerprint = computeFingerprint(resolved); Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); @@ -324,6 +332,10 @@ public class SkillQueryService { private Skill findSkill(String namespaceSlug, String skillSlug) { Namespace namespace = findNamespace(namespaceSlug); + return findSkill(namespace, skillSlug); + } + + private Skill findSkill(Namespace namespace, String skillSlug) { return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); } @@ -440,7 +452,14 @@ public class SkillQueryService { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); } - private void assertPublishedAccessible(Skill skill, String currentUserId, Map userNsRoles) { + private void assertPublishedAccessible( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); + } if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } @@ -466,6 +485,10 @@ public class SkillQueryService { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { + return currentUserId != null && userNsRoles.containsKey(namespaceId); + } + private int lifecycleListPriority(SkillVersionStatus status) { if (status == SkillVersionStatus.PUBLISHED) { return 0; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 143e8b7a..dd9b40a9 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -118,6 +119,24 @@ class SkillQueryServiceTest { ); } + @Test + void testGetSkillDetail_ShouldHideArchivedNamespaceFromAnonymousUsers() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "test-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "user-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> + service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of())); + } + @Test void testListSkillsByNamespace() throws Exception { // Arrange