From b26fe6a36451025666f65864e8f6fc80647f30e2 Mon Sep 17 00:00:00 2001 From: xiose Date: Tue, 14 Apr 2026 11:58:15 +0800 Subject: [PATCH 1/2] fix(rerelease): support precheck warning confirmation flow - Backend: Add confirmWarnings parameter to rerelease DTO, domain service, and app service - Frontend: Add warning dialog with retry logic when precheck warnings are detected - i18n: Add rerelease warning dialog translations (en/zh) Fixes the issue where rereleasing a published version with secret detection warnings always fails with 400 error. Now follows the same confirm-and-retry pattern as initial publish. --- .../dto/SkillVersionRereleaseRequest.java | 3 +- .../service/SkillLifecycleAppService.java | 3 +- .../portal/SkillLifecycleControllerTest.java | 9 +- .../skill/service/SkillPublishService.java | 5 +- .../service/SkillPublishServiceTest.java | 42 +++++++- web/src/api/client.ts | 4 +- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/zh.json | 3 + web/src/pages/skill-detail.tsx | 96 +++++++++++++------ web/src/shared/hooks/use-skill-queries.ts | 7 +- 10 files changed, 133 insertions(+), 42 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java index 6ca9e708..b3d61fc9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; public record SkillVersionRereleaseRequest( @NotBlank(message = "{validation.required}") - String targetVersion + String targetVersion, + boolean confirmWarnings ) { } 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 5670ebef..e7f86d45 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 @@ -154,7 +154,8 @@ public class SkillLifecycleAppService { skillVersion.getVersion(), targetVersion, userId, - normalizeRoles(userNamespaceRoles) + normalizeRoles(userNamespaceRoles), + request.confirmWarnings() ); auditLogService.record( userId, 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 acf5a24f..04d0945e 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 @@ -212,7 +212,8 @@ class SkillLifecycleControllerTest { eq("1.2.3"), eq("1.2.4"), eq("usr_1"), - anyMap())) + anyMap(), + eq(false))) .willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion)); mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease") @@ -279,7 +280,8 @@ class SkillLifecycleControllerTest { eq("1.2.3"), eq("1.2.4"), eq("usr_1"), - anyMap())) + anyMap(), + eq(false))) .willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion)); mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease") @@ -299,7 +301,8 @@ class SkillLifecycleControllerTest { eq("1.2.3"), eq("1.2.4"), eq("usr_1"), - anyMap()); + anyMap(), + eq(false)); } private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 07ca9735..4cf72082 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -156,7 +156,8 @@ public class SkillPublishService { String sourceVersion, String targetVersion, String publisherId, - Map userNamespaceRoles) { + Map userNamespaceRoles, + boolean confirmWarnings) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); assertCanManageLifecycle(skill, publisherId, userNamespaceRoles); @@ -181,7 +182,7 @@ public class SkillPublishService { publisherId, skill.getVisibility(), Set.of(), - false, // confirmWarnings=false: no warnings to confirm for rerelease + confirmWarnings, // confirmWarnings: honour caller's choice for rerelease false, // forceAutoPublish=false: respect visibility rules true ); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index ae091889..bff1c0f9 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -855,7 +855,8 @@ class SkillPublishServiceTest { "1.2.3", "1.2.4", publisherId, - Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false ); assertEquals("1.2.4", result.version().getVersion()); @@ -892,7 +893,8 @@ class SkillPublishServiceTest { "1.2.3", "1.2.4", publisherId, - Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false )); } @@ -953,7 +955,8 @@ class SkillPublishServiceTest { "1.2.3", "1.2.4", publisherId, - Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false ); assertEquals("1.2.4", result.version().getVersion()); @@ -999,6 +1002,39 @@ class SkillPublishServiceTest { )); } + @Test + void testPublishFromEntries_ShouldRejectWithPrivateConflictWhenOtherOwnerHasPrivatePublishedSkill() 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 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()); + + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE); + 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)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of() + )); + assertEquals("error.skill.publish.nameConflict.private", ex.messageCode()); + } + @Test void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 714111e3..c306c146 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -466,14 +466,14 @@ export const skillLifecycleApi = { }) }, - async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ targetVersion }), + body: JSON.stringify({ targetVersion, confirmWarnings }), }) }, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3af596c9..fa5ec4b8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -881,6 +881,9 @@ "rereleaseSuccessTitle": "Version re-released", "rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.", "rereleaseErrorTitle": "Failed to re-release version", + "rereleaseWarningTitle": "Pre-publish warning", + "rereleaseWarningDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue re-releasing.", + "rereleaseWarningConfirm": "Continue re-releasing", "yankVersion": "Yank Current Version", "promoteToGlobal": "Promote to Global", "promotionSectionTitle": "Promote to Global", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 17c2eb9a..7d007922 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -882,6 +882,9 @@ "rereleaseSuccessTitle": "版本已重新发布", "rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。", "rereleaseErrorTitle": "重新发布版本失败", + "rereleaseWarningTitle": "发布前风险提醒", + "rereleaseWarningDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续重新发布。", + "rereleaseWarningConfirm": "继续重新发布", "yankVersion": "撤回当前版本", "promoteToGlobal": "申请提升到全局", "promotionSectionTitle": "提升到全局", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index d28b4f75..467f359e 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, RefreshCw, ShieldCheck, Terminal, User } from 'lucide-react' +import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' @@ -10,12 +10,14 @@ import type { FileTreeNode } from '@/features/skill/file-tree-builder' import { InstallCommand } from '@/features/skill/install-command' import { ShareButton } from '@/features/skill/share-button' import { SkillLabelPanel } from '@/features/skill/skill-label-panel' +import { VersionStatusBadge, getVersionRowStyle } from '@/features/skill/version-status-badge' import { getOverviewCollapseMaxHeight, OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT, shouldCollapseOverview, } from '@/features/skill/overview-collapse' import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error' +import { isPrecheckConfirmationMessage, extractPrecheckWarnings } from '@/features/publish/publish-error-utils' import { clearDeletedSkillQueries, isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from '@/features/skill/skill-delete-flow' import { isSkillDetailQueriesEnabled } from './skill-detail-query' import { RatingInput } from '@/features/social/rating-input' @@ -116,6 +118,8 @@ export function SkillDetailPage() { const [withdrawVersionTarget, setWithdrawVersionTarget] = useState(null) const [rereleaseTarget, setRereleaseTarget] = useState(null) const [targetVersionInput, setTargetVersionInput] = useState('') + const [rereleaseWarnings, setRereleaseWarnings] = useState([]) + const [rereleaseWarningDialogOpen, setRereleaseWarningDialogOpen] = useState(false) const [diffSourceVersion, setDiffSourceVersion] = useState(null) const [confirmPublishTarget, setConfirmPublishTarget] = useState(null) const [submitReviewTarget, setSubmitReviewTarget] = useState(null) @@ -381,19 +385,6 @@ export function SkillDetailPage() { return status ?? '' } - const resolveVersionStatusLabel = (status?: string) => { - const map: Record = { - DRAFT: t('skillDetail.versionStatusDraft'), - SCANNING: t('skillDetail.versionStatusScanning'), - SCAN_FAILED: t('skillDetail.versionStatusScanFailed'), - UPLOADED: t('skillDetail.versionStatusUploaded'), - PENDING_REVIEW: t('skillDetail.versionStatusPendingReview'), - PUBLISHED: t('skillDetail.versionStatusPublished'), - REJECTED: t('skillDetail.versionStatusRejected'), - YANKED: t('skillDetail.versionStatusYanked'), - } - return status ? (map[status] ?? status) : '' - } const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' || status === 'UPLOADED' const isLastVersion = versions?.length === 1 @@ -575,7 +566,7 @@ export function SkillDetailPage() { setTargetVersionInput(suggestNextVersion(version)) } - const handleRereleaseVersion = async () => { + const handleRereleaseVersion = async (confirmWarnings = false) => { if (!rereleaseTarget || !targetVersionInput.trim()) { return } @@ -585,6 +576,7 @@ export function SkillDetailPage() { slug, version: rereleaseTarget, targetVersion: targetVersionInput.trim(), + confirmWarnings, }) toast.success( t('skillDetail.rereleaseSuccessTitle'), @@ -592,7 +584,15 @@ export function SkillDetailPage() { ) setRereleaseTarget(null) setTargetVersionInput('') + setRereleaseWarnings([]) + setRereleaseWarningDialogOpen(false) } catch (error) { + if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage)) { + const warnings = extractPrecheckWarnings(error.serverMessage) + setRereleaseWarnings(warnings) + setRereleaseWarningDialogOpen(true) + return + } toast.error(t('skillDetail.rereleaseErrorTitle'), error instanceof Error ? error.message : '') throw error } @@ -710,10 +710,31 @@ export function SkillDetailPage() {
{skill.status && ( - + {resolveSkillStatusLabel(skill.status)} )} + {skill.visibility && ( + + {skill.visibility === 'PUBLIC' && } + {skill.visibility === 'PRIVATE' && } + {skill.visibility === 'NAMESPACE_ONLY' && } + {skill.visibility === 'PUBLIC' && t('publish.visibilityOptions.public')} + {skill.visibility === 'PRIVATE' && t('publish.visibilityOptions.private')} + {skill.visibility === 'NAMESPACE_ONLY' && t('publish.visibilityOptions.namespaceOnly')} + + )} {isReviewFlowPending && ( {t('skillDetail.versionStatusPendingReview')} @@ -862,21 +883,19 @@ export function SkillDetailPage() { - {versions && versions.length > 0 ? ( -
+
{versions.map((version) => ( -
+
v{version.version} - {version.status && ( - - {resolveVersionStatusLabel(version.status)} - - )} + {headlineVersion?.version === version.version && ( {t(hasPublishedPendingReview && publishedVersion?.version === version.version @@ -928,7 +947,6 @@ export function SkillDetailPage() { {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && (
) : ( -
{t('skillDetail.noVersions')}
+ {t('skillDetail.noVersions')} )} -
@@ -1396,6 +1413,8 @@ export function SkillDetailPage() { if (!open) { setRereleaseTarget(null) setTargetVersionInput('') + setRereleaseWarnings([]) + setRereleaseWarningDialogOpen(false) } }} > @@ -1422,13 +1441,34 @@ export function SkillDetailPage() { - + +

{t('skillDetail.rereleaseWarningDescription')}

+
    + {rereleaseWarnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ } + confirmText={t('skillDetail.rereleaseWarningConfirm')} + onConfirm={() => { + setRereleaseWarningDialogOpen(false) + handleRereleaseVersion(true) + }} + /> + { diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 4784ada5..cf19d18c 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -206,8 +206,11 @@ export function useRereleaseSkillVersion() { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ namespace, slug, version, targetVersion }: { namespace: string; slug: string; version: string; targetVersion: string }) => - skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion), + mutationFn: ({ namespace, slug, version, targetVersion, confirmWarnings }: { namespace: string; slug: string; version: string; targetVersion: string; confirmWarnings?: boolean }) => + skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion, confirmWarnings), + meta: { + skipGlobalErrorHandler: true, + }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) From 7c2f06d1b64f5fab0f49a7e6ce034e2e7c10abcb Mon Sep 17 00:00:00 2001 From: xiose Date: Tue, 14 Apr 2026 17:55:56 +0800 Subject: [PATCH 2/2] test(rerelease): add confirmWarnings coverage and sync generated schema - Add domain tests for rerelease with precheck warnings (reject + confirm) - Add controller test verifying confirmWarnings passthrough - Sync SkillVersionRereleaseRequest generated type with backend DTO --- .../portal/SkillLifecycleControllerTest.java | 36 +++++++ .../service/SkillPublishServiceTest.java | 94 +++++++++++++++++++ web/src/api/generated/schema.d.ts | 1 + 3 files changed, 131 insertions(+) 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 04d0945e..09751065 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 @@ -305,6 +305,42 @@ class SkillLifecycleControllerTest { eq(false)); } + @Test + void rereleaseVersion_passesConfirmWarningsToService() 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 newVersion = new SkillVersion(1L, "1.2.4", "owner"); + setSkillVersionId(newVersion, 3L); + newVersion.setStatus(SkillVersionStatus.PUBLISHED); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); + SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner"); + setSkillVersionId(sourceVersion, 2L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion)); + given(skillPublishService.rereleasePublishedVersion( + eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true))) + .willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetVersion\":\"1.2.4\",\"confirmWarnings\":true}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION")); + + verify(skillPublishService).rereleasePublishedVersion( + eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true)); + } + private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { skill.setStatus(status); return skill; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index bff1c0f9..caf6fe0c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -969,6 +969,100 @@ class SkillPublishServiceTest { assertEquals(30L, skill.getLatestVersionId()); } + @Test + void testRereleasePublishedVersion_ShouldRequireConfirmationWhenWarningsExist() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 11L); + skill.setDisplayName("Demo Skill"); + skill.setSummary("Original summary"); + Namespace namespace = new Namespace("global", "Global", "owner"); + setId(namespace, 1L); + + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world"; + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md"); + SkillMetadata rereleaseMetadata = new SkillMetadata( + "Demo Skill", "Original summary", "1.2.4", "Hello world", + Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4")); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty()); + when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.rereleasePublishedVersion( + skill.getId(), "1.2.3", "1.2.4", publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false + )); + + assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode()); + assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token")); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + } + + @Test + void testRereleasePublishedVersion_ShouldSucceedWhenWarningsConfirmed() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 11L); + skill.setDisplayName("Demo Skill"); + skill.setSummary("Original summary"); + Namespace namespace = new Namespace("global", "Global", "owner"); + setId(namespace, 1L); + + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world"; + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md"); + SkillMetadata rereleaseMetadata = new SkillMetadata( + "Demo Skill", "Original summary", "1.2.4", "Hello world", + Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4")); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty()); + when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { setId(saved, 30L); } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.rereleasePublishedVersion( + skill.getId(), "1.2.3", "1.2.4", publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + true // confirmWarnings = true → should bypass warning and succeed + ); + + assertEquals("1.2.4", result.version().getVersion()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class)); + } + @Test void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index bfc1e4c9..d8714025 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3237,6 +3237,7 @@ export interface components { }; SkillVersionRereleaseRequest: { targetVersion: string; + confirmWarnings?: boolean; }; SkillReportSubmitRequest: { reason?: string;