mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-24 00:55:35 +00:00
feat(skills): let skill owners yank a published version (#866)
Some checks are pending
Deploy Docs / build (push) Waiting to run
Deploy Docs / Deploy (push) Blocked by required conditions
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Some checks are pending
Deploy Docs / build (push) Waiting to run
Deploy Docs / Deploy (push) Blocked by required conditions
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <thiago.nascimento.nogueira@emeal.nttdata.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
95d3d6a1df
commit
217e7f4042
11 changed files with 343 additions and 6 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<SkillLifecycleMutationResponse> 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<Long, NamespaceRole> 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<SkillLifecycleMutationResponse> withdrawReview(@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> userNsRoles,
|
||||
AuditRequestContext auditContext) {
|
||||
return skillLifecycleAppService.yankVersion(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
request,
|
||||
userId,
|
||||
userNsRoles,
|
||||
auditContext
|
||||
);
|
||||
}
|
||||
|
||||
public SkillLifecycleMutationResponse withdrawReviewVersion(String namespace,
|
||||
String slug,
|
||||
String version,
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> 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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> 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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
98
web/src/api/generated/schema.d.ts
vendored
98
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue