From 217e7f4042a179f24baa6dd90a48edaf13526f56 Mon Sep 17 00:00:00 2001 From: thiagonogueira Date: Thu, 17 Sep 2026 07:48:05 -0300 Subject: [PATCH] feat(skills): let skill owners yank a published version (#866) * feat(auth): expose skill lifecycle routes to API tokens With an API token, v0.2.19 can remove a whole skill (DELETE /api/v1/skills/{ns}/{slug} with skill:delete) but cannot archive or unarchive a skill, nor delete a single draft/rejected version. Those three routes are opened by AUTHORIZATION_POLICIES (authenticated fallback) yet have no entry in API_TOKEN_POLICIES, so a bearer request falls through to "unsupported" and is rejected with 403. That contradicts the contract written above SESSION_ONLY_ROUTES in RouteSecurityPolicyRegistry: bearer tokens are rejected on exactly the listed session-only routes and nowhere else, and anything else the authorization list opens must be reachable with a token holding the required scope. Add API-token policies for both the /api/v1 and /api/web prefixes that SkillLifecycleController serves: - POST .../skills/{ns}/{slug}/archive and .../unarchive require skill:publish. They are owner-level operations, gated by the same assertCanManageLifecycle check as publishing, so they sit at the same scope tier. - DELETE .../skills/{ns}/{slug}/versions/{version} requires skill:delete, matching the existing whole-skill delete. Whole-skill DELETE on /api/web stays session-only as documented; the new version-delete pattern does not overlap it. No scope allow-list exists outside the registry (TokenController and ApiTokenScopeService accept any scope string), so no other change is needed for tokens to carry these scopes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DdnYX4jTS3JwMMP9JCGxzU * feat(skills): let skill owners yank a published version Yanking a published version is only available through POST /api/v1/admin/skills/versions/{versionId}/yank, which is session-only (SESSION_ONLY_ROUTES covers /api/v1/admin/**) and requires SKILL_ADMIN or SUPER_ADMIN. A skill owner therefore cannot pull a broken release themselves, neither from the web surface nor from a script holding an API token. In package registries yank is an act of the publisher: `cargo yank` and PyPI's "yank release" are performed by the package owner, not by a registry admin, because the goal is to stop new installs of a bad release while keeping the artifact available for lock files. SkillHub already lets owners archive, unarchive, rerelease and delete draft versions through SkillLifecycleController under the assertCanManageLifecycle rule (owner, or namespace ADMIN/OWNER); yank belongs on the same surface with the same rule. Changes: - SkillGovernanceService: add an owner-checked yankVersion(skill, version, actor, roles, ip, ua, reason) that runs assertCanManageLifecycle and then the same yank logic as the admin variant, now shared in yankVersionInternal. The admin entry point is unchanged for AdminSkillController. - SkillLifecycleAppService / GovernanceWorkflowAppService: resolve skill and version by namespace/slug/version, delegate to the new domain method, and return SkillLifecycleMutationResponse with action YANK and the resulting version status. The YANK_SKILL_VERSION audit record and SkillVersionYankedEvent are emitted by the domain service exactly as for the admin path. - SkillLifecycleController: POST /{namespace}/{slug}/versions/{version}/yank on both /api/v1/skills and /api/web/skills, optional body AdminSkillActionRequest (reason). - RouteSecurityPolicyRegistry: require skill:yank for the new route on both prefixes, so tokens can reach it as the SESSION_ONLY_ROUTES comment promises for every route the authorization list opens. The admin yank stays session-only. No allow-list of scopes exists outside the registry; the docs' scope enumeration is updated to include skill:yank. - Tests: RouteSecurityPolicyRegistryTest (scope required on both prefixes, admin route still closed), SkillGovernanceServiceTest (owner and namespace ADMIN allowed, MEMBER forbidden, unpublished rejected), SkillLifecycleControllerTest (envelope with and without body). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DdnYX4jTS3JwMMP9JCGxzU * fix(auth): complete API token lifecycle access Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(skills): align owner lifecycle token access Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: Thiago Nascimento Nogueira Co-authored-by: Claude Fable 5.1 Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/03-authentication-design.md | 1 + docs/06-api-design.md | 1 + .../portal/SkillLifecycleController.java | 21 +++- .../service/GovernanceWorkflowAppService.java | 18 ++++ .../service/SkillLifecycleAppService.java | 22 +++++ .../portal/SkillLifecycleControllerTest.java | 71 ++++++++++++++ .../policy/RouteSecurityPolicyRegistry.java | 2 + .../RouteSecurityPolicyRegistryTest.java | 18 ++++ .../skill/service/SkillGovernanceService.java | 30 ++++++ .../service/SkillGovernanceServiceTest.java | 67 +++++++++++++ web/src/api/generated/schema.d.ts | 98 ++++++++++++++++++- 11 files changed, 343 insertions(+), 6 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index acf1a662..4c8ef11e 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -622,6 +622,7 @@ window.location.href = '/oauth2/authorization/github' | `POST /api/v1/skills/{ns}/{slug}/archive` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | | `POST .../versions/{ver}/rerelease` | namespace ADMIN 以上 或 owner;源版本必须 `PUBLISHED` | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` | | `DELETE .../versions/{ver}` | namespace ADMIN 以上 或 owner(仅 `DRAFT` / `REJECTED`) | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` | +| `POST .../versions/{ver}/yank` | namespace ADMIN 以上 或 owner;版本必须 `PUBLISHED`;API Token 需 `skill:publish` | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` | ### 10.3 CLI API diff --git a/docs/06-api-design.md b/docs/06-api-design.md index a0f580ae..1dbd906c 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -230,6 +230,7 @@ Public API 的可见性规则: | POST | `/api/v1/skills/{namespace}/{slug}/archive` | 归档技能(namespace ADMIN 或 owner) | | POST | `/api/v1/skills/{namespace}/{slug}/unarchive` | 恢复归档(namespace ADMIN 或 owner) | | DELETE | `/api/v1/skills/{namespace}/{slug}/versions/{version}` | 删除 DRAFT/REJECTED 版本 | +| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/yank` | 撤回已发布版本(namespace ADMIN 或 owner;可选 body `{"reason"}`;API Token 需 `skill:publish`) | | POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/rerelease` | 从已发布版本重新发出一个新版本(namespace ADMIN 或 owner) | 当前代码中的 skill 生命周期读模型不再依赖 `latestVersionStatus` / `viewingVersionStatus` 一类拼装字段,而统一使用以下 projection: diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index b590fa22..87e3f8e9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -24,7 +24,7 @@ import org.springframework.web.bind.annotation.RestController; /** * Endpoints that mutate skill lifecycle state, including archive, unarchive, - * withdraw-review, delete-version, and rerelease operations. + * withdraw-review, delete-version, yank-version, and rerelease operations. */ @RestController @RequestMapping({"/api/v1/skills", "/api/web/skills"}) @@ -87,6 +87,25 @@ public class SkillLifecycleController extends BaseApiController { AuditRequestContext.from(httpRequest))); } + @PostMapping("/{namespace}/{slug}/versions/{version}/yank") + public ApiResponse yankVersion(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @RequestBody(required = false) AdminSkillActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.yankVersion( + namespace, + slug, + version, + request, + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } + @PostMapping("/{namespace}/{slug}/versions/{version}/withdraw-review") public ApiResponse withdrawReview(@PathVariable String namespace, @PathVariable String slug, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index c27f2a90..3a29d9f0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -234,6 +234,24 @@ public class GovernanceWorkflowAppService { return skillLifecycleAppService.deleteVersion(namespace, slug, version, userId, userNsRoles, auditContext); } + public SkillLifecycleMutationResponse yankVersion(String namespace, + String slug, + String version, + AdminSkillActionRequest request, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.yankVersion( + namespace, + slug, + version, + request, + userId, + userNsRoles, + auditContext + ); + } + public SkillLifecycleMutationResponse withdrawReviewVersion(String namespace, String slug, String version, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index 4aa57b8d..5e38644f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -112,6 +112,28 @@ public class SkillLifecycleAppService { return new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version); } + @Transactional + public SkillLifecycleMutationResponse yankVersion(String namespace, + String slug, + String version, + AdminSkillActionRequest request, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + SkillVersion yanked = skillGovernanceService.yankVersion( + skill, + skillVersion, + userId, + normalizeRoles(userNamespaceRoles), + auditContext.clientIp(), + auditContext.userAgent(), + request != null ? request.reason() : null + ); + return new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "YANK", yanked.getStatus().name()); + } + @Transactional public SkillLifecycleMutationResponse withdrawReview(String namespace, String slug, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index bef8c274..44215f5d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.portal; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -160,6 +161,76 @@ class SkillLifecycleControllerTest { .andExpect(jsonPath("$.data.status").value("1.0.0")); } + @Test + void yankVersion_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion version = new SkillVersion(1L, "1.2.3", "owner"); + setSkillVersionId(version, 2L); + version.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion yanked = new SkillVersion(1L, "1.2.3", "owner"); + setSkillVersionId(yanked, 2L); + yanked.setStatus(SkillVersionStatus.YANKED); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(version)); + given(skillGovernanceService.yankVersion( + eq(skill), eq(version), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("broken"))) + .willReturn(yanked); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/yank") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"broken\"}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.versionId").value(2)) + .andExpect(jsonPath("$.data.action").value("YANK")) + .andExpect(jsonPath("$.data.status").value("YANKED")); + + verify(skillGovernanceService).yankVersion( + eq(skill), eq(version), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("broken")); + } + + @Test + void yankVersion_acceptsMissingBodyAndPassesNullReason() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion version = new SkillVersion(1L, "1.2.3", "owner"); + setSkillVersionId(version, 2L); + version.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion yanked = new SkillVersion(1L, "1.2.3", "owner"); + setSkillVersionId(yanked, 2L); + yanked.setStatus(SkillVersionStatus.YANKED); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(version)); + given(skillGovernanceService.yankVersion( + eq(skill), eq(version), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), isNull())) + .willReturn(yanked); + + mockMvc.perform(post("/api/v1/skills/global/demo-skill/versions/1.2.3/yank") + .requestAttr("userId", "usr_1") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.action").value("YANK")) + .andExpect(jsonPath("$.data.status").value("YANKED")); + } + @Test void withdrawReview_returnsUnifiedEnvelope() throws Exception { Namespace namespace = new Namespace("global", "Global", "owner"); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index ee1c52bf..132ca515 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -258,6 +258,8 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/submit-review", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/confirm-publish", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/confirm-publish", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/versions/*/yank", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/versions/*/yank", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/publish", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/versions/*/security-audit/retry", "skill:publish"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index bf6ac622..33fa4ec4 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -166,6 +166,24 @@ class RouteSecurityPolicyRegistryTest { } } + @Test + void authorizeApiToken_requiresPublishScopeForOwnerYankEndpoint() { + for (String prefix : List.of("/api/v1", "/api/web")) { + String path = prefix + "/skills/global/demo-skill/versions/1.2.3/yank"; + var denied = registry.authorizeApiToken("POST", path, Set.of("skill:read", "skill:delete")); + var allowed = registry.authorizeApiToken("POST", path, Set.of("skill:publish")); + + assertFalse(denied.allowed(), path); + assertEquals("skill:publish", denied.requiredScope(), path); + assertTrue(allowed.allowed(), path); + } + } + + @Test + void authorizeApiToken_keepsAdminYankSessionOnly() { + assertFalse(registry.authorizeApiToken("POST", "/api/v1/admin/skills/versions/42/yank", ALL_SCOPES).allowed()); + } + @Test void authorizeApiToken_requiresPublishScopeForOwnerLifecycleEndpoints() { for (String prefix : List.of("/api/v1", "/api/web")) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index e4bfc81c..5737e70e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -260,10 +260,40 @@ public class SkillGovernanceService { return savedVersion; } + /** + * Yanks a published version on behalf of a platform admin. Callers are + * expected to have enforced the admin role before invoking this method. + */ @Transactional public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) { SkillVersion version = skillVersionRepository.findById(versionId) .orElseThrow(() -> new DomainNotFoundException("error.skill.version.notFound", versionId)); + return yankVersionInternal(version, actorUserId, clientIp, userAgent, reason); + } + + /** + * Yanks a published version on behalf of the skill owner or a namespace + * ADMIN/OWNER, using the same lifecycle permission rule as archive and + * delete-version. + */ + @Transactional + public SkillVersion yankVersion(Skill skill, + SkillVersion version, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent, + String reason) { + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + return yankVersionInternal(version, actorUserId, clientIp, userAgent, reason); + } + + private SkillVersion yankVersionInternal(SkillVersion version, + String actorUserId, + String clientIp, + String userAgent, + String reason) { + Long versionId = version.getId(); if (version.getStatus() != SkillVersionStatus.PUBLISHED) { throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 50aa3e4b..dd9e9a75 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -148,6 +148,7 @@ class SkillGovernanceServiceTest { @Test void yankVersion_setsYankedStatus() { SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 22L); version.setStatus(SkillVersionStatus.PUBLISHED); given(skillVersionRepository.findById(22L)).willReturn(Optional.of(version)); given(skillVersionRepository.save(version)).willReturn(version); @@ -161,6 +162,72 @@ class SkillGovernanceServiceTest { verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); } + @Test + void yankVersion_allowsSkillOwnerWithoutAdminRole() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 2L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 22L); + version.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.save(version)).willReturn(version); + given(skillRepository.findById(2L)).willReturn(Optional.of(skill)); + + SkillVersion result = service.yankVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "broken"); + + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.YANKED); + assertThat(result.getYankedBy()).isEqualTo("owner"); + assertThat(result.isDownloadReady()).isFalse(); + verify(auditLogService).record("owner", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); + verify(eventPublisher).publishEvent(any(com.iflytek.skillhub.domain.event.SkillVersionYankedEvent.class)); + } + + @Test + void yankVersion_allowsNamespaceAdminWhoIsNotOwner() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 2L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 22L); + version.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.save(version)).willReturn(version); + given(skillRepository.findById(2L)).willReturn(Optional.of(skill)); + + SkillVersion result = service.yankVersion( + skill, version, "ns-admin", Map.of(1L, NamespaceRole.ADMIN), "127.0.0.1", "JUnit", null); + + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.YANKED); + assertThat(result.getYankedBy()).isEqualTo("ns-admin"); + } + + @Test + void yankVersion_rejectsMemberWhoIsNotOwner() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 2L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 22L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + assertThrows(DomainForbiddenException.class, + () -> service.yankVersion(skill, version, "other", Map.of(1L, NamespaceRole.MEMBER), "127.0.0.1", "JUnit", null)); + + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); + verify(skillVersionRepository, never()).save(any()); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void yankVersion_rejectsUnpublishedVersionForOwner() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 2L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 22L); + version.setStatus(SkillVersionStatus.DRAFT); + + assertThrows(DomainBadRequestException.class, + () -> service.yankVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", null)); + + verify(skillVersionRepository, never()).save(any()); + } + @Test void withdrawPendingVersion_demotesVersionToUploaded() { Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a57d765d..d756bff0 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1100,6 +1100,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/yank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["yankVersion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/versions/{version}/yank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["yankVersion_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review": { parameters: { query?: never; @@ -2229,7 +2261,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["yankVersion"]; + post: operations["yankVersion_2"]; delete?: never; options?: never; head?: never; @@ -5277,6 +5309,9 @@ export interface components { /** @enum {string} */ visibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; }; + AdminSkillActionRequest: { + reason?: string; + }; ApiResponseSkillLifecycleMutationResponse: { /** Format: int32 */ code?: number; @@ -5323,9 +5358,6 @@ export interface components { ConfirmPublishRequest: { version: string; }; - AdminSkillActionRequest: { - reason?: string; - }; ApiResponsePublishResponse: { /** Format: int32 */ code?: number; @@ -9583,6 +9615,62 @@ export interface operations { }; }; }; + yankVersion: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminSkillActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; + yankVersion_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminSkillActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; withdrawReview: { parameters: { query?: never; @@ -11585,7 +11673,7 @@ export interface operations { }; }; }; - yankVersion: { + yankVersion_2: { parameters: { query?: never; header?: never;