fix(compat): enforce anonymous installability rules

This commit is contained in:
dongmucat 2026-06-12 14:38:38 +08:00
parent 4776550391
commit fa680a86f8
9 changed files with 337 additions and 20 deletions

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {

View file

@ -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<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds());
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds(), scope);
return new SearchResponse(pageItems, result.total(), page, size);
}
@ -158,7 +167,7 @@ public class SkillSearchAppService {
.toList();
}
private List<SkillSummaryResponse> mapVisibleSkillSummaries(List<Long> skillIds) {
private List<SkillSummaryResponse> mapVisibleSkillSummaries(List<Long> skillIds, SearchVisibilityScope scope) {
if (skillIds.isEmpty()) {
return List.of();
}
@ -177,16 +186,36 @@ public class SkillSearchAppService {
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
Map<Long, String> namespaceSlugsById = namespacesById.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug()));
Map<Long, SkillVersion> latestVersionsById = scope.userId() == null
? loadLatestVersions(matchedSkills)
: Map.of();
Map<Long, SkillLifecycleProjectionService.Projection> 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<Long, SkillVersion> loadLatestVersions(List<Skill> skills) {
List<Long> 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<Long, String> namespaceSlugsById,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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