fix(auth): enforce owner isolation for skill publishing

- Change skill uniqueness constraint from (namespace_id, slug) to
  (namespace_id, slug, owner_id) to support per-user skill records
- Reject publish when another owner has a published skill with same slug
- Reject review approval when same-slug conflict exists
- Auto-withdraw pending review versions when submitting a new version
- Resolve visible skill by preferring published skill, then current
  user's own skill, to fix wrong skill returned for same-slug queries
- Invalidate all skill query cache on publish to prevent stale data
This commit is contained in:
xiose 2026-03-16 19:11:23 +08:00 • committed by vsxd
parent 94a1e95bc7
commit cb717fd5f4
22 changed files with 427 additions and 92 deletions

View file

@ -304,8 +304,7 @@ public class ClawHubCompatController {
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", coord.slug()));
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), userId);
SkillVersion latestVersionEntity = null;
if (skill.getLatestVersionId() != null) {
@ -382,8 +381,7 @@ public class ClawHubCompatController {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug));
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId());
boolean alreadyStarred = skillStarService.isStarred(skill.getId(), principal.userId());
skillStarService.star(skill.getId(), principal.userId());
@ -399,8 +397,7 @@ public class ClawHubCompatController {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug));
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId());
boolean alreadyUnstarred = !skillStarService.isStarred(skill.getId(), principal.userId());
skillStarService.unstar(skill.getId(), principal.userId());
@ -482,4 +479,26 @@ public class ClawHubCompatController {
principal.avatarUrl()
);
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
java.util.List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug);
if (skills.isEmpty()) {
throw new DomainNotFoundException("error.skill.notFound", slug);
}
java.util.Optional<Skill> published = skills.stream()
.filter(s -> s.getLatestVersionId() != null)
.findFirst();
if (published.isPresent()) {
return published.get();
}
if (currentUserId != null) {
java.util.Optional<Skill> ownSkill = skills.stream()
.filter(s -> currentUserId.equals(s.getOwnerId()))
.findFirst();
if (ownSkill.isPresent()) {
return ownSkill.get();
}
}
return skills.get(0);
}
}

View file

@ -66,7 +66,7 @@ public class SkillLifecycleController extends BaseApiController {
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
Skill archived = skillGovernanceService.archiveSkill(
skill.getId(),
userId,
@ -86,7 +86,7 @@ public class SkillLifecycleController extends BaseApiController {
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
Skill restored = skillGovernanceService.unarchiveSkill(
skill.getId(),
userId,
@ -106,7 +106,7 @@ public class SkillLifecycleController extends BaseApiController {
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
skillGovernanceService.deleteVersion(
@ -128,7 +128,7 @@ public class SkillLifecycleController extends BaseApiController {
@PathVariable String version,
@RequestAttribute("userId") String userId,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
reviewService.withdrawReview(skillVersion.getId(), userId);
@ -155,7 +155,7 @@ public class SkillLifecycleController extends BaseApiController {
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion(
@ -181,11 +181,32 @@ public class SkillLifecycleController extends BaseApiController {
new SkillLifecycleMutationResponse(result.skillId(), result.version().getId(), "RERELEASE_VERSION", result.version().getStatus().name()));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
java.util.List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug);
if (skills.isEmpty()) {
throw new DomainBadRequestException("error.skill.notFound", slug);
}
java.util.Optional<Skill> published = skills.stream()
.filter(s -> s.getLatestVersionId() != null)
.findFirst();
if (published.isPresent()) {
return published.get();
}
if (currentUserId != null) {
java.util.Optional<Skill> ownSkill = skills.stream()
.filter(s -> currentUserId.equals(s.getOwnerId()))
.findFirst();
if (ownSkill.isPresent()) {
return ownSkill.get();
}
}
return skills.get(0);
}
}

View file

@ -43,7 +43,7 @@ public class SkillReportController extends BaseApiController {
@RequestBody SkillReportSubmitRequest request,
@RequestAttribute("userId") String userId,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill skill = findSkill(namespace, slug, userId);
var report = skillReportService.submitReport(
skill.getId(),
userId,
@ -55,11 +55,32 @@ public class SkillReportController extends BaseApiController {
return ok("response.success.created", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
}
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
java.util.List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug);
if (skills.isEmpty()) {
throw new DomainBadRequestException("error.skill.notFound", slug);
}
java.util.Optional<Skill> published = skills.stream()
.filter(s -> s.getLatestVersionId() != null)
.findFirst();
if (published.isPresent()) {
return published.get();
}
if (currentUserId != null) {
java.util.Optional<Skill> ownSkill = skills.stream()
.filter(s -> currentUserId.equals(s.getOwnerId()))
.findFirst();
if (ownSkill.isPresent()) {
return ownSkill.get();
}
}
return skills.get(0);
}
}

View file

@ -0,0 +1,6 @@
-- V12__skill_owner_uniqueness.sql
-- Change skill uniqueness from (namespace_id, slug) to (namespace_id, slug, owner_id)
-- to support owner-isolated skill records with the same name
ALTER TABLE skill DROP CONSTRAINT skill_namespace_id_slug_key;
ALTER TABLE skill ADD CONSTRAINT skill_namespace_id_slug_owner_id_key UNIQUE(namespace_id, slug, owner_id);

View file

@ -119,3 +119,5 @@ error.admin.user.role.invalid=Invalid role: {0}
error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role
error.admin.user.status.invalid=Invalid user status: {0}
error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace
error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace

View file

@ -119,3 +119,5 @@ error.admin.user.role.invalid=无效的角色:{0}
error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色
error.admin.user.status.invalid=无效的用户状态:{0}
error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户
error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交
error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能

View file

@ -81,7 +81,7 @@ class SkillLifecycleControllerTest {
setSkillId(skill, 1L);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup")))
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED));
@ -108,7 +108,7 @@ class SkillLifecycleControllerTest {
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class)))
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE));
@ -135,7 +135,7 @@ class SkillLifecycleControllerTest {
version.setStatus(SkillVersionStatus.DRAFT);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0")
@ -162,7 +162,7 @@ class SkillLifecycleControllerTest {
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review")
@ -188,7 +188,7 @@ class SkillLifecycleControllerTest {
newVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner");
setSkillVersionId(sourceVersion, 2L);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);

View file

@ -64,7 +64,7 @@ class SkillReportControllerTest {
ReflectionTestUtils.setField(report, "id", 99L);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
given(skillReportService.submitReport(eq(10L), eq("user-1"), eq("Spam"), eq("details"), nullable(String.class), nullable(String.class)))
.willReturn(report);

View file

@ -24,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -153,12 +154,27 @@ public class ReviewService {
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
// Check no other owner has a published skill with the same slug
List<Skill> sameSlugSkills = skillRepository.findByNamespaceIdAndSlug(skill.getNamespaceId(), skill.getSlug());
for (Skill other : sameSlugSkills) {
if (!other.getId().equals(skill.getId())) {
boolean otherHasPublished = !skillVersionRepository
.findBySkillIdAndStatus(other.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (otherHasPublished) {
throw new DomainBadRequestException("error.skill.approve.nameConflict", skill.getSlug());
}
}
}
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
skillVersion.setPublishedAt(LocalDateTime.now());
skillVersionRepository.save(skillVersion);
Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
skill.setLatestVersionId(skillVersion.getId());
applyPublishedMetadata(skill, skillVersion);
skill.setUpdatedBy(reviewerId);

View file

@ -7,7 +7,8 @@ public interface SkillRepository {
Optional<Skill> findById(Long id);
List<Skill> findByIdIn(List<Long> ids);
List<Skill> findAll();
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
Skill save(Skill skill);
void delete(Skill skill);

View file

@ -14,7 +14,9 @@ import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
public class SkillDownloadService {
@ -59,8 +61,7 @@ public class SkillDownloadService {
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
@ -85,8 +86,7 @@ public class SkillDownloadService {
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
@ -107,8 +107,7 @@ public class SkillDownloadService {
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
@ -155,6 +154,28 @@ public class SkillDownloadService {
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug);
if (skills.isEmpty()) {
throw new DomainBadRequestException("error.skill.notFound", slug);
}
Optional<Skill> published = skills.stream()
.filter(s -> s.getLatestVersionId() != null)
.findFirst();
if (published.isPresent()) {
return published.get();
}
if (currentUserId != null) {
Optional<Skill> ownSkill = skills.stream()
.filter(s -> currentUserId.equals(s.getOwnerId()))
.findFirst();
if (ownSkill.isPresent()) {
return ownSkill.get();
}
}
return skills.get(0);
}
private void assertPublishedAccessible(Skill skill) {
if (skill.getStatus() != SkillStatus.ACTIVE) {
throw new DomainBadRequestException("error.skill.status.notActive");

View file

@ -188,8 +188,23 @@ public class SkillPublishService {
String.join(", ", prePublishValidation.errors()));
}
// 6. Find or create Skill record
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
// 6. Find or create Skill record (with owner isolation)
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug);
// Check if any other owner's skill has published versions
for (Skill existing : existingSkills) {
if (!existing.getOwnerId().equals(publisherId)) {
boolean hasPublished = !skillVersionRepository
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (hasPublished) {
throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug);
}
}
}
// Find or create skill for current user
Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)
.orElseGet(() -> {
Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility);
newSkill.setCreatedBy(publisherId);
@ -200,6 +215,14 @@ public class SkillPublishService {
throw new DomainBadRequestException("error.skill.publish.archived", skillSlug);
}
// 6c. Auto-withdraw pending review versions
List<SkillVersion> pendingVersions = skillVersionRepository
.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW);
for (SkillVersion pending : pendingVersions) {
pending.setStatus(SkillVersionStatus.DRAFT);
skillVersionRepository.save(pending);
}
// 7. Check version doesn't already exist
if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) {
throw new DomainBadRequestException("error.skill.version.exists", metadata.version());

View file

@ -114,8 +114,7 @@ public class SkillQueryService {
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
@ -182,7 +181,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
@ -207,7 +206,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
@ -223,7 +222,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
return skillFileRepository.findByVersionId(skillVersion.getId());
@ -237,7 +236,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
@ -256,7 +255,7 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
SkillFile file = findFile(skillVersion, filePath);
@ -269,7 +268,7 @@ public class SkillQueryService {
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
List<SkillVersion> visibleVersions;
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
@ -313,7 +312,7 @@ public class SkillQueryService {
}
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash);
String fingerprint = computeFingerprint(resolved);
@ -340,14 +339,30 @@ public class SkillQueryService {
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
Namespace namespace = findNamespace(namespaceSlug);
return findSkill(namespace, skillSlug);
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
}
private Skill findSkill(Namespace namespace, String skillSlug) {
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug);
if (skills.isEmpty()) {
throw new DomainBadRequestException("error.skill.notFound", slug);
}
if (currentUserId != null) {
Optional<Skill> ownSkill = skills.stream()
.filter(s -> currentUserId.equals(s.getOwnerId()))
.findFirst();
if (ownSkill.isPresent()) {
return ownSkill.get();
}
}
return skills.stream()
.filter(s -> s.getLatestVersionId() != null)
.findFirst()
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug));
}
private SkillVersion findVersion(Skill skill, String version) {

View file

@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
public class SkillTagService {
@ -44,8 +45,7 @@ public class SkillTagService {
String currentUserId,
java.util.Map<Long, NamespaceRole> userNamespaceRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
@ -76,8 +76,7 @@ public class SkillTagService {
Namespace namespace = findNamespace(namespaceSlug);
assertAdminOrOwner(namespace.getId(), operatorId);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId);
// Find target version
SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion)
@ -111,8 +110,7 @@ public class SkillTagService {
Namespace namespace = findNamespace(namespaceSlug);
assertAdminOrOwner(namespace.getId(), operatorId);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId);
SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName)
.orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName));
@ -125,6 +123,28 @@ public class SkillTagService {
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug);
if (skills.isEmpty()) {
throw new DomainBadRequestException("error.skill.notFound", slug);
}
Optional<Skill> published = skills.stream()
.filter(s -> s.getLatestVersionId() != null)
.findFirst();
if (published.isPresent()) {
return published.get();
}
if (currentUserId != null) {
Optional<Skill> ownSkill = skills.stream()
.filter(s -> currentUserId.equals(s.getOwnerId()))
.findFirst();
if (ownSkill.isPresent()) {
return ownSkill.get();
}
}
return skills.get(0);
}
private void assertAdminOrOwner(Long namespaceId, String operatorId) {
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorId)
.map(member -> member.getRole())

View file

@ -29,6 +29,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@ -233,6 +234,7 @@ class ReviewServiceTest {
.thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
ReviewTask result = reviewService.approveReview(
@ -263,6 +265,7 @@ class ReviewServiceTest {
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
@ -356,6 +359,7 @@ class ReviewServiceTest {
.thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
ReviewTask result = reviewService.approveReview(
@ -379,6 +383,33 @@ class ReviewServiceTest {
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()));
}
@Test
void shouldRejectApproveWhenOtherOwnerHasPublishedSameSlug() {
ReviewTask task = createPendingReviewTask();
Namespace ns = createTeamNamespace();
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill(); // owned by USER_ID
// Another owner's skill with same slug that has a published version
Skill otherSkill = new Skill(NAMESPACE_ID, "my-skill", "other-user", SkillVisibility.PUBLIC);
setField(otherSkill, "id", 99L);
SkillVersion otherPublished = new SkillVersion(99L, "1.0.0", "other-user");
otherPublished.setStatus(SkillVersionStatus.PUBLISHED);
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true);
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill, otherSkill));
when(skillVersionRepository.findBySkillIdAndStatus(99L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(otherPublished));
assertThrows(DomainBadRequestException.class,
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()));
}
}
@Nested

View file

@ -19,6 +19,7 @@ import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -82,7 +83,7 @@ class SkillDownloadServiceTest {
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
when(objectStorageService.exists(storageKey)).thenReturn(true);
@ -124,7 +125,7 @@ class SkillDownloadServiceTest {
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
@ -164,7 +165,7 @@ class SkillDownloadServiceTest {
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
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));
when(objectStorageService.exists(storageKey)).thenReturn(true);
@ -196,7 +197,7 @@ class SkillDownloadServiceTest {
version.setStatus(SkillVersionStatus.DRAFT);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
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));

View file

@ -109,7 +109,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -162,7 +163,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("smoke-skill-two"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("0.2.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -205,7 +207,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("auto-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -253,7 +256,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(archivedSkill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(archivedSkill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(archivedSkill));
assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug,
@ -283,7 +287,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
when(skillVersionRepository.findBySkillIdAndVersion(any(), anyString())).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -367,7 +372,8 @@ class SkillPublishServiceTest {
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("admin-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -413,7 +419,8 @@ class SkillPublishServiceTest {
Skill skill = new Skill(namespace.getId(), "too-long-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 10L);
when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), "too-long-skill", publisherId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.0.0")).thenReturn(Optional.empty());
when(skillVersionRepository.save(any())).thenAnswer(invocation -> {
SkillVersion version = invocation.getArgument(0);
@ -537,6 +544,129 @@ class SkillPublishServiceTest {
));
}
@Test
void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
// Existing skill owned by another user with a published version
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC);
setId(existingSkill, 1L);
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()
));
}
@Test
void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
// Existing skill owned by another user with NO published version
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC);
setId(existingSkill, 1L);
Skill newSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(newSkill, 2L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of());
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.empty());
when(skillRepository.save(any(Skill.class))).thenReturn(newSkill);
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) setId(saved, 10L);
return saved;
});
SkillPublishService.PublishResult result = service.publishFromEntries(
namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()
);
assertNotNull(result);
assertEquals("test-skill", result.slug());
}
@Test
void testPublishFromEntries_ShouldAutoWithdrawPendingVersions() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 2.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of());
Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 1L);
// Existing pending version
SkillVersion pendingV1 = new SkillVersion(1L, "1.0.0", publisherId);
pendingV1.setStatus(SkillVersionStatus.PENDING_REVIEW);
setId(pendingV1, 5L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).thenReturn(List.of(pendingV1));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("2.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) setId(saved, 10L);
return saved;
});
when(skillRepository.save(any())).thenReturn(skill);
service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of());
// Verify pending version was withdrawn to DRAFT
assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus());
verify(skillVersionRepository).save(pendingV1);
}
private void setId(Object entity, Long id) throws Exception {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);

View file

@ -87,7 +87,7 @@ class SkillQueryServiceTest {
setId(version, 10L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
@ -115,7 +115,7 @@ class SkillQueryServiceTest {
setId(skill, 1L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
// Act & Assert
@ -189,7 +189,7 @@ class SkillQueryServiceTest {
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1));
@ -219,7 +219,7 @@ class SkillQueryServiceTest {
skillVersion.setStatus(SkillVersionStatus.DRAFT);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
@ -246,7 +246,7 @@ class SkillQueryServiceTest {
SkillFile file = new SkillFile(1L, filePath, 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file));
@ -279,7 +279,7 @@ class SkillQueryServiceTest {
skillVersion.setManifestJson("[{\"path\":\"SKILL.md\"}]");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
@ -313,7 +313,7 @@ class SkillQueryServiceTest {
SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
@ -351,7 +351,7 @@ class SkillQueryServiceTest {
rejected.setStatus(SkillVersionStatus.REJECTED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected));
@ -385,7 +385,7 @@ class SkillQueryServiceTest {
SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(version100, version110));
@ -427,7 +427,7 @@ class SkillQueryServiceTest {
SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version));
@ -460,7 +460,7 @@ class SkillQueryServiceTest {
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -574,7 +574,7 @@ class SkillQueryServiceTest {
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -607,7 +607,7 @@ class SkillQueryServiceTest {
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW))
.thenReturn(List.of(pending));
@ -642,7 +642,7 @@ class SkillQueryServiceTest {
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
@ -674,7 +674,7 @@ class SkillQueryServiceTest {
pending.setManifestJson("[{\"path\":\"SKILL.md\"}]");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
@ -709,7 +709,7 @@ class SkillQueryServiceTest {
SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
@ -739,7 +739,7 @@ class SkillQueryServiceTest {
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
@ -771,7 +771,7 @@ class SkillQueryServiceTest {
rejected.setStatus(SkillVersionStatus.REJECTED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
@ -805,7 +805,7 @@ class SkillQueryServiceTest {
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));

View file

@ -73,7 +73,7 @@ class SkillTagServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.OWNER)));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(1L, targetVersion)).thenReturn(Optional.of(version));
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.empty());
when(skillTagRepository.save(any())).thenReturn(tag);
@ -118,7 +118,7 @@ class SkillTagServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.ADMIN)));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
// Act
@ -175,7 +175,7 @@ class SkillTagServiceTest {
SkillTag tag2 = new SkillTag(1L, "beta", 2L, "user-100");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2));
when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true);

View file

@ -38,10 +38,15 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
}
@Override
public Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug) {
public List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug) {
return delegate.findByNamespaceIdAndSlug(namespaceId, slug);
}
@Override
public Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId) {
return delegate.findByNamespaceIdAndSlugAndOwnerId(namespaceId, slug, ownerId);
}
@Override
public List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {
return delegate.findByNamespaceIdAndStatus(namespaceId, status);

View file

@ -18,7 +18,8 @@ import java.util.Optional;
@Repository
public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRepository {
List<Skill> findByIdIn(List<Long> ids);
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
@Override
default List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {

View file

@ -246,7 +246,7 @@ export function usePublishSkill() {
skipGlobalErrorHandler: true,
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
queryClient.invalidateQueries({ queryKey: ['skills'] })
},
})
}