mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(compat): enforce anonymous installability rules
This commit is contained in:
parent
4776550391
commit
fa680a86f8
9 changed files with 337 additions and 20 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue