From 3a111dc78c4078420eec2d0603bc6604f43d9579 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 14:43:23 +0800 Subject: [PATCH 1/2] fix(cli): align anonymous installability rules Signed-off-by: dongmucat <1127093059@qq.com> --- .../service/cli/CliSkillAppService.java | 5 +- .../service/SkillSearchAppServiceTest.java | 32 ++++++++ .../service/cli/CliSkillAppServiceTest.java | 31 ++++++++ .../domain/skill/SkillInstallability.java | 18 +++++ .../skill/service/SkillDownloadService.java | 20 +---- .../SkillLifecycleProjectionService.java | 7 +- .../skill/service/SkillQueryService.java | 15 ++-- .../service/SkillDownloadServiceTest.java | 73 +++++++++++++++++++ .../skill/service/SkillQueryServiceTest.java | 44 +++++++++++ 9 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index e431eaf33..a4030ebf7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -56,15 +56,16 @@ public CliSearchResult search(String q, int limit, String userId, Map items = response.items().stream() + .filter(item -> item.publishedVersion() != null) .map(item -> new CliSearchItem( item.namespace(), item.slug(), - item.publishedVersion() != null ? item.publishedVersion().version() : null, + item.publishedVersion().version(), item.summary() )) .toList(); - return new CliSearchResult(items, response.total(), limit); + return new CliSearchResult(items, items.size(), limit); } public CliResolveResponse resolve(String namespace, String slug, String version, String userId, Map userNsRoles) { 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 fdac46f83..984be1d07 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,7 +8,9 @@ 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.SkillLifecycleProjectionService; import com.iflytek.skillhub.search.SearchQuery; @@ -184,6 +186,36 @@ void search_shouldResolvePublishedVersionsInBatch() { .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); } + @Test + void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { + Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion version = new SkillVersion(10L, "1.0.0", "owner-1"); + setField(version, "id", 101L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + Namespace namespace = new Namespace("global", "Global", "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(version)); + when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(version)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("not-ready", response.items().getFirst().slug()); + assertEquals(null, response.items().getFirst().publishedVersion()); + } + @Test void search_shouldNormalizeAndPassLabelSlugs() { when(searchQueryService.search(any())) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index b7fbe1d7f..b2c230fe0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -76,6 +76,37 @@ void search_mapsResultsToCliFormat() { assertEquals(20, result.limit()); } + @Test + void search_filtersResultsWithoutInstallablePublishedVersion() { + var searchResponse = new SkillSearchAppService.SearchResponse( + List.of( + new SkillSummaryResponse( + 1L, "draft-only", "Draft Only", "No installable version", + "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, + "global", Instant.now(), false, + null, null, null, "NONE" + ), + new SkillSummaryResponse( + 2L, "ready", "Ready", "Installable", + "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, + "global", Instant.now(), false, + new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), + new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), + null, "PUBLISHED" + ) + ), + 2L, 0, 20 + ); + given(skillSearchAppService.search("demo", null, "newest", 0, 20, null, null)) + .willReturn(searchResponse); + + var result = service.search("demo", 20, null, null); + + assertEquals(1, result.items().size()); + assertEquals("ready", result.items().getFirst().slug()); + assertEquals(1L, result.total()); + } + @Test void resolve_delegatesToQueryService() { given(skillQueryService.resolveVersion("global", "demo", "2.0.0", null, null, "user-1", Map.of())) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java new file mode 100644 index 000000000..dfeeed1c4 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.domain.skill; + +/** + * Defines whether a skill version can be installed through public download + * paths. Storage object presence is checked later by the download service so + * fallback bundle behavior stays separate from domain publication state. + */ +public final class SkillInstallability { + private SkillInstallability() { + } + + public static boolean isInstallableVersion(SkillVersion version) { + return version != null + && version.getStatus() == SkillVersionStatus.PUBLISHED + && version.isDownloadReady() + && version.getYankedAt() == null; + } +} 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 b53a6c63b..e5df44cc9 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 @@ -163,7 +163,7 @@ public DownloadResult downloadReviewVersion(Skill skill, SkillVersion version) { private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); - assertDownloadableVersion(skill, version); + assertDownloadableVersion(version); DownloadResult result = buildDownloadResult(skill, version); // Only increment download count for PUBLISHED versions @@ -301,21 +301,9 @@ private void assertPublishedAccessible(Skill skill) { } } - /** - * Asserts that the version can be downloaded. - * - PUBLISHED: anyone with skill access can download - * - UPLOADED/PENDING_REVIEW: only skill owner can download - */ - private void assertDownloadableVersion(Skill skill, SkillVersion version) { - switch (version.getStatus()) { - case PUBLISHED -> { - // Anyone with skill access can download published versions - } - case UPLOADED, PENDING_REVIEW -> { - // Only owner can download UPLOADED/PENDING_REVIEW versions - // Note: This check is already done in assertCanDownload via visibilityChecker - } - default -> throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); + private void assertDownloadableVersion(SkillVersion version) { + if (!SkillInstallability.isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 368ae850f..1ad3fd356 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -2,6 +2,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillInstallability; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; @@ -91,7 +92,7 @@ public Map projectPublishedSummaries(List skills) { List unresolvedSkillIds = new java.util.ArrayList<>(); for (Skill skill : skills) { SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId()); - if (latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED) { + if (SkillInstallability.isInstallableVersion(latestVersion)) { publishedBySkillId.put(skill.getId(), latestVersion); } else { unresolvedSkillIds.add(skill.getId()); @@ -100,7 +101,9 @@ public Map projectPublishedSummaries(List skills) { if (!unresolvedSkillIds.isEmpty()) { for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) { - publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); + if (SkillInstallability.isInstallableVersion(version)) { + publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); + } } } 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 7d966327f..66c5e3191 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 @@ -487,13 +487,7 @@ public Page listVersions(String namespaceSlug, } public boolean isDownloadAvailable(SkillVersion version) { - if (version == null) { - return false; - } - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - return false; - } - return version.isDownloadReady(); + return SkillInstallability.isInstallableVersion(version); } public ReviewSkillSnapshotDTO getReviewSkillSnapshot(Long skillVersionId) { @@ -565,6 +559,7 @@ public ResolvedVersionDTO resolveVersion( Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); + assertInstallableVersion(resolved, resolved.getVersion()); String fingerprint = computeFingerprint(resolved); Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); @@ -916,6 +911,12 @@ private void assertPublishedVersion(SkillVersion version, String versionStr) { } } + private void assertInstallableVersion(SkillVersion version, String versionStr) { + if (!SkillInstallability.isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", versionStr); + } + } + /** * Checks whether the caller may preview a specific version's files and metadata. * Published versions are visible to everyone; all other statuses are restricted 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 4bc20d344..7a40f12bf 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 @@ -91,6 +91,7 @@ void testDownloadLatest_Success() throws Exception { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -136,6 +137,7 @@ void testDownloadByTag_Success() throws Exception { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -179,6 +181,7 @@ void testDownloadVersion_WithPresignedUrlStillProvidesStreamFallback() throws Ex SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -231,6 +234,73 @@ void testDownloadVersion_ShouldRejectDraftVersion() throws Exception { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_ShouldRejectDownloadUnavailablePublishedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + 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(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{versionStr}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadVersion_ShouldRejectYankedPublishedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + 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.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{versionStr}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + @Test void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throws Exception { String namespaceSlug = "test-ns"; @@ -248,6 +318,7 @@ void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throw SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); SkillFile file = new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -296,6 +367,7 @@ void testDownloadVersion_AllowsAnonymousForGlobalPublicSkill() throws Exception 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 +403,7 @@ void testDownloadVersion_AllowsAnonymousForTeamNamespacePublicSkill() throws Exc 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 7ee683ac7..032bc683f 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 @@ -27,6 +27,7 @@ import java.io.InputStream; import java.io.UncheckedIOException; import java.lang.reflect.Field; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -439,6 +440,17 @@ void testIsDownloadAvailable_ShouldReturnTrueWhenPublishedVersionHasFiles() thro assertTrue(service.isDownloadAvailable(version)); } + @Test + void testIsDownloadAvailable_ShouldReturnFalseWhenVersionIsYanked() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + assertFalse(service.isDownloadAvailable(version)); + } + @Test void testIsDownloadAvailable_ShouldNotHitObjectStorageForListSignals() throws Exception { SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); @@ -565,9 +577,11 @@ void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exceptio SkillVersion version100 = new SkillVersion(1L, "1.0.0", "user-100"); setId(version100, 9L); version100.setStatus(SkillVersionStatus.PUBLISHED); + version100.setDownloadReady(true); SkillVersion version110 = new SkillVersion(1L, "1.1.0", "user-100"); setId(version110, 10L); version110.setStatus(SkillVersionStatus.PUBLISHED); + version110.setDownloadReady(true); SkillFile version100File = new SkillFile(9L, "SKILL.md", 10L, "text/markdown", "hash100", "key100"); SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110"); @@ -611,6 +625,7 @@ void testResolveVersion_ShouldEncodeDownloadUrlPathSegments() throws Exception { 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)); @@ -632,6 +647,35 @@ void testResolveVersion_ShouldEncodeDownloadUrlPathSegments() throws Exception { assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl()); } + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + 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.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns"; From 7f8d2aa5d6cc2156a77e74bd19712d1b34e6b794 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 15:12:13 +0800 Subject: [PATCH 2/2] fix(cli): require installable latest in search Signed-off-by: dongmucat <1127093059@qq.com> --- .../service/SkillSearchAppServiceTest.java | 97 +++++++++-- .../skill/service/SkillDownloadService.java | 9 + .../SkillLifecycleProjectionService.java | 15 -- .../service/SkillDownloadServiceTest.java | 131 +++++++++++++++ .../skill/service/SkillQueryServiceTest.java | 154 ++++++++++++++++++ .../PostgresFullTextQueryServiceTest.java | 37 +++++ 6 files changed, 416 insertions(+), 27 deletions(-) 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 984be1d07..3cd408e45 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 @@ -24,11 +24,13 @@ import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -98,8 +100,6 @@ void search_shouldFillVisiblePageAcrossArchivedNamespaceResults() { 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()); SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null); @@ -147,8 +147,6 @@ void search_shouldExcludeHiddenSkillsForRegularUsers() { when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(visibleSkill)); when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 20, "user-9", Map.of()); @@ -166,6 +164,9 @@ void search_shouldResolvePublishedVersionsInBatch() { setField(second, "id", 11L); second.setLatestVersionId(102L); + SkillVersion firstVersion = publishedVersion(10L, 101L, "1.0.0"); + SkillVersion secondVersion = publishedVersion(11L, 102L, "2.0.0"); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); setField(namespace, "id", 1L); namespace.setStatus(NamespaceStatus.ACTIVE); @@ -174,20 +175,80 @@ void search_shouldResolvePublishedVersionsInBatch() { .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()); + assertEquals("1.0.0", response.items().get(0).publishedVersion().version()); + assertEquals("2.0.0", response.items().get(1).publishedVersion().version()); verify(skillVersionRepository, times(1)).findByIdIn(List.of(101L, 102L)); - verify(skillVersionRepository, times(1)) + verify(skillVersionRepository, times(0)) .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); } @Test - void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsMissing() { + Skill skill = new Skill(1L, "missing-latest", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); + + Namespace namespace = new Namespace("global", "Global", "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)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("missing-latest", response.items().getFirst().slug()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); + } + + @Test + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsYanked() { + Skill skill = new Skill(1L, "yanked-latest", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion latest = publishedVersion(10L, 101L, "1.0.0"); + latest.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); + + Namespace namespace = new Namespace("global", "Global", "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(latest)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("yanked-latest", response.items().getFirst().slug()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); + } + + @Test + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestDownloadUnavailable() { Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC); setField(skill, "id", 10L); skill.setLatestVersionId(101L); @@ -196,6 +257,7 @@ void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { setField(version, "id", 101L); version.setStatus(SkillVersionStatus.PUBLISHED); version.setDownloadReady(false); + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); Namespace namespace = new Namespace("global", "Global", "owner-1"); setField(namespace, "id", 1L); @@ -206,14 +268,17 @@ void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { 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(version)); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of(version)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); assertEquals(1, response.items().size()); assertEquals("not-ready", response.items().getFirst().slug()); - assertEquals(null, response.items().getFirst().publishedVersion()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); } @Test @@ -272,4 +337,12 @@ private void setField(Object target, String fieldName, Object value) { throw new RuntimeException(e); } } + + private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) { + SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1"); + setField(version, "id", versionId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + return version; + } } 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 e5df44cc9..83338294b 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 @@ -4,6 +4,7 @@ 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.*; @@ -281,12 +282,20 @@ private void assertCanDownload(Namespace namespace, if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); + } } private boolean isAnonymousDownloadAllowed(Skill skill) { return skill.getVisibility() == SkillVisibility.PUBLIC; } + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { + return currentUserId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); + } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { return skillSlugResolutionService.resolve( namespaceId, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 1ad3fd356..31ff9a6bf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -89,21 +89,10 @@ public Map projectPublishedSummaries(List skills) { .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); Map publishedBySkillId = new java.util.HashMap<>(); - List unresolvedSkillIds = new java.util.ArrayList<>(); for (Skill skill : skills) { SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId()); if (SkillInstallability.isInstallableVersion(latestVersion)) { publishedBySkillId.put(skill.getId(), latestVersion); - } else { - unresolvedSkillIds.add(skill.getId()); - } - } - - if (!unresolvedSkillIds.isEmpty()) { - for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) { - if (SkillInstallability.isInstallableVersion(version)) { - publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); - } } } @@ -160,10 +149,6 @@ private Comparator versionComparator() { .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())); } - private SkillVersion newerVersion(SkillVersion left, SkillVersion right) { - return versionComparator().compare(left, right) >= 0 ? left : right; - } - private VersionProjection toProjection(SkillVersion version) { if (version == null) { return null; 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 7a40f12bf..6a52e4016 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 @@ -118,6 +118,137 @@ void testDownloadLatest_Success() throws Exception { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadLatest_ShouldRejectSkillWithoutLatest() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "missing-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest(namespaceSlug, skillSlug, null, Map.of())); + + assertEquals("error.skill.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{skillSlug}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectYankedLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "yanked-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + 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); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).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(namespaceSlug, skillSlug, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectAnonymousArchivedNamespaceSkill() throws Exception { + String namespaceSlug = "archived"; + String skillSlug = "archived-skill"; + + 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.PUBLIC); + setId(skill, 1L); + 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(1000L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + org.mockito.Mockito.lenient().when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + org.mockito.Mockito.lenient().when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(true); + org.mockito.Mockito.lenient().when(objectStorageService.getMetadata("packages/1/10/bundle.zip")).thenReturn(metadata); + org.mockito.Mockito.lenient().when(objectStorageService.getObject("packages/1/10/bundle.zip")) + .thenReturn(new ByteArrayInputStream("test".getBytes())); + org.mockito.Mockito.lenient() + .when(objectStorageService.generatePresignedUrl(eq("packages/1/10/bundle.zip"), any(), eq("archived-skill-1.0.0.zip"))) + .thenReturn(null); + + assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () -> + service.downloadLatest(namespaceSlug, skillSlug, null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectAnonymousHiddenPrivateAndUnpublishedSkills() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setId(namespace, 1L); + + Skill hiddenSkill = new Skill(1L, "hidden", "owner-1", SkillVisibility.PUBLIC); + setId(hiddenSkill, 11L); + hiddenSkill.setStatus(SkillStatus.ACTIVE); + hiddenSkill.setLatestVersionId(101L); + hiddenSkill.setHidden(true); + + Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE); + setId(privateSkill, 12L); + privateSkill.setStatus(SkillStatus.ACTIVE); + privateSkill.setLatestVersionId(102L); + + Skill unpublishedSkill = new Skill(1L, "unpublished", "owner-1", SkillVisibility.PUBLIC); + setId(unpublishedSkill, 13L); + unpublishedSkill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "hidden")).thenReturn(List.of(hiddenSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "unpublished")).thenReturn(List.of(unpublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest("global", "hidden", null, Map.of())); + assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () -> + service.downloadLatest("global", "private", null, Map.of())); + assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest("global", "unpublished", null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + @Test void testDownloadByTag_Success() throws Exception { // Arrange 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 032bc683f..99cb448b9 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 @@ -676,6 +676,160 @@ void testResolveVersion_ShouldRejectDownloadUnavailableLatestVersion() throws Ex assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); } + @Test + void testResolveVersion_ShouldRejectSkillWithoutLatest() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "missing-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{skillSlug}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectYankedLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "yanked-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableExplicitVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "explicit-not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + 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.findBySkillIdAndVersion(3L, "1.0.0")).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, "1.0.0", null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableTaggedVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "tag-not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + SkillTag tag = new SkillTag(3L, "stable", 11L, "owner-1"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillTagRepository.findBySkillIdAndTagName(3L, "stable")).thenReturn(Optional.of(tag)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, "stable", null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectAnonymousHiddenPrivateArchivedAndUnpublishedSkills() throws Exception { + Namespace activeNamespace = new Namespace("global", "Global", "owner-1"); + setId(activeNamespace, 1L); + Namespace archivedNamespace = new Namespace("archived", "Archived", "owner-1"); + setId(archivedNamespace, 2L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + + Skill hiddenSkill = new Skill(1L, "hidden", "owner-1", SkillVisibility.PUBLIC); + setId(hiddenSkill, 10L); + hiddenSkill.setStatus(SkillStatus.ACTIVE); + hiddenSkill.setLatestVersionId(101L); + hiddenSkill.setHidden(true); + + Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE); + setId(privateSkill, 11L); + privateSkill.setStatus(SkillStatus.ACTIVE); + privateSkill.setLatestVersionId(102L); + + Skill archivedSkill = new Skill(2L, "archived", "owner-1", SkillVisibility.PUBLIC); + setId(archivedSkill, 12L); + archivedSkill.setStatus(SkillStatus.ACTIVE); + archivedSkill.setLatestVersionId(103L); + + Skill unpublishedSkill = new Skill(1L, "unpublished", "owner-1", SkillVisibility.PUBLIC); + setId(unpublishedSkill, 13L); + unpublishedSkill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(activeNamespace)); + when(namespaceRepository.findBySlug("archived")).thenReturn(Optional.of(archivedNamespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "hidden")).thenReturn(List.of(hiddenSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill)); + when(skillRepository.findByNamespaceIdAndSlug(2L, "archived")).thenReturn(List.of(archivedSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "unpublished")).thenReturn(List.of(unpublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion("global", "hidden", null, null, null, null, Map.of())); + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("global", "private", null, null, null, null, Map.of())); + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("archived", "archived", null, null, null, null, Map.of())); + assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion("global", "unpublished", null, null, null, null, Map.of())); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index f89d05e93..15804b92b 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -363,6 +363,43 @@ void emptyKeywordRelevanceShouldUseStableNewestOrdering() { .contains("ORDER BY s.updated_at DESC, d.skill_id DESC"); } + @Test + void anonymousSearchSqlShouldOnlyReadPublicActiveVisibleNonArchivedSkills() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + null, + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "newest", + 0, + 12 + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("AND (d.visibility = 'PUBLIC' )") + .contains("AND d.status = 'ACTIVE'") + .contains("AND s.status = 'ACTIVE'") + .contains("AND s.hidden = FALSE") + .contains("AND (n.status <> 'ARCHIVED' )") + .doesNotContain("memberNamespaceIds"); + verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); + verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); + } + @Test void authenticatedQueriesShouldAllowArchivedNamespacesForMembers() { EntityManager entityManager = mock(EntityManager.class);