fix(cli): align anonymous installability rules

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-12 14:43:23 +08:00
parent 9520cf63e0
commit 32cc316b30
9 changed files with 218 additions and 13 deletions

View file

@ -56,15 +56,16 @@ public class CliSkillAppService {
);
List<CliSearchItem> 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<Long, NamespaceRole> userNsRoles) {

View file

@ -8,7 +8,9 @@ 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.SkillLifecycleProjectionService;
import com.iflytek.skillhub.search.SearchQuery;
@ -184,6 +186,36 @@ class SkillSearchAppServiceTest {
.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()))

View file

@ -76,6 +76,37 @@ class CliSkillAppServiceTest {
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()))

View file

@ -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;
}
}

View file

@ -306,7 +306,7 @@ public class SkillDownloadService {
/**
* Asserts that the version can be downloaded.
* - PUBLISHED: anyone with skill access can download
* - PUBLISHED: must be installable before public download
* - UPLOADED/PENDING_REVIEW: only skill owner or namespace admin can download
*/
private void assertDownloadableVersion(Skill skill,
@ -315,7 +315,9 @@ public class SkillDownloadService {
Map<Long, NamespaceRole> userNsRoles) {
switch (version.getStatus()) {
case PUBLISHED -> {
// Anyone with skill access can download published versions
if (!SkillInstallability.isInstallableVersion(version)) {
throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion());
}
}
case UPLOADED, PENDING_REVIEW -> {
if (!canManageSkillDraft(skill, currentUserId, userNsRoles)) {

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.skill.service;
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 class SkillLifecycleProjectionService {
List<Long> 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 class SkillLifecycleProjectionService {
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);
}
}
}

View file

@ -487,13 +487,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 SkillInstallability.isInstallableVersion(version);
}
public ReviewSkillSnapshotDTO getReviewSkillSnapshot(Long skillVersionId) {
@ -565,6 +559,7 @@ public class SkillQueryService {
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 @@ public class SkillQueryService {
}
}
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

View file

@ -92,6 +92,7 @@ class SkillDownloadServiceTest {
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());
@ -137,6 +138,7 @@ class SkillDownloadServiceTest {
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());
@ -180,6 +182,7 @@ class SkillDownloadServiceTest {
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());
@ -232,6 +235,73 @@ class SkillDownloadServiceTest {
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<Long, NamespaceRole> 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<Long, NamespaceRole> 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";
@ -249,6 +319,7 @@ class SkillDownloadServiceTest {
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));
@ -297,6 +368,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));
@ -332,6 +404,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));

View file

@ -27,6 +27,7 @@ import java.io.ByteArrayInputStream;
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 @@ class SkillQueryServiceTest {
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 @@ class SkillQueryServiceTest {
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 @@ 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));
@ -632,6 +647,35 @@ class SkillQueryServiceTest {
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";