From fa680a86f884b6112f77a40b6258a346ffe25be1 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 14:38:38 +0800 Subject: [PATCH] fix(compat): enforce anonymous installability rules --- .../compat/ClawHubCompatAppService.java | 18 ++++ .../service/SkillSearchAppService.java | 33 ++++++- .../service/SkillSearchAppServiceTest.java | 57 ++++++++++-- .../AnonymousSkillInstallabilityPolicy.java | 58 ++++++++++++ .../skill/service/SkillDownloadService.java | 14 ++- .../skill/service/SkillQueryService.java | 14 +-- ...nonymousSkillInstallabilityPolicyTest.java | 88 +++++++++++++++++++ .../service/SkillDownloadServiceTest.java | 37 +++++++- .../skill/service/SkillQueryServiceTest.java | 38 ++++++++ 9 files changed, 337 insertions(+), 20 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/AnonymousSkillInstallabilityPolicy.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/AnonymousSkillInstallabilityPolicyTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index b6fc5b1f..9b9c6876 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -131,6 +131,7 @@ public class ClawHubCompatAppService { public String downloadLocationByPath(String canonicalSlug, String version) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + assertResolvableDownloadTarget(coord, version, null, Map.of()); return "latest".equals(version) ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; @@ -141,11 +142,28 @@ public class ClawHubCompatAppService { String userId, Map userNsRoles) { SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); + assertResolvableDownloadTarget(coord, version, userId, userNsRoles); return "latest".equals(version) ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; } + private void assertResolvableDownloadTarget(SkillCoordinate coord, + String version, + String userId, + Map userNsRoles) { + boolean latest = version == null || "latest".equals(version); + skillQueryService.resolveVersion( + coord.namespace(), + coord.slug(), + latest ? null : version, + latest ? "latest" : null, + null, + userId, + normalizeRoles(userNsRoles) + ); + } + private SkillCoordinate resolveQueryCoordinate(String slug, String userId, Map userNsRoles) { 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 bffa778a..fe24733c 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 @@ -7,6 +7,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.service.AnonymousSkillInstallabilityPolicy; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.search.SearchQuery; @@ -35,7 +38,9 @@ public class SkillSearchAppService { private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; private final NamespaceService namespaceService; + private final SkillVersionRepository skillVersionRepository; private final SkillLifecycleProjectionService skillLifecycleProjectionService; + private final AnonymousSkillInstallabilityPolicy anonymousSkillInstallabilityPolicy; private final RbacService rbacService; public SkillSearchAppService( @@ -43,13 +48,17 @@ public class SkillSearchAppService { SkillRepository skillRepository, NamespaceRepository namespaceRepository, NamespaceService namespaceService, + SkillVersionRepository skillVersionRepository, SkillLifecycleProjectionService skillLifecycleProjectionService, + AnonymousSkillInstallabilityPolicy anonymousSkillInstallabilityPolicy, RbacService rbacService) { this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.namespaceService = namespaceService; + this.skillVersionRepository = skillVersionRepository; this.skillLifecycleProjectionService = skillLifecycleProjectionService; + this.anonymousSkillInstallabilityPolicy = anonymousSkillInstallabilityPolicy; this.rbacService = rbacService; } @@ -143,7 +152,7 @@ public class SkillSearchAppService { size, normalizeLabelSlugs(labelSlugs) )); - List pageItems = mapVisibleSkillSummaries(result.skillIds()); + List pageItems = mapVisibleSkillSummaries(result.skillIds(), scope); return new SearchResponse(pageItems, result.total(), page, size); } @@ -158,7 +167,7 @@ public class SkillSearchAppService { .toList(); } - private List mapVisibleSkillSummaries(List skillIds) { + private List mapVisibleSkillSummaries(List skillIds, SearchVisibilityScope scope) { if (skillIds.isEmpty()) { return List.of(); } @@ -177,16 +186,36 @@ public class SkillSearchAppService { .collect(Collectors.toMap(Namespace::getId, Function.identity())); Map namespaceSlugsById = namespacesById.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug())); + Map latestVersionsById = scope.userId() == null + ? loadLatestVersions(matchedSkills) + : Map.of(); Map projectionsBySkillId = skillLifecycleProjectionService.projectPublishedSummaries(matchedSkills); return skillIds.stream() .map(skillsById::get) .filter(java.util.Objects::nonNull) + .filter(skill -> scope.userId() != null || anonymousSkillInstallabilityPolicy.isAnonymousInstallable( + namespacesById.get(skill.getNamespaceId()), + skill, + latestVersionsById.get(skill.getLatestVersionId()))) .map(skill -> toSummaryResponse(skill, namespaceSlugsById, projectionsBySkillId.get(skill.getId()))) .toList(); } + private Map loadLatestVersions(List skills) { + List latestVersionIds = skills.stream() + .map(Skill::getLatestVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + if (latestVersionIds.isEmpty()) { + return Map.of(); + } + return skillVersionRepository.findByIdIn(latestVersionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + } + private SkillSummaryResponse toSummaryResponse( Skill skill, Map namespaceSlugsById, 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 index fdac46f8..e4fb0b21 100644 --- 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 @@ -8,8 +8,11 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.AnonymousSkillInstallabilityPolicy; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; @@ -64,7 +67,9 @@ class SkillSearchAppServiceTest { skillRepository, namespaceRepository, namespaceService, + skillVersionRepository, new SkillLifecycleProjectionService(skillVersionRepository), + new AnonymousSkillInstallabilityPolicy(), rbacService ); } @@ -87,6 +92,11 @@ class SkillSearchAppServiceTest { setField(visibleSkill, "id", 11L); visibleSkill.setLatestVersionId(111L); + SkillVersion latestVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setField(latestVersion, "id", 111L); + latestVersion.setStatus(SkillVersionStatus.PUBLISHED); + latestVersion.setDownloadReady(true); + Namespace activeNamespace = new Namespace("team-a", "Team A", "owner-1"); setField(activeNamespace, "id", 2L); activeNamespace.setStatus(NamespaceStatus.ACTIVE); @@ -95,9 +105,7 @@ class SkillSearchAppServiceTest { .thenReturn(new SearchResult(List.of(11L), 1, 0, 20)); when(skillRepository.findByIdIn(List.of(11L))).thenReturn(List.of(visibleSkill)); when(namespaceRepository.findByIdIn(List.of(2L))).thenReturn(List.of(activeNamespace)); - when(skillVersionRepository.findByIdIn(List.of(111L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); + when(skillVersionRepository.findByIdIn(List.of(111L))).thenReturn(List.of(latestVersion)); SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null); @@ -164,6 +172,15 @@ class SkillSearchAppServiceTest { setField(second, "id", 11L); second.setLatestVersionId(102L); + SkillVersion firstVersion = new SkillVersion(10L, "1.0.0", "owner-1"); + setField(firstVersion, "id", 101L); + firstVersion.setStatus(SkillVersionStatus.PUBLISHED); + firstVersion.setDownloadReady(true); + SkillVersion secondVersion = new SkillVersion(11L, "2.0.0", "owner-1"); + setField(secondVersion, "id", 102L); + secondVersion.setStatus(SkillVersionStatus.PUBLISHED); + secondVersion.setDownloadReady(true); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); setField(namespace, "id", 1L); namespace.setStatus(NamespaceStatus.ACTIVE); @@ -172,16 +189,38 @@ class SkillSearchAppServiceTest { .thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20)); when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(first, second)); when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); - when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); + when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of(firstVersion, secondVersion)); SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); assertEquals(2, response.items().size()); - verify(skillVersionRepository, times(1)).findByIdIn(List.of(101L, 102L)); - verify(skillVersionRepository, times(1)) - .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); + verify(skillVersionRepository, times(2)).findByIdIn(List.of(101L, 102L)); + } + + @Test + void search_shouldExcludeAnonymousPublicSkillWhenLatestVersionIsNotDownloadReady() { + Skill skill = new Skill(1L, "skill-a", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion latestVersion = new SkillVersion(10L, "1.0.0", "owner-1"); + setField(latestVersion, "id", 101L); + latestVersion.setStatus(SkillVersionStatus.PUBLISHED); + latestVersion.setDownloadReady(false); + + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(latestVersion)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(0, response.items().size()); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/AnonymousSkillInstallabilityPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/AnonymousSkillInstallabilityPolicy.java new file mode 100644 index 00000000..7ee46c07 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/AnonymousSkillInstallabilityPolicy.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +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.Skill; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.springframework.stereotype.Service; + +/** + * Defines the public install target contract for anonymous CLI consumers. + */ +@Service +public class AnonymousSkillInstallabilityPolicy { + + public boolean isAnonymousInstallable(Namespace namespace, Skill skill, SkillVersion version) { + return isActiveNamespace(namespace) + && isPublicActiveSkill(skill) + && isInstallableVersion(version); + } + + public boolean isInstallableVersion(SkillVersion version) { + return version != null + && version.getStatus() == SkillVersionStatus.PUBLISHED + && version.isDownloadReady() + && version.getYankedAt() == null; + } + + public void assertAnonymousInstallable(Namespace namespace, Skill skill, SkillVersion version) { + if (!isActiveNamespace(namespace)) { + throw new DomainForbiddenException("error.namespace.archived", namespace == null ? null : namespace.getSlug()); + } + if (!isPublicActiveSkill(skill)) { + throw new DomainForbiddenException("error.skill.access.denied", skill == null ? null : skill.getSlug()); + } + if (version == null) { + throw new DomainBadRequestException("error.skill.version.latest.unavailable", skill.getSlug()); + } + if (!isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); + } + } + + private boolean isActiveNamespace(Namespace namespace) { + return namespace != null && namespace.getStatus() == NamespaceStatus.ACTIVE; + } + + private boolean isPublicActiveSkill(Skill skill) { + return skill != null + && skill.getStatus() == SkillStatus.ACTIVE + && !skill.isHidden() + && skill.getVisibility() == SkillVisibility.PUBLIC; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index b53a6c63..c6d77a52 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -45,6 +45,7 @@ public class SkillDownloadService { private final VisibilityChecker visibilityChecker; private final ApplicationEventPublisher eventPublisher; private final SkillSlugResolutionService skillSlugResolutionService; + private final AnonymousSkillInstallabilityPolicy anonymousSkillInstallabilityPolicy; public SkillDownloadService( NamespaceRepository namespaceRepository, @@ -56,7 +57,8 @@ public class SkillDownloadService { ObjectStorageService objectStorageService, VisibilityChecker visibilityChecker, ApplicationEventPublisher eventPublisher, - SkillSlugResolutionService skillSlugResolutionService) { + SkillSlugResolutionService skillSlugResolutionService, + AnonymousSkillInstallabilityPolicy anonymousSkillInstallabilityPolicy) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -67,6 +69,7 @@ public class SkillDownloadService { this.visibilityChecker = visibilityChecker; this.eventPublisher = eventPublisher; this.skillSlugResolutionService = skillSlugResolutionService; + this.anonymousSkillInstallabilityPolicy = anonymousSkillInstallabilityPolicy; } public record DownloadResult( @@ -102,6 +105,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); + assertAnonymousInstallable(namespace, skill, version, currentUserId); return downloadVersion(skill, version); } @@ -123,6 +127,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); + assertAnonymousInstallable(namespace, skill, version, currentUserId); return downloadVersion(skill, version); } @@ -150,6 +155,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findById(tag.getVersionId()) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.version.notFound", tagName)); + assertAnonymousInstallable(namespace, skill, version, currentUserId); return downloadVersion(skill, version); } @@ -179,6 +185,12 @@ public class SkillDownloadService { eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); } + private void assertAnonymousInstallable(Namespace namespace, Skill skill, SkillVersion version, String currentUserId) { + if (currentUserId == null) { + anonymousSkillInstallabilityPolicy.assertAnonymousInstallable(namespace, skill, version); + } + } + private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { String storageKey = buildBundleStorageKey(skill, version); 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 7d966327..076e3359 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 @@ -60,6 +60,7 @@ public class SkillQueryService { private final ReviewTaskRepository reviewTaskRepository; private final SkillSlugResolutionService skillSlugResolutionService; private final SkillLifecycleProjectionService skillLifecycleProjectionService; + private final AnonymousSkillInstallabilityPolicy anonymousSkillInstallabilityPolicy; private final UserAccountRepository userAccountRepository; public SkillQueryService( @@ -74,6 +75,7 @@ public class SkillQueryService { ReviewTaskRepository reviewTaskRepository, SkillSlugResolutionService skillSlugResolutionService, SkillLifecycleProjectionService skillLifecycleProjectionService, + AnonymousSkillInstallabilityPolicy anonymousSkillInstallabilityPolicy, UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; @@ -86,6 +88,7 @@ public class SkillQueryService { this.reviewTaskRepository = reviewTaskRepository; this.skillSlugResolutionService = skillSlugResolutionService; this.skillLifecycleProjectionService = skillLifecycleProjectionService; + this.anonymousSkillInstallabilityPolicy = anonymousSkillInstallabilityPolicy; this.userAccountRepository = userAccountRepository; } @@ -487,13 +490,7 @@ public class SkillQueryService { } public boolean isDownloadAvailable(SkillVersion version) { - if (version == null) { - return false; - } - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - return false; - } - return version.isDownloadReady(); + return anonymousSkillInstallabilityPolicy.isInstallableVersion(version); } public ReviewSkillSnapshotDTO getReviewSkillSnapshot(Long skillVersionId) { @@ -565,6 +562,9 @@ public class SkillQueryService { Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); + if (currentUserId == null) { + anonymousSkillInstallabilityPolicy.assertAnonymousInstallable(namespace, skill, resolved); + } String fingerprint = computeFingerprint(resolved); Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/AnonymousSkillInstallabilityPolicyTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/AnonymousSkillInstallabilityPolicyTest.java new file mode 100644 index 00000000..a9984ebf --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/AnonymousSkillInstallabilityPolicyTest.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.domain.skill.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class AnonymousSkillInstallabilityPolicyTest { + + private final AnonymousSkillInstallabilityPolicy policy = new AnonymousSkillInstallabilityPolicy(); + + @Test + void isAnonymousInstallable_returnsTrueForActivePublicSkillWithReadyPublishedVersion() { + assertTrue(policy.isAnonymousInstallable(activeNamespace(), publicSkill(), readyPublishedVersion())); + } + + @Test + void isAnonymousInstallable_returnsFalseWhenNamespaceIsArchived() { + Namespace namespace = activeNamespace(); + namespace.setStatus(NamespaceStatus.ARCHIVED); + + assertFalse(policy.isAnonymousInstallable(namespace, publicSkill(), readyPublishedVersion())); + } + + @Test + void isAnonymousInstallable_returnsFalseWhenSkillIsNotPublicActiveAndVisible() { + Skill hidden = publicSkill(); + hidden.setHidden(true); + assertFalse(policy.isAnonymousInstallable(activeNamespace(), hidden, readyPublishedVersion())); + + Skill archived = publicSkill(); + archived.setStatus(SkillStatus.ARCHIVED); + assertFalse(policy.isAnonymousInstallable(activeNamespace(), archived, readyPublishedVersion())); + + assertFalse(policy.isAnonymousInstallable( + activeNamespace(), + new Skill(1L, "demo", "owner-1", SkillVisibility.NAMESPACE_ONLY), + readyPublishedVersion())); + assertFalse(policy.isAnonymousInstallable( + activeNamespace(), + new Skill(1L, "demo", "owner-1", SkillVisibility.PRIVATE), + readyPublishedVersion())); + } + + @Test + void isAnonymousInstallable_returnsFalseWhenVersionIsNotPublishedReadyAndNotYanked() { + assertFalse(policy.isAnonymousInstallable(activeNamespace(), publicSkill(), null)); + + SkillVersion draft = readyPublishedVersion(); + draft.setStatus(SkillVersionStatus.DRAFT); + assertFalse(policy.isAnonymousInstallable(activeNamespace(), publicSkill(), draft)); + + SkillVersion notReady = readyPublishedVersion(); + notReady.setDownloadReady(false); + assertFalse(policy.isAnonymousInstallable(activeNamespace(), publicSkill(), notReady)); + + SkillVersion yankedStatus = readyPublishedVersion(); + yankedStatus.setStatus(SkillVersionStatus.YANKED); + assertFalse(policy.isAnonymousInstallable(activeNamespace(), publicSkill(), yankedStatus)); + + SkillVersion yankedMarker = readyPublishedVersion(); + yankedMarker.setYankedAt(Instant.parse("2026-03-01T10:00:00Z")); + assertFalse(policy.isAnonymousInstallable(activeNamespace(), publicSkill(), yankedMarker)); + } + + private Namespace activeNamespace() { + return new Namespace("global", "Global", "system"); + } + + private Skill publicSkill() { + return new Skill(1L, "demo", "owner-1", SkillVisibility.PUBLIC); + } + + private SkillVersion readyPublishedVersion() { + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + return version; + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 4bc20d34..4679cd2f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -68,7 +68,8 @@ class SkillDownloadServiceTest { objectStorageService, visibilityChecker, eventPublisher, - skillSlugResolutionService + skillSlugResolutionService, + new AnonymousSkillInstallabilityPolicy() ); } @@ -281,6 +282,38 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadLatest_ShouldRejectAnonymousWhenLatestVersionIsNotDownloadReadyBeforeStorageLookup() throws Exception { + Namespace namespace = new Namespace("global", "Global", "system"); + setId(namespace, 1L); + namespace.setType(NamespaceType.GLOBAL); + + Skill skill = new Skill(1L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setDisplayName("Demo 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(false); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest("global", "demo-skill", null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + verifyNoInteractions(objectStorageService, skillFileRepository); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + @Test void testDownloadVersion_AllowsAnonymousForGlobalPublicSkill() throws Exception { Namespace namespace = new Namespace("global", "Global", "system"); @@ -296,6 +329,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); @@ -331,6 +365,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); 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 7ee683ac..0be1080c 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 @@ -80,6 +80,7 @@ class SkillQueryServiceTest { reviewTaskRepository, skillSlugResolutionService, skillLifecycleProjectionService, + new AnonymousSkillInstallabilityPolicy(), userAccountRepository ); } @@ -595,6 +596,42 @@ class SkillQueryServiceTest { assertTrue(result.downloadUrl().contains("/versions/1.1.0/download")); } + @Test + void testResolveVersion_ShouldRejectAnonymousLatestWhenDownloadIsNotReadyBeforeFingerprinting() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "test-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "system"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> service.resolveVersion( + namespaceSlug, + skillSlug, + null, + "latest", + null, + null, + Map.of() + )); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + verify(skillFileRepository, never()).findByVersionId(anyLong()); + verifyNoInteractions(objectStorageService); + } + @Test void testResolveVersion_ShouldEncodeDownloadUrlPathSegments() throws Exception { String namespaceSlug = "global"; @@ -611,6 +648,7 @@ class SkillQueryServiceTest { SkillVersion version = new SkillVersion(3L, "1.0.0 beta", "user-100"); setId(version, 11L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));