From 42da467336f79e6f7e78d2dd2aeb6d4786f60aa7 Mon Sep 17 00:00:00 2001 From: wurongjie Date: Thu, 9 Apr 2026 13:53:52 +0800 Subject: [PATCH 01/78] fix(nginx): use X-Forwarded-Proto header for proper protocol forwarding Replace $scheme with $http_x_forwarded_proto in proxy headers to correctly forward the original client protocol when behind a reverse proxy or load balancer. This fixes OAuth2 authentication issues where redirects would use the wrong protocol scheme. --- web/nginx.conf.template | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/nginx.conf.template b/web/nginx.conf.template index fe0300b6..be2a51a2 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -10,6 +10,11 @@ server { gzip_types text/plain text/css application/json application/javascript text/xml; gzip_min_length 1000; + set $proxy_x_forwarded_proto $scheme; + if ($http_x_forwarded_proto) { + set $proxy_x_forwarded_proto $http_x_forwarded_proto; + } + location / { try_files $uri $uri/ /index.html; } @@ -19,27 +24,27 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /oauth2/ { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /login/oauth2/ { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /.well-known/ { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /assets/ { From 85c025a1b955668e808866c9786b74ed6e853693 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 4 Jun 2026 10:41:55 +0800 Subject: [PATCH 02/78] feat(skill): add namespace search and bundle download Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/NamespaceController.java | 30 ++++++ .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../portal/SkillControllerDownloadTest.java | 25 +++++ .../skill/service/SkillDownloadService.java | 93 ++++++++++++++++++- .../service/SkillDownloadServiceTest.java | 70 ++++++++++++++ web/src/app/router.tsx | 3 +- web/src/i18n/locales/en.json | 7 +- web/src/i18n/locales/zh.json | 7 +- web/src/pages/namespace.test.tsx | 50 +++++++++- web/src/pages/namespace.tsx | 73 ++++++++++++++- web/src/pages/search.test.tsx | 51 +++++++++- web/src/pages/search.tsx | 72 ++++++++------ web/src/shared/lib/search-query.test.ts | 25 ++++- web/src/shared/lib/search-query.ts | 29 ++++++ 15 files changed, 497 insertions(+), 40 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 69be5fa3..461b086d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.BatchMemberRequest; @@ -18,6 +19,7 @@ import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.TransferOwnershipRequest; import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; +import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; @@ -25,7 +27,11 @@ import com.iflytek.skillhub.service.NamespacePortalQueryAppService; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -44,17 +50,20 @@ public class NamespaceController extends BaseApiController { private final NamespacePortalCommandAppService namespacePortalCommandAppService; private final NamespaceMemberCandidateService namespaceMemberCandidateService; private final GovernanceWorkflowAppService governanceWorkflowAppService; + private final SkillDownloadService skillDownloadService; public NamespaceController(NamespacePortalQueryAppService namespacePortalQueryAppService, NamespacePortalCommandAppService namespacePortalCommandAppService, NamespaceMemberCandidateService namespaceMemberCandidateService, GovernanceWorkflowAppService governanceWorkflowAppService, + SkillDownloadService skillDownloadService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespacePortalQueryAppService = namespacePortalQueryAppService; this.namespacePortalCommandAppService = namespacePortalCommandAppService; this.namespaceMemberCandidateService = namespaceMemberCandidateService; this.governanceWorkflowAppService = governanceWorkflowAppService; + this.skillDownloadService = skillDownloadService; } @GetMapping("/namespaces") @@ -169,6 +178,27 @@ public class NamespaceController extends BaseApiController { return ok("response.success.read", namespaceMemberCandidateService.searchCandidates(slug, search, userId, size)); } + @GetMapping("/namespaces/{slug}/skills/download") + @RateLimit(category = "download", authenticated = 30, anonymous = 10) + public ResponseEntity downloadNamespaceSkills( + @PathVariable String slug, + @RequestParam(name = "skill", required = false) List selectedSkills, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + SkillDownloadService.DownloadResult result = skillDownloadService.downloadNamespaceBundle( + slug, + selectedSkills != null ? selectedSkills : List.of(), + userId, + userNsRoles != null ? userNsRoles : Map.of()); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") + .contentType(MediaType.parseMediaType(result.contentType())) + .contentLength(result.contentLength()) + .body(new InputStreamResource(result.openContent())); + } + @PostMapping("/namespaces/{slug}/members") public ApiResponse addMember( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index be3e2ebe..32a072e6 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -143,6 +143,7 @@ error.skill.version.submit.notUploaded=Version ''{0}'' is not in UPLOADED status error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be confirmed error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish error.skill.version.notDownloadable=Version ''{0}'' is not available for download +error.namespace.skills.download.empty=No downloadable skills found in namespace ''{0}'' # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index cef09563..057c2f03 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -143,6 +143,7 @@ error.skill.version.submit.notUploaded=版本"{0}"不在 UPLOADED 状态,无 error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无法确认发布 error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 error.skill.version.notDownloadable=版本"{0}"不可下载 +error.namespace.skills.download.empty=命名空间“{0}”下没有可下载的技能 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index 8945d2a9..4804537c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimiter; import java.io.ByteArrayInputStream; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -199,4 +200,28 @@ class SkillControllerDownloadTest { 120, 60); } + + @Test + void downloadNamespaceBundle_streamsSelectedNamespaceSkills() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadNamespaceBundle("team-ai", List.of("alpha"), "test-user", java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> new ByteArrayInputStream("zip".getBytes()), + "team-ai-skills.zip", + 3L, + "application/zip", + null, + false + )); + + mockMvc.perform(get("/api/web/namespaces/team-ai/skills/download") + .param("skill", "alpha") + .with(user("test-user")) + .requestAttr("userId", "test-user") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"team-ai-skills.zip\"")); + + verify(skillDownloadService).downloadNamespaceBundle("team-ai", List.of("alpha"), "test-user", java.util.Map.of()); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 3bb194ff..354f7e05 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -20,8 +20,10 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.time.Duration; import java.util.Comparator; +import java.util.HashSet; import java.util.Map; import java.util.List; +import java.util.Set; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -162,6 +164,49 @@ public class SkillDownloadService { return buildDownloadResult(skill, version); } + /** + * Builds one namespace-level archive containing each selected skill as its + * own versioned zip bundle. + */ + public DownloadResult downloadNamespaceBundle( + String namespaceSlug, + List selectedSkillSlugs, + String currentUserId, + Map userNsRoles) { + + Namespace namespace = findNamespace(namespaceSlug); + Set selected = selectedSkillSlugs == null + ? Set.of() + : new HashSet<>(selectedSkillSlugs.stream() + .filter(slug -> slug != null && !slug.isBlank()) + .map(slug -> slug.trim().replaceFirst("^@", "")) + .toList()); + + List entries = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) + .stream() + .filter(skill -> selected.isEmpty() || selected.contains(skill.getSlug())) + .sorted(Comparator.comparing(Skill::getSlug)) + .map(skill -> toNamespaceBundleEntry(namespace, skill, currentUserId, userNsRoles)) + .flatMap(java.util.Optional::stream) + .toList(); + + if (entries.isEmpty()) { + throw new DomainBadRequestException("error.namespace.skills.download.empty", namespaceSlug); + } + + byte[] bundle = createNamespaceBundle(namespace.getSlug(), entries); + entries.forEach(entry -> recordPublishedDownload(entry.skill(), entry.version())); + + return new DownloadResult( + () -> new ByteArrayInputStream(bundle), + sanitizeFilename(namespace.getSlug()) + "-skills.zip", + bundle.length, + "application/zip", + null, + false + ); + } + private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); assertDownloadableVersion(skill, version); @@ -169,13 +214,55 @@ public class SkillDownloadService { // Only increment download count for PUBLISHED versions if (version.getStatus() == SkillVersionStatus.PUBLISHED) { - skillRepository.incrementDownloadCount(skill.getId()); - skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); - eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + recordPublishedDownload(skill, version); } return result; } + private java.util.Optional toNamespaceBundleEntry( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + assertCanDownload(namespace, skill, currentUserId, userNsRoles); + if (skill.getLatestVersionId() == null) { + return java.util.Optional.empty(); + } + SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + return java.util.Optional.empty(); + } + return java.util.Optional.of(new NamespaceBundleEntry(skill, version, buildDownloadResult(skill, version))); + } + + private byte[] createNamespaceBundle(String namespaceSlug, List entries) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (NamespaceBundleEntry entry : entries) { + ZipEntry zipEntry = new ZipEntry(namespaceSlug + "/" + entry.skill().getSlug() + "-" + entry.version().getVersion() + ".zip"); + zipOutputStream.putNextEntry(zipEntry); + try (InputStream inputStream = entry.downloadResult().openContent()) { + inputStream.transferTo(zipOutputStream); + } + zipOutputStream.closeEntry(); + } + zipOutputStream.finish(); + return outputStream.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException("Failed to build namespace skill bundle zip", e); + } + } + + private void recordPublishedDownload(Skill skill, SkillVersion version) { + skillRepository.incrementDownloadCount(skill.getId()); + skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + } + + private record NamespaceBundleEntry(Skill skill, SkillVersion version, DownloadResult downloadResult) { + } + private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index ba24003b..666df6fa 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -340,6 +340,76 @@ class SkillDownloadServiceTest { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadNamespaceBundle_PackagesVisiblePublishedSkills() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); + setId(alpha, 11L); + alpha.setDisplayName("Alpha Skill"); + alpha.setStatus(SkillStatus.ACTIVE); + alpha.setLatestVersionId(101L); + + Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); + setId(beta, 12L); + beta.setDisplayName("Beta Skill"); + beta.setStatus(SkillStatus.ACTIVE); + beta.setLatestVersionId(102L); + + SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(alphaVersion, 101L); + alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion betaVersion = new SkillVersion(12L, "2.0.0", "owner-1"); + setId(betaVersion, 102L); + betaVersion.setStatus(SkillVersionStatus.PUBLISHED); + + SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 5L, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); + SkillFile betaFile = new SkillFile(102L, "README.md", 4L, "text/markdown", "hash-b", "skills/12/102/README.md"); + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); + when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); + when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); + when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); + when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); + when(skillFileRepository.findByVersionId(102L)).thenReturn(List.of(betaFile)); + when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); + when(objectStorageService.exists("skills/12/102/README.md")).thenReturn(true); + when(objectStorageService.getObject("skills/11/101/SKILL.md")).thenReturn(new ByteArrayInputStream("alpha".getBytes())); + when(objectStorageService.getObject("skills/12/102/README.md")).thenReturn(new ByteArrayInputStream("beta".getBytes())); + + SkillDownloadService.DownloadResult result = service.downloadNamespaceBundle( + "team-ai", + List.of(), + "user-1", + Map.of(2L, NamespaceRole.MEMBER)); + + assertEquals("team-ai-skills.zip", result.filename()); + assertEquals("application/zip", result.contentType()); + assertNull(result.presignedUrl()); + assertTrue(result.contentLength() > 0); + + try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { + var firstEntry = zipInputStream.getNextEntry(); + assertNotNull(firstEntry); + assertEquals("team-ai/alpha-1.0.0.zip", firstEntry.getName()); + var secondEntry = zipInputStream.getNextEntry(); + assertNotNull(secondEntry); + assertEquals("team-ai/beta-2.0.0.zip", secondEntry.getName()); + } + + verify(skillRepository).incrementDownloadCount(11L); + verify(skillRepository).incrementDownloadCount(12L); + verify(skillVersionStatsRepository).incrementDownloadCount(101L, 11L); + verify(skillVersionStatsRepository).incrementDownloadCount(102L, 12L); + verify(eventPublisher, times(2)).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 8cabf0b3..b925f1c1 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -199,9 +199,10 @@ const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: 'search', component: SearchPage, - validateSearch: (search: Record): { q: string; label?: string; sort: string; page: number; starredOnly: boolean } => { + validateSearch: (search: Record): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => { return { q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), + namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined, label: typeof search.label === 'string' && search.label ? search.label : undefined, sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3ff964a4..d556f3c9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -189,6 +189,7 @@ "noStarredResults": "No starred skills found", "noStarredResultsFor": "No starred skills match \"{{q}}\"", "noStarredSkills": "You have not starred any skills yet", + "namespaceFilter": "@{{namespace}}", "enterKeyword": "Please enter a search keyword", "results": "{{count}} skills found", "resultCount": "Found <1>{{count}} results", @@ -765,7 +766,11 @@ "notFound": "Namespace not found", "skillList": "Skills", "emptyTitle": "No skills", - "emptyDescription": "No skills have been published in this namespace yet" + "emptyDescription": "No skills have been published in this namespace yet", + "downloadAll": "Download all", + "downloadSelected": "Download selected", + "copyInstallManifest": "Copy install list", + "selectSkill": "Select {{name}}" }, "skillDetail": { "back": "Back", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 85cdc73b..88daccf8 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -189,6 +189,7 @@ "noStarredResults": "未找到已收藏技能", "noStarredResultsFor": "已收藏技能中没有与 \"{{q}}\" 相关的结果", "noStarredSkills": "你还没有收藏任何技能", + "namespaceFilter": "@{{namespace}}", "enterKeyword": "请输入搜索关键词", "results": "找到 {{count}} 个技能", "resultCount": "找到 <1>{{count}} 个结果", @@ -765,7 +766,11 @@ "notFound": "命名空间不存在", "skillList": "技能列表", "emptyTitle": "暂无技能", - "emptyDescription": "该命名空间下还没有发布任何技能" + "emptyDescription": "该命名空间下还没有发布任何技能", + "downloadAll": "下载全部", + "downloadSelected": "下载选中", + "copyInstallManifest": "复制安装清单", + "selectSkill": "选择 {{name}}" }, "skillDetail": { "back": "返回上一页", diff --git a/web/src/pages/namespace.test.tsx b/web/src/pages/namespace.test.tsx index 8fcfd410..6a920a85 100644 --- a/web/src/pages/namespace.test.tsx +++ b/web/src/pages/namespace.test.tsx @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const buttonRecords: Array<{ label: string }> = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), @@ -23,6 +26,14 @@ vi.mock('@/features/skill/skill-card', () => ({ SkillCard: () => null, })) +vi.mock('@/shared/ui/button', () => ({ + Button: ({ children }: { children?: ReactNode }) => { + const label = Array.isArray(children) ? children.join('') : String(children ?? '') + buttonRecords.push({ label }) + return + }, +})) + vi.mock('@/shared/components/skeleton-loader', () => ({ SkeletonList: () => null, })) @@ -38,7 +49,26 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useSearchSkills: () => ({ - data: { items: [] }, + data: { + items: [ + { + id: 1, + displayName: 'Demo Skill', + summary: 'summary', + namespace: 'global', + slug: 'demo', + downloadCount: 1, + starCount: 1, + ratingCount: 0, + updatedAt: '2026-03-20T00:00:00Z', + canSubmitPromotion: false, + publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, + }, + ], + total: 1, + page: 0, + size: 20, + }, isLoading: false, }), })) @@ -47,6 +77,14 @@ import { renderToStaticMarkup } from 'react-dom/server' import { NamespacePage } from './namespace' describe('NamespacePage', () => { + beforeEach(() => { + buttonRecords.length = 0 + useNamespaceDetailMock.mockReturnValue({ + data: { id: 1, slug: 'global', displayName: 'Global', type: 'GLOBAL', status: 'ACTIVE' }, + isLoading: false, + }) + }) + it('exports a named component function', () => { expect(typeof NamespacePage).toBe('function') }) @@ -60,4 +98,12 @@ describe('NamespacePage', () => { const html = renderToStaticMarkup() expect(html).toContain('namespace.notFound') }) + + it('renders namespace distribution actions when skills are available', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('namespace.downloadAll') + expect(html).toContain('namespace.downloadSelected') + expect(html).toContain('namespace.copyInstallManifest') + }) }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 69ac0127..58dc2271 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -1,13 +1,16 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useNavigate, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { ClipboardCopy, Download } from 'lucide-react' import { NamespaceHeader } from '@/features/namespace/namespace-header' import { SkillCard } from '@/features/skill/skill-card' +import { buildInstallTarget } from '@/features/skill/install-command' import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' +import { Button } from '@/shared/ui/button' const PAGE_SIZE = 20 @@ -19,10 +22,12 @@ export function NamespacePage() { const navigate = useNavigate() const { namespace } = useParams({ from: '/space/$namespace' }) const [page, setPage] = useState(0) + const [selectedSkillSlugs, setSelectedSkillSlugs] = useState([]) // Reset page when namespace changes useEffect(() => { setPage(0) + setSelectedSkillSlugs([]) }, [namespace]) const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) @@ -33,11 +38,46 @@ export function NamespacePage() { }) const totalPages = skillsData ? Math.max(Math.ceil(skillsData.total / skillsData.size), 1) : 1 + const visibleSkills = skillsData?.items ?? [] + const selectedSlugSet = useMemo(() => new Set(selectedSkillSlugs), [selectedSkillSlugs]) + const hasSkills = visibleSkills.length > 0 + const selectedDownloadSlugs = selectedSkillSlugs.filter((slug) => visibleSkills.some((skill) => skill.slug === slug)) const handleSkillClick = (slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) } + const handleSkillSelectionChange = (slug: string, selected: boolean) => { + setSelectedSkillSlugs((current) => { + if (selected) { + return current.includes(slug) ? current : [...current, slug] + } + return current.filter((item) => item !== slug) + }) + } + + const buildNamespaceDownloadUrl = (slugs: string[]) => { + const params = new URLSearchParams() + slugs.forEach((slug) => params.append('skill', slug)) + const queryString = params.toString() + return `/api/web/namespaces/${encodeURIComponent(namespace)}/skills/download${queryString ? `?${queryString}` : ''}` + } + + const handleDownloadAll = () => { + window.location.assign(buildNamespaceDownloadUrl([])) + } + + const handleDownloadSelected = () => { + window.location.assign(buildNamespaceDownloadUrl(selectedDownloadSlugs)) + } + + const handleCopyInstallManifest = async () => { + const manifest = visibleSkills + .map((skill) => `skillhub install ${buildInstallTarget(skill.namespace, skill.slug)}`) + .join('\n') + await navigator.clipboard?.writeText(manifest) + } + if (isLoadingNamespace) { return (
@@ -56,14 +96,41 @@ export function NamespacePage() {
-

{t('namespace.skillList')}

+
+

{t('namespace.skillList')}

+ {hasSkills ? ( +
+ + + +
+ ) : null} +
{isLoadingSkills ? ( ) : skillsData && skillsData.items.length > 0 ? ( <>
{skillsData.items.map((skill, idx) => ( -
+
+ handleSkillClick(skill.slug)} diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index a921d5d3..aac629a0 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -6,6 +6,8 @@ const navigateMock = vi.fn() const useSearchMock = vi.fn() const buttonRecords: Array<{ label: string; variant?: string | null; onClick?: (() => void) | undefined }> = [] const paginationProps: Array<{ onPageChange: (page: number) => void }> = [] +const searchBarProps: Array<{ value?: string; onSearch?: (query: string) => void }> = [] +const searchSkillParams: Array> = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -34,7 +36,10 @@ vi.mock('@/features/auth/use-auth', () => ({ })) vi.mock('@/features/search/search-bar', () => ({ - SearchBar: () =>
search-bar
, + SearchBar: (props: { value?: string; onSearch?: (query: string) => void }) => { + searchBarProps.push(props) + return
search-bar
+ }, })) vi.mock('@/features/skill/skill-card', () => ({ @@ -85,7 +90,10 @@ vi.mock('@/app/page-shell-style', () => ({ const useSearchSkillsMock = vi.fn() vi.mock('@/shared/hooks/use-skill-queries', () => ({ - useSearchSkills: () => useSearchSkillsMock(), + useSearchSkills: (params: Record) => { + searchSkillParams.push(params) + return useSearchSkillsMock() + }, })) vi.mock('@/shared/hooks/use-label-queries', () => ({ @@ -120,8 +128,11 @@ describe('SearchPage', () => { navigateMock.mockReset() buttonRecords.length = 0 paginationProps.length = 0 + searchBarProps.length = 0 + searchSkillParams.length = 0 useSearchMock.mockReturnValue({ q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 1, @@ -156,6 +167,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: '', sort: 'downloads', page: 0, @@ -173,6 +185,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'newest', page: 0, @@ -191,6 +204,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 2, @@ -201,6 +215,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 0, @@ -209,6 +224,38 @@ describe('SearchPage', () => { }) }) + it('passes the namespace URL state into skill search', () => { + renderToStaticMarkup() + + expect(searchSkillParams[0]).toMatchObject({ + q: 'agent', + namespace: 'team-ai', + label: 'code-generation', + sort: 'downloads', + page: 1, + size: 12, + }) + }) + + it('extracts a leading namespace token from the search input', () => { + renderToStaticMarkup() + + searchBarProps[0]?.onSearch?.('@product-team onboarding') + + expect(navigateMock).toHaveBeenCalledWith({ + to: '/search', + search: { + q: 'onboarding', + namespace: 'product-team', + label: 'code-generation', + sort: 'downloads', + page: 0, + starredOnly: false, + }, + replace: true, + }) + }) + it('renders the default skill list when the empty query still returns items', () => { useSearchMock.mockReturnValue({ q: '', diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 58c421db..dc849cab 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -12,7 +12,7 @@ import { Pagination } from '@/shared/components/pagination' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useVisibleLabels } from '@/shared/hooks/use-label-queries' import { useMyStars } from '@/shared/hooks/use-user-queries' -import { normalizeSearchQuery } from '@/shared/lib/search-query' +import { formatNamespaceSearchInput, normalizeSearchQuery, parseNamespaceSearchInput } from '@/shared/lib/search-query' import { Button } from '@/shared/ui/button' import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' @@ -55,17 +55,22 @@ function scrollToTopOnPageChange() { * Search text, sorting, pagination, and the starred-only filter are mirrored into router search * params so the page can be shared, restored, and revisited without losing state. */ -function filterStarredSkills(skills: SkillSummary[], query: string): SkillSummary[] { +function filterStarredSkills(skills: SkillSummary[], query: string, namespace: string): SkillSummary[] { const normalizedQuery = query.trim().toLowerCase() - if (!normalizedQuery) { - return skills - } + const normalizedNamespace = namespace.trim().toLowerCase() - return skills.filter((skill) => - [skill.displayName, skill.summary, skill.namespace, skill.slug] - .filter(Boolean) - .some((value) => value!.toLowerCase().includes(normalizedQuery)) - ) + return skills.filter((skill) => { + const matchesNamespace = !normalizedNamespace || skill.namespace.toLowerCase() === normalizedNamespace + if (!matchesNamespace) { + return false + } + if (!normalizedQuery) { + return true + } + return [skill.displayName, skill.summary, skill.namespace, skill.slug] + .filter(Boolean) + .some((value) => value!.toLowerCase().includes(normalizedQuery)) + }) } function sortStarredSkills(skills: SkillSummary[], sort: string): SkillSummary[] { @@ -86,16 +91,17 @@ export function SearchPage() { const { isAuthenticated } = useAuth() const q = normalizeSearchQuery(searchParams.q || '') + const namespace = (searchParams.namespace || '').replace(/^@/, '') const selectedLabel = searchParams.label || '' const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false - const [queryInput, setQueryInput] = useState(q) + const [queryInput, setQueryInput] = useState(formatNamespaceSearchInput(namespace, q)) const previousPageRef = useRef(page) useEffect(() => { - setQueryInput(q) - }, [q]) + setQueryInput(formatNamespaceSearchInput(namespace, q)) + }, [namespace, q]) useEffect(() => { if (previousPageRef.current !== page) { @@ -113,6 +119,7 @@ export function SearchPage() { const { data, isLoading, isFetching } = useSearchSkills({ q, + namespace: namespace || undefined, label: selectedLabel || undefined, sort, page, @@ -128,47 +135,51 @@ export function SearchPage() { useEffect(() => { // Debounce URL updates while the user is typing so query state stays shareable without // triggering a navigation on every keystroke. - const normalizedQuery = normalizeSearchQuery(queryInput) - if (normalizedQuery === q) { + const parsedInput = parseNamespaceSearchInput(queryInput) + if (parsedInput.query === q && parsedInput.namespace === namespace) { return } - if (!normalizedQuery) { + if (!parsedInput.query && !parsedInput.namespace) { startTransition(() => { - navigate({ to: '/search', search: { q: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 }) + navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 }) }) return } const timeoutId = window.setTimeout(() => { startTransition(() => { - navigate({ to: '/search', search: { q: normalizedQuery, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) }) }, 250) return () => window.clearTimeout(timeoutId) - }, [navigate, page, q, queryInput, selectedLabel, sort, starredOnly]) + }, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly]) const handleSearch = (query: string) => { - const normalizedQuery = normalizeSearchQuery(query) + const parsedInput = parseNamespaceSearchInput(query) setQueryInput(query) startTransition(() => { - navigate({ to: '/search', search: { q: normalizedQuery, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) }) } const handleSortChange = (newSort: string) => { - navigate({ to: '/search', search: { q, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) } const handlePageChange = (newPage: number) => { blurActiveElement() - navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: newPage, starredOnly } }) } const handleLabelToggle = (label: string) => { const nextLabel = selectedLabel === label ? '' : label - navigate({ to: '/search', search: { q, label: nextLabel, sort, page: 0, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: nextLabel, sort, page: 0, starredOnly } }) + } + + const handleNamespaceClear = () => { + navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } }) } const handleStarredToggle = () => { @@ -182,7 +193,7 @@ export function SearchPage() { return } - navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) } const handleSkillClick = (namespace: string, slug: string) => { @@ -190,7 +201,7 @@ export function SearchPage() { } const filteredStarredSkills = starredOnly - ? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q), sort) + ? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q, namespace), sort) : [] const starredPageItems = starredOnly ? filteredStarredSkills.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) @@ -280,6 +291,15 @@ export function SearchPage() { {label.displayName} ))} + {namespace ? ( + + ) : null}
diff --git a/web/src/shared/lib/search-query.test.ts b/web/src/shared/lib/search-query.test.ts index b0322df0..1643793a 100644 --- a/web/src/shared/lib/search-query.test.ts +++ b/web/src/shared/lib/search-query.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery } from './search-query' +import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery, parseNamespaceSearchInput } from './search-query' describe('normalizeSearchQuery', () => { it('trims whitespace around the query', () => { @@ -13,3 +13,26 @@ describe('normalizeSearchQuery', () => { expect(normalizeSearchQuery(query)).toBe('a'.repeat(MAX_SEARCH_QUERY_LENGTH)) }) }) + +describe('parseNamespaceSearchInput', () => { + it('extracts a leading namespace token and keeps the remaining query', () => { + expect(parseNamespaceSearchInput('@team-ai release notes')).toEqual({ + namespace: 'team-ai', + query: 'release notes', + }) + }) + + it('treats a bare namespace token as a namespace-only search', () => { + expect(parseNamespaceSearchInput('@product')).toEqual({ + namespace: 'product', + query: '', + }) + }) + + it('leaves ordinary search text unchanged', () => { + expect(parseNamespaceSearchInput('meeting assistant')).toEqual({ + namespace: '', + query: 'meeting assistant', + }) + }) +}) diff --git a/web/src/shared/lib/search-query.ts b/web/src/shared/lib/search-query.ts index 1b28b3ea..24f920fa 100644 --- a/web/src/shared/lib/search-query.ts +++ b/web/src/shared/lib/search-query.ts @@ -3,3 +3,32 @@ export const MAX_SEARCH_QUERY_LENGTH = 50 export function normalizeSearchQuery(query: string): string { return query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH) } + +export interface NamespaceSearchInput { + namespace: string + query: string +} + +const LEADING_NAMESPACE_PATTERN = /^@([a-zA-Z0-9][a-zA-Z0-9-]{0,63})(?:\s+|$)(.*)$/ + +export function parseNamespaceSearchInput(input: string): NamespaceSearchInput { + const normalized = normalizeSearchQuery(input) + const match = normalized.match(LEADING_NAMESPACE_PATTERN) + if (!match) { + return { namespace: '', query: normalized } + } + + return { + namespace: match[1], + query: normalizeSearchQuery(match[2] ?? ''), + } +} + +export function formatNamespaceSearchInput(namespace: string, query: string): string { + const normalizedNamespace = namespace.trim().replace(/^@/, '') + const normalizedQuery = normalizeSearchQuery(query) + if (!normalizedNamespace) { + return normalizedQuery + } + return normalizedQuery ? `@${normalizedNamespace} ${normalizedQuery}` : `@${normalizedNamespace}` +} From 204f52dd304c9b5f000def4b2fe04c8d3ac5b516 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 4 Jun 2026 15:49:40 +0800 Subject: [PATCH 03/78] test(web): add namespace search download e2e coverage Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/namespace-search-download.spec.ts | 301 ++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 web/e2e/namespace-search-download.spec.ts diff --git a/web/e2e/namespace-search-download.spec.ts b/web/e2e/namespace-search-download.spec.ts new file mode 100644 index 00000000..b935a558 --- /dev/null +++ b/web/e2e/namespace-search-download.spec.ts @@ -0,0 +1,301 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +const namespaceSlug = 'product-managers' + +const skillFixtures = [ + { + id: 7101, + slug: 'roadmap-agent', + displayName: 'Roadmap Agent', + summary: 'Turns product strategy into roadmap drafts.', + downloadCount: 12, + starCount: 3, + ratingAvg: 4.8, + ratingCount: 4, + namespace: namespaceSlug, + updatedAt: '2026-06-01T00:00:00Z', + canSubmitPromotion: false, + headlineVersion: { id: 8101, version: '1.0.0', status: 'PUBLISHED' }, + publishedVersion: { id: 8101, version: '1.0.0', status: 'PUBLISHED' }, + }, + { + id: 7102, + slug: 'requirements-agent', + displayName: 'Requirements Agent', + summary: 'Helps product managers refine user stories.', + downloadCount: 8, + starCount: 2, + ratingAvg: 4.5, + ratingCount: 2, + namespace: namespaceSlug, + updatedAt: '2026-06-02T00:00:00Z', + canSubmitPromotion: false, + headlineVersion: { id: 8102, version: '1.1.0', status: 'PUBLISHED' }, + publishedVersion: { id: 8102, version: '1.1.0', status: 'PUBLISHED' }, + }, + { + id: 7201, + slug: 'backend-agent', + displayName: 'Backend Agent', + summary: 'A skill outside the selected namespace.', + downloadCount: 20, + starCount: 6, + ratingAvg: 4.2, + ratingCount: 5, + namespace: 'developers', + updatedAt: '2026-06-03T00:00:00Z', + canSubmitPromotion: false, + headlineVersion: { id: 8201, version: '2.0.0', status: 'PUBLISHED' }, + publishedVersion: { id: 8201, version: '2.0.0', status: 'PUBLISHED' }, + }, +] + +function envelope(data: unknown, code = 0, msg = 'success') { + return JSON.stringify({ + code, + msg, + data, + timestamp: '2026-06-04T00:00:00Z', + requestId: 'e2e-namespace-search-download', + }) +} + +async function mockCommonApi(page: Page, options?: { authenticated?: boolean }) { + await page.route('**/api/v1/auth/me', async (route) => { + if (options?.authenticated) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope({ + userId: 'e2e-product-manager', + displayName: 'E2E Product Manager', + email: 'pm@example.com', + platformRoles: [], + }), + }) + return + } + + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: envelope(null, 401, 'Unauthorized'), + }) + }) + await page.route('**/api/v1/auth/providers**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope([]), + }) + }) + await page.route('**/api/v1/auth/methods**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope([]), + }) + }) + await page.route('**/api/web/labels', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope([]), + }) + }) + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope([]), + }) + }) + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope({ count: 0 }), + }) + }) + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: '', + }) + }) + await page.route(/\/api\/web\/skills\/\d+\/star$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope(false), + }) + }) +} + +async function mockSearchApi(page: Page) { + const requests: URL[] = [] + + await page.route(/\/api\/web\/skills\?/, async (route) => { + const url = new URL(route.request().url()) + requests.push(url) + + const q = (url.searchParams.get('q') ?? '').trim().toLowerCase() + const namespace = (url.searchParams.get('namespace') ?? '').trim().toLowerCase() + const pageNumber = Number(url.searchParams.get('page') ?? '0') + const pageSize = Number(url.searchParams.get('size') ?? '12') + const items = skillFixtures.filter((skill) => { + const matchesNamespace = !namespace || skill.namespace === namespace + const matchesQuery = !q + || skill.displayName.toLowerCase().includes(q) + || skill.summary.toLowerCase().includes(q) + || skill.slug.toLowerCase().includes(q) + return matchesNamespace && matchesQuery + }) + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope({ + items, + total: items.length, + page: pageNumber, + size: pageSize, + }), + }) + }) + + return requests +} + +async function mockNamespaceApi(page: Page) { + await page.route(`**/api/web/namespaces/${namespaceSlug}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope({ + id: 5101, + slug: namespaceSlug, + displayName: 'Product Managers', + description: 'Skills curated for product and requirements work.', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-06-01T00:00:00Z', + updatedAt: '2026-06-02T00:00:00Z', + }), + }) + }) + + await page.route(`**/api/web/namespaces/${namespaceSlug}/skills/download**`, async (route) => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${namespaceSlug}-skills.zip"`, + }, + body: 'PK', + }) + }) +} + +test.describe('Namespace Search and Download', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('submits @namespace search input as separate namespace and keyword URL parameters', async ({ page }) => { + await mockCommonApi(page) + const requests = await mockSearchApi(page) + + await page.goto('/search') + await page.getByPlaceholder('Search skills...').fill(`@${namespaceSlug} roadmap`) + await page.getByRole('button', { name: 'Search', exact: true }).click() + + await expect(page).toHaveURL(new RegExp(`namespace=${namespaceSlug}`)) + await expect(page).toHaveURL(/q=roadmap/) + await expect(page.getByRole('button', { name: `@${namespaceSlug}` })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Roadmap Agent' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Backend Agent' })).toHaveCount(0) + await expect.poll(() => requests.some((url) => + url.searchParams.get('namespace') === namespaceSlug + && url.searchParams.get('q') === 'roadmap', + )).toBe(true) + }) + + test('clears the namespace filter while preserving the keyword and sort mode', async ({ page }) => { + await mockCommonApi(page) + const requests = await mockSearchApi(page) + + await page.goto(`/search?q=roadmap&namespace=${namespaceSlug}&sort=downloads&page=1&starredOnly=false`) + await page.getByRole('button', { name: `@${namespaceSlug}` }).click() + + await expect(page).toHaveURL(/q=roadmap/) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page).toHaveURL(/page=0/) + await expect(page).not.toHaveURL(new RegExp(`namespace=${namespaceSlug}`)) + await expect.poll(() => requests.some((url) => + url.searchParams.get('q') === 'roadmap' + && !url.searchParams.has('namespace') + && url.searchParams.get('sort') === 'downloads', + )).toBe(true) + }) + + test('copies the namespace install manifest and gates selected download until a skill is checked', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await mockCommonApi(page, { authenticated: true }) + await mockSearchApi(page) + await mockNamespaceApi(page) + + await page.goto(`/space/${namespaceSlug}`) + + const selectedDownloadButton = page.getByRole('button', { name: 'Download selected' }) + await expect(selectedDownloadButton).toBeDisabled() + + await page.getByLabel('Select Roadmap Agent').check() + await expect(selectedDownloadButton).toBeEnabled() + + await page.getByRole('button', { name: 'Copy install list' }).click() + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + + expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--roadmap-agent`) + expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--requirements-agent`) + }) + + test('downloads only selected namespace skills with skill query parameters', async ({ page }) => { + await mockCommonApi(page, { authenticated: true }) + await mockSearchApi(page) + await mockNamespaceApi(page) + + await page.goto(`/space/${namespaceSlug}`) + await page.getByLabel('Select Roadmap Agent').check() + + const [request, response] = await Promise.all([ + page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), + page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), + page.getByRole('button', { name: 'Download selected' }).click(), + ]) + + const downloadUrl = new URL(request.url()) + expect(response.headers()['content-disposition']).toContain(`${namespaceSlug}-skills.zip`) + expect(downloadUrl.searchParams.getAll('skill')).toEqual(['roadmap-agent']) + }) + + test('downloads the full namespace bundle without skill query parameters', async ({ page }) => { + await mockCommonApi(page, { authenticated: true }) + await mockSearchApi(page) + await mockNamespaceApi(page) + + await page.goto(`/space/${namespaceSlug}`) + + const [request, response] = await Promise.all([ + page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), + page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), + page.getByRole('button', { name: 'Download all' }).click(), + ]) + + const downloadUrl = new URL(request.url()) + expect(response.headers()['content-disposition']).toContain(`${namespaceSlug}-skills.zip`) + expect(downloadUrl.searchParams.getAll('skill')).toEqual([]) + }) +}) From 6bb89b1c89164aa2948954e1bbd17435d9ec8df0 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 4 Jun 2026 17:12:44 +0800 Subject: [PATCH 04/78] fix(skill): address namespace bundle review findings Signed-off-by: dongmucat <1127093059@qq.com> --- .../policy/RouteSecurityPolicyRegistry.java | 4 ++ .../RouteSecurityPolicyRegistryTest.java | 17 +++++ .../skill/service/SkillDownloadService.java | 15 +++- .../service/SkillDownloadServiceTest.java | 72 +++++++++++++++++++ web/e2e/namespace-search-download.spec.ts | 18 +++-- web/src/i18n/locales/en.json | 8 ++- web/src/i18n/locales/zh.json | 8 ++- web/src/pages/namespace.tsx | 36 +++++++++- web/src/shared/components/confirm-dialog.tsx | 2 +- 9 files changed, 167 insertions(+), 13 deletions(-) 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 5ac6c1d1..4747814f 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 @@ -52,6 +52,7 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/labels"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*/skills/download"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions"), @@ -67,6 +68,7 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*/skills/download"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), @@ -98,8 +100,10 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*/skills/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*/skills/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), 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 0206e395..d46d8b9d 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 @@ -73,6 +73,23 @@ class RouteSecurityPolicyRegistryTest { assertTrue(matchedWeb); } + @Test + void authorizationPolicies_shouldKeepNamespaceDownloadRoutesAnonymous() { + boolean matchedV1 = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/v1/namespaces/*/skills/download".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + boolean matchedWeb = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/web/namespaces/*/skills/download".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + + assertTrue(matchedV1); + assertTrue(matchedWeb); + assertTrue(registry.authorizeApiToken("GET", "/api/v1/namespaces/global/skills/download", Set.of()).allowed()); + assertTrue(registry.authorizeApiToken("GET", "/api/web/namespaces/global/skills/download", Set.of()).allowed()); + } + @Test void apiTokenPolicySupportsNativeCliRoutes() { assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/auth/whoami", Set.of()).allowed()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 354f7e05..aed9a91b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -224,7 +224,9 @@ public class SkillDownloadService { Skill skill, String currentUserId, Map userNsRoles) { - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + if (!canIncludeInNamespaceBundle(namespace, skill, currentUserId, userNsRoles)) { + return java.util.Optional.empty(); + } if (skill.getLatestVersionId() == null) { return java.util.Optional.empty(); } @@ -236,6 +238,17 @@ public class SkillDownloadService { return java.util.Optional.of(new NamespaceBundleEntry(skill, version, buildDownloadResult(skill, version))); } + private boolean canIncludeInNamespaceBundle( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + return false; + } + return visibilityChecker.canAccess(skill, currentUserId, userNsRoles); + } + private byte[] createNamespaceBundle(String namespaceSlug, List entries) { try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 666df6fa..22be0a3f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -410,6 +410,78 @@ class SkillDownloadServiceTest { verify(eventPublisher, times(2)).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadNamespaceBundle_SkipsInvisibleAndUnpublishedSkills() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill visible = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); + setId(visible, 11L); + visible.setDisplayName("Alpha Skill"); + visible.setStatus(SkillStatus.ACTIVE); + visible.setLatestVersionId(101L); + + Skill invisible = new Skill(2L, "beta-private", "owner-2", SkillVisibility.PRIVATE); + setId(invisible, 12L); + invisible.setDisplayName("Beta Private"); + invisible.setStatus(SkillStatus.ACTIVE); + invisible.setLatestVersionId(102L); + + Skill draftOnly = new Skill(2L, "gamma-draft", "owner-1", SkillVisibility.PUBLIC); + setId(draftOnly, 13L); + draftOnly.setDisplayName("Gamma Draft"); + draftOnly.setStatus(SkillStatus.ACTIVE); + draftOnly.setLatestVersionId(103L); + + SkillVersion visibleVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(visibleVersion, 101L); + visibleVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion privateVersion = new SkillVersion(12L, "1.0.0", "owner-2"); + setId(privateVersion, 102L); + privateVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion draftVersion = new SkillVersion(13L, "0.1.0", "owner-1"); + setId(draftVersion, 103L); + draftVersion.setStatus(SkillVersionStatus.DRAFT); + + SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 5L, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); + + Map roles = Map.of(2L, NamespaceRole.MEMBER); + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(visible, invisible, draftOnly)); + when(visibilityChecker.canAccess(visible, "user-1", roles)).thenReturn(true); + when(visibilityChecker.canAccess(invisible, "user-1", roles)).thenReturn(false); + when(visibilityChecker.canAccess(draftOnly, "user-1", roles)).thenReturn(true); + when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(visibleVersion)); + when(skillVersionRepository.findById(103L)).thenReturn(Optional.of(draftVersion)); + when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); + when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); + when(objectStorageService.getObject("skills/11/101/SKILL.md")).thenReturn(new ByteArrayInputStream("alpha".getBytes())); + + SkillDownloadService.DownloadResult result = service.downloadNamespaceBundle( + "team-ai", + List.of(), + "user-1", + roles); + + try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { + var firstEntry = zipInputStream.getNextEntry(); + assertNotNull(firstEntry); + assertEquals("team-ai/alpha-1.0.0.zip", firstEntry.getName()); + assertNull(zipInputStream.getNextEntry()); + } + + verify(skillVersionRepository, never()).findById(102L); + verify(skillRepository).incrementDownloadCount(11L); + verify(skillRepository, never()).incrementDownloadCount(12L); + verify(skillRepository, never()).incrementDownloadCount(13L); + verify(skillVersionStatsRepository).incrementDownloadCount(101L, 11L); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(102L, 12L); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(103L, 13L); + verify(eventPublisher, times(1)).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/web/e2e/namespace-search-download.spec.ts b/web/e2e/namespace-search-download.spec.ts index b935a558..208d02a8 100644 --- a/web/e2e/namespace-search-download.spec.ts +++ b/web/e2e/namespace-search-download.spec.ts @@ -241,7 +241,7 @@ test.describe('Namespace Search and Download', () => { )).toBe(true) }) - test('copies the namespace install manifest and gates selected download until a skill is checked', async ({ page, context }) => { + test('copies the current page install manifest and gates selected download until a skill is checked', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']) await mockCommonApi(page, { authenticated: true }) await mockSearchApi(page) @@ -249,13 +249,13 @@ test.describe('Namespace Search and Download', () => { await page.goto(`/space/${namespaceSlug}`) - const selectedDownloadButton = page.getByRole('button', { name: 'Download selected' }) + const selectedDownloadButton = page.getByRole('button', { name: 'Download selected on this page' }) await expect(selectedDownloadButton).toBeDisabled() await page.getByLabel('Select Roadmap Agent').check() await expect(selectedDownloadButton).toBeEnabled() - await page.getByRole('button', { name: 'Copy install list' }).click() + await page.getByRole('button', { name: 'Copy current page install list' }).click() const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--roadmap-agent`) @@ -270,10 +270,14 @@ test.describe('Namespace Search and Download', () => { await page.goto(`/space/${namespaceSlug}`) await page.getByLabel('Select Roadmap Agent').check() + await page.getByRole('button', { name: 'Download selected on this page' }).click() + await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() + await expect(page.getByText('This will request 1 skill package from @product-managers.')).toBeVisible() + const [request, response] = await Promise.all([ page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.getByRole('button', { name: 'Download selected' }).click(), + page.getByRole('button', { name: 'Download', exact: true }).click(), ]) const downloadUrl = new URL(request.url()) @@ -288,10 +292,14 @@ test.describe('Namespace Search and Download', () => { await page.goto(`/space/${namespaceSlug}`) + await page.getByRole('button', { name: 'Download all' }).click() + await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() + await expect(page.getByText('This will request 2 skill packages from @product-managers.')).toBeVisible() + const [request, response] = await Promise.all([ page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.getByRole('button', { name: 'Download all' }).click(), + page.getByRole('button', { name: 'Download', exact: true }).click(), ]) const downloadUrl = new URL(request.url()) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d556f3c9..1d2ad394 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -768,8 +768,12 @@ "emptyTitle": "No skills", "emptyDescription": "No skills have been published in this namespace yet", "downloadAll": "Download all", - "downloadSelected": "Download selected", - "copyInstallManifest": "Copy install list", + "downloadSelected": "Download selected on this page", + "copyInstallManifest": "Copy current page install list", + "downloadConfirmTitle": "Confirm namespace download", + "downloadConfirmDescription_one": "This will request {{count}} skill package from @{{namespace}}.", + "downloadConfirmDescription_other": "This will request {{count}} skill packages from @{{namespace}}.", + "downloadConfirmAction": "Download", "selectSkill": "Select {{name}}" }, "skillDetail": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 88daccf8..feb66a8b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -768,8 +768,12 @@ "emptyTitle": "暂无技能", "emptyDescription": "该命名空间下还没有发布任何技能", "downloadAll": "下载全部", - "downloadSelected": "下载选中", - "copyInstallManifest": "复制安装清单", + "downloadSelected": "下载本页选中", + "copyInstallManifest": "复制本页安装清单", + "downloadConfirmTitle": "确认命名空间下载", + "downloadConfirmDescription_one": "将请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmDescription_other": "将请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmAction": "下载", "selectSkill": "选择 {{name}}" }, "skillDetail": { diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 58dc2271..5a628a0d 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -8,6 +8,7 @@ import { buildInstallTarget } from '@/features/skill/install-command' import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { Button } from '@/shared/ui/button' @@ -23,13 +24,19 @@ export function NamespacePage() { const { namespace } = useParams({ from: '/space/$namespace' }) const [page, setPage] = useState(0) const [selectedSkillSlugs, setSelectedSkillSlugs] = useState([]) + const [pendingDownloadSlugs, setPendingDownloadSlugs] = useState(null) // Reset page when namespace changes useEffect(() => { setPage(0) setSelectedSkillSlugs([]) + setPendingDownloadSlugs(null) }, [namespace]) + useEffect(() => { + setSelectedSkillSlugs([]) + }, [page]) + const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({ namespace, @@ -42,6 +49,9 @@ export function NamespacePage() { const selectedSlugSet = useMemo(() => new Set(selectedSkillSlugs), [selectedSkillSlugs]) const hasSkills = visibleSkills.length > 0 const selectedDownloadSlugs = selectedSkillSlugs.filter((slug) => visibleSkills.some((skill) => skill.slug === slug)) + const pendingDownloadCount = pendingDownloadSlugs + ? pendingDownloadSlugs.length || skillsData?.total || visibleSkills.length + : 0 const handleSkillClick = (slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) @@ -64,11 +74,18 @@ export function NamespacePage() { } const handleDownloadAll = () => { - window.location.assign(buildNamespaceDownloadUrl([])) + setPendingDownloadSlugs([]) } const handleDownloadSelected = () => { - window.location.assign(buildNamespaceDownloadUrl(selectedDownloadSlugs)) + setPendingDownloadSlugs(selectedDownloadSlugs) + } + + const confirmDownload = () => { + if (!pendingDownloadSlugs) { + return + } + window.location.assign(buildNamespaceDownloadUrl(pendingDownloadSlugs)) } const handleCopyInstallManifest = async () => { @@ -150,6 +167,21 @@ export function NamespacePage() { /> )}
+ { + if (!open) { + setPendingDownloadSlugs(null) + } + }} + title={t('namespace.downloadConfirmTitle')} + description={t('namespace.downloadConfirmDescription', { + count: pendingDownloadCount, + namespace, + })} + confirmText={t('namespace.downloadConfirmAction')} + onConfirm={confirmDownload} + />
) } diff --git a/web/src/shared/components/confirm-dialog.tsx b/web/src/shared/components/confirm-dialog.tsx index b760ac45..aee74103 100644 --- a/web/src/shared/components/confirm-dialog.tsx +++ b/web/src/shared/components/confirm-dialog.tsx @@ -45,7 +45,7 @@ export function ConfirmDialog({ return ( - + {title} {description && {description}} From 90fc97e740dd5184e27699aaeeec746cf8b0afae Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:27:37 +0800 Subject: [PATCH 05/78] feat(bootstrap): initialize built-in skills (#481) * feat(bootstrap): initialize built-in skills Signed-off-by: dongmucat <1127093059@qq.com> * docs(builtin-skills): remove implementation plan Signed-off-by: dongmucat <1127093059@qq.com> --------- Signed-off-by: dongmucat <1127093059@qq.com> --- README.md | 7 + docs/20-builtin-skills-design.md | 382 +++++++++++++++++ docs/openclaw-integration-en.md | 13 + docs/openclaw-integration.md | 13 + .../bootstrap/BuiltinSkillFingerprints.java | 42 ++ .../bootstrap/BuiltinSkillInitializer.java | 295 ++++++++++++++ .../bootstrap/BuiltinSkillPackageLoader.java | 107 +++++ .../bootstrap/BuiltinSkillProperties.java | 21 + .../support/MultipartPackageExtractor.java | 10 +- .../support/SkillPackageArchiveExtractor.java | 26 +- .../SkillPackageContentTypeResolver.java | 35 ++ .../support/ZipPackageExtractor.java | 26 +- .../src/main/resources/application.yml | 2 + .../builtin-skills/skillhub-hello/README.md | 5 + .../builtin-skills/skillhub-hello/SKILL.md | 8 + .../BuiltinSkillInitializerTest.java | 383 ++++++++++++++++++ .../BuiltinSkillPackageLoaderTest.java | 124 ++++++ .../BuiltinSkillPropertiesBindingTest.java | 47 +++ .../SkillPackageContentTypeResolverTest.java | 17 + 19 files changed, 1504 insertions(+), 59 deletions(-) create mode 100644 docs/20-builtin-skills-design.md create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java create mode 100644 server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md create mode 100644 server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java diff --git a/README.md b/README.md index d47ef5cc..8f0e4860 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,10 @@ skillhub login --token sk_xxx --registry https://skill.xfyun.cn skillhub search pdf skillhub install pdf-parser --agent codex +# Verify the bundled example skill after a fresh deployment +skillhub search skillhub-hello +skillhub install skillhub-hello --agent codex + # List installed skills skillhub list ``` @@ -286,6 +290,7 @@ Recommended production baseline: - keep PostgreSQL / Redis bound to `127.0.0.1` - use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` - change `BOOTSTRAP_ADMIN_PASSWORD` to a strong password (`validate-release-config.sh` rejects the default `ChangeMe!2026`) +- set `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` if you do not want the bundled `skillhub-hello` skill initialized in `@global` - rotate or disable the bootstrap admin after initial setup - run `make validate-release-config` before `docker compose up -d` @@ -421,6 +426,8 @@ clawhub login --token YOUR_API_TOKEN npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill +npx clawhub search skillhub-hello +npx clawhub install skillhub-hello # Publish to global namespace npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 diff --git a/docs/20-builtin-skills-design.md b/docs/20-builtin-skills-design.md new file mode 100644 index 00000000..d968df5a --- /dev/null +++ b/docs/20-builtin-skills-design.md @@ -0,0 +1,382 @@ +# Built-in Skills Initialization Design + +## Context + +New SkillHub deployments currently start without a guaranteed installable skill in the registry. +Users must first understand publishing or find an external package before they can verify search, +detail, download, and CLI installation flows. + +This design adds a small, built-in example skill that is bundled with the Java service and published +automatically to `@global` during application startup. + +The MVP example skill is `skillhub-hello`. It is intentionally generic and does not encode an +AgentGuard-specific product decision. Future official skills can reuse the same initialization +mechanism. + +## Goals + +- Bundle one or more directory-form skills in the Java service resources. +- Enable built-in skill initialization by default. +- Publish built-in skills to the fixed `global` namespace. +- Publish as `PUBLIC` and `PUBLISHED` so the skill is immediately searchable and installable. +- Use a fixed system publisher, `builtin-skill-publisher`, for owner and audit traceability. +- Reuse the existing `SkillPublishService.publishFromEntries(...)` pipeline. +- Keep initialization idempotent across repeated container deployments. +- Treat published versions as immutable: same version with changed content is skipped with a warning. +- Avoid new seed state tables and distributed locks in the MVP. +- Keep initialization failures non-fatal to application startup. +- Document `skillhub-hello` as an out-of-the-box verification skill. + +## Non-Goals + +- No new database table for seed state. +- No Redis or database distributed lock. +- No zip-based built-in skill packages. +- No configurable target namespace; built-in skills always publish to `global`. +- No label creation or binding for `skillhub-hello`. +- No landing page or frontend recommendation slot. +- No direct SQL/JPA insertion into `skill`, `skill_version`, or `skill_file`. +- No changes to ordinary publish, review, promotion, or lifecycle behavior. + +## Key Decisions + +| Decision | Choice | Reason | +|----------|--------|--------| +| Source location | Java service classpath resources | The runtime artifact always contains the built-in package | +| Directory | `server/skillhub-app/src/main/resources/builtin-skills/` | Spring Boot resource packaging is predictable | +| First built-in skill | `skillhub-hello` | Generic verification skill, not product-specific | +| Startup default | Enabled | Supports out-of-the-box discovery and installation | +| Target namespace | Fixed `global` | Built-in examples are platform-level public skills | +| Publication state | `PUBLIC + PUBLISHED` | Immediately searchable and installable | +| Publisher | `builtin-skill-publisher` | Stable owner and audit source | +| Version mutability | Same version is never overwritten | Published versions remain reproducible | +| Seed state table | None | Existing skill/version/file records are enough for MVP idempotency | +| Distributed lock | None | Conflict-tolerant startup is sufficient for a small built-in set | +| Labels | None | MVP validates the built-in publish mechanism only | +| Failure behavior | Log and continue | A sample skill must not make the service unavailable | +| Package format | Directory only | Easier review and classpath loading | + +## Resource Layout + +Built-in skills live under the `skillhub-app` resource tree: + +```text +server/skillhub-app/src/main/resources/builtin-skills/ + skillhub-hello/ + SKILL.md + README.md +``` + +Each direct child directory under `builtin-skills/` is treated as one skill package. + +Rules: + +- The directory must contain root-level `SKILL.md`. +- File paths are relative to the skill directory. +- Files are converted into `PackageEntry` values. +- Files outside the skill directory are ignored. +- Zip packages are not supported in the MVP. + +Suggested `skillhub-hello/SKILL.md`: + +```markdown +--- +name: skillhub-hello +description: A built-in example skill that verifies SkillHub discovery and installation. +version: 1.0.0 +--- +# SkillHub Hello + +This skill is bundled with SkillHub as a minimal example for validating discovery and installation. +``` + +Published coordinate: + +```text +@global/skillhub-hello +``` + +ClawHub canonical slug: + +```text +skillhub-hello +``` + +## Configuration + +Add one configuration property: + +```yaml +skillhub: + builtin-skills: + enabled: true +``` + +Environment override: + +```bash +SKILLHUB_BUILTIN_SKILLS_ENABLED=false +``` + +The MVP does not expose a namespace or locations property. The implementation uses the fixed +classpath location: + +```text +classpath*:builtin-skills/*/SKILL.md +``` + +## Backend Design + +### Components + +Add the following app-layer bootstrap components: + +- `BuiltinSkillProperties` +- `BuiltinSkillPackageLoader` +- `BuiltinSkillInitializer` + +Responsibilities: + +| Component | Responsibility | +|-----------|----------------| +| `BuiltinSkillProperties` | Bind `skillhub.builtin-skills.enabled` | +| `BuiltinSkillPackageLoader` | Read classpath skill directories and construct `PackageEntry` values | +| `BuiltinSkillInitializer` | Ensure publisher, evaluate idempotency, and call the publish pipeline | + +These classes belong in: + +```text +server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/ +``` + +### Publish Pipeline + +The initializer must call the existing domain publish service: + +```java +skillPublishService.publishFromEntries( + "global", + entries, + "builtin-skill-publisher", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false +); +``` + +This preserves existing behavior for: + +- package policy validation +- `SKILL.md` parsing +- slug generation +- `Skill` creation or reuse +- `SkillVersion` creation +- `PUBLISHED` state assignment +- `latestVersionId` updates +- `SkillFile` records +- object storage writes +- bundle zip creation +- `SkillPublishedEvent` +- after-commit search index rebuild + +The initializer must not create skill/version/file rows directly. +Passing `false` for warning confirmation means built-in packages with validation warnings are treated +as package quality failures and skipped instead of being silently accepted. + +### System Publisher + +The initializer ensures this system user exists: + +```text +userId: builtin-skill-publisher +displayName: SkillHub Built-in Publisher +email: builtin-skill-publisher@example.invalid +``` + +Requirements: + +- Create `UserAccount` if missing. +- Ensure the user is an `OWNER` member of `@global`. +- Do not create a local login credential. +- Do not require a persisted platform role binding. +- Pass `Set.of("SUPER_ADMIN")` only for the publish call to reuse auto-publish behavior. + +## Idempotency And Version Policy + +### Content Fingerprint + +The initializer computes a package fingerprint from current classpath resources: + +1. Sort entries by normalized path. +2. Hash each file content with SHA-256. +3. Build a canonical stream of `path + fileSha256`. +4. Hash that stream to produce the package fingerprint. + +For an existing published version, the initializer recomputes the same fingerprint from `skill_file`: + +1. Query the target `SkillVersion`. +2. Query its `SkillFile` rows. +3. Sort by `filePath`. +4. Build the canonical stream from `filePath + sha256`. +5. Hash that stream. + +No new persistence field is added. + +### Startup Rules + +For each built-in skill: + +| Existing state | Action | +|----------------|--------| +| Skill does not exist | Publish | +| Skill exists, same version does not exist | Publish new version | +| Same version is `PUBLISHED` and fingerprint matches | Skip | +| Same version is `PUBLISHED` and fingerprint differs | Warn and skip | +| Same version exists but is not `PUBLISHED` | Warn and skip | + +Same-version content changes must bump the version in `SKILL.md`. The initializer must never +overwrite a published version. + +### Concurrent Startup + +The MVP does not use a distributed lock. + +If multiple application instances start at the same time: + +- each instance performs the idempotency check; +- one instance may publish first; +- later instances may hit an existing-version conflict; +- conflict handling should re-read the existing version and skip when a valid published version is present; +- conflicts must be logged but must not fail application startup. + +## Failure Behavior + +Built-in skill initialization is best-effort. + +Rules: + +- Failure in one built-in skill does not prevent other built-in skills from being processed. +- Any initialization failure is logged at error level. +- Same-version fingerprint drift is logged at warning level. +- Same-version matching content is logged at info level. +- Successful publishing is logged at info level. +- Exceptions are contained inside the initializer and do not abort Spring Boot startup. + +Log context should include: + +- skill directory +- resolved slug +- resolved version +- namespace `global` +- action +- error message when applicable + +## Object Storage And Search + +Classpath resources are only the source for initialization. Published files still go through the +configured object storage backend: + +- LocalFile +- MinIO +- S3 + +The initializer does not write object storage directly. + +Search index updates also remain event-driven. The publish service emits `SkillPublishedEvent`, and +the existing search listener rebuilds the search document after transaction commit. + +## User Experience + +The MVP does not add frontend UI. + +After startup, users can discover and install the built-in skill through existing flows: + +- search for `skillhub-hello`; +- open the skill detail page; +- copy the existing install command; +- install with ClawHub/OpenClaw. + +Expected command: + +```bash +npx clawhub install skillhub-hello --registry +``` + +Because labels and recommendation slots are out of scope, this design does not guarantee permanent +homepage prominence for `skillhub-hello`. + +## Documentation + +Update the user-facing docs to mention the built-in verification skill: + +- `README.md` +- `docs/openclaw-integration.md` +- `docs/openclaw-integration-en.md` + +Recommended example: + +```bash +npx clawhub search skillhub-hello --registry +npx clawhub install skillhub-hello --registry +``` + +The docs should explain: + +- `skillhub-hello` is bundled with SkillHub; +- it validates registry search and installation; +- operators can disable initialization with `SKILLHUB_BUILTIN_SKILLS_ENABLED=false`. + +## Testing Strategy + +### Unit Tests + +Add backend tests for: + +- `enabled=false` skips all publishing. +- first startup publishes `skillhub-hello`; +- same version and same fingerprint skips; +- same version and different fingerprint warns and skips; +- same version in a non-`PUBLISHED` state warns and skips; +- publish exceptions are swallowed after logging; +- missing system publisher is created; +- missing `@global` membership is created; +- loader reads classpath directories into stable `PackageEntry` order; +- loader reports a directory missing `SKILL.md`. + +### Local Validation + +Run: + +```bash +make test-backend-app +``` + +If implementation touches runtime packaging or staging startup behavior, also run: + +```bash +make staging +``` + +### Manual Validation + +After local startup: + +```bash +curl "http://localhost:8080/api/web/skills?q=skillhub-hello" +npx clawhub search skillhub-hello --registry http://localhost:8080 +npx clawhub install skillhub-hello --registry http://localhost:8080 +``` + +## Future Extensions + +Potential follow-up work: + +- external filesystem source locations; +- zip package support; +- seed state table; +- distributed lock; +- official label binding; +- landing page official/recommended slot; +- AgentGuard or other official skills built on the same mechanism. + +These are outside the MVP. diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 32cb2878..77d93e5e 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -72,6 +72,10 @@ npx clawhub search find-skills npx clawhub search find-skills --limit 5 npx clawhub inspect find-skills +# Bundled verification skill initialized by default on new deployments +npx clawhub search skillhub-hello +npx clawhub inspect skillhub-hello + # Help npx clawhub search --help npx clawhub inspect --help @@ -100,6 +104,9 @@ npx clawhub list npx clawhub --dir ~/.claude/skills install find-skills CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills +# Install the bundled verification skill +npx clawhub install skillhub-hello + # Help npx clawhub install --help npx clawhub update --help @@ -210,6 +217,12 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com clawhub login --token sk_your_api_token_here ``` +New SkillHub deployments initialize the bundled `skillhub-hello` skill in `@global` by default. To disable built-in skill initialization, set this before starting the backend: + +```bash +export SKILLHUB_BUILTIN_SKILLS_ENABLED=false +``` + ## FAQ ### Q: How do I switch back to public ClawHub? diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md index f85f588e..353676c3 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -72,6 +72,10 @@ npx clawhub search find-skills npx clawhub search find-skills --limit 5 npx clawhub inspect find-skills +# 新部署默认内置的验证 Skill +npx clawhub search skillhub-hello +npx clawhub inspect skillhub-hello + # 使用帮助 npx clawhub search --help npx clawhub inspect --help @@ -100,6 +104,9 @@ npx clawhub list npx clawhub --dir ~/.claude/skills install find-skills CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills +# 安装默认内置的验证 Skill +npx clawhub install skillhub-hello + # 使用帮助 npx clawhub install --help npx clawhub update --help @@ -210,6 +217,12 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com clawhub login --token sk_your_api_token_here ``` +SkillHub 新部署默认会在 `@global` 初始化内置 `skillhub-hello`。如需关闭内置 Skill 初始化,请在启动后端前设置: + +```bash +export SKILLHUB_BUILTIN_SKILLS_ENABLED=false +``` + ## 常见问题 ### Q: 如何切换回公共 ClawHub? diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java new file mode 100644 index 00000000..836f733f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; + +final class BuiltinSkillFingerprints { + + private BuiltinSkillFingerprints() { + } + + static String fromEntries(List entries) { + StringBuilder canonical = new StringBuilder(); + entries.stream() + .sorted(Comparator.comparing(PackageEntry::path)) + .map(entry -> entry.path() + "\0" + sha256(entry.content())) + .forEach(line -> canonical.append(line).append('\n')); + return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); + } + + static String fromFiles(List files) { + StringBuilder canonical = new StringBuilder(); + files.stream() + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .map(file -> file.getFilePath() + "\0" + file.getSha256()) + .forEach(line -> canonical.append(line).append('\n')); + return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (Exception exception) { + throw new IllegalStateException("Failed to calculate SHA-256 fingerprint", exception); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java new file mode 100644 index 00000000..21c58f95 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -0,0 +1,295 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.skill.validation.ValidationResult; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Publishes bundled example skills into the global namespace during startup. + */ +@Component +public class BuiltinSkillInitializer implements ApplicationRunner { + + static final String BUILTIN_PUBLISHER_ID = "builtin-skill-publisher"; + static final String GLOBAL_NAMESPACE = "global"; + private static final String BUILTIN_PUBLISHER_NAME = "SkillHub Built-in Publisher"; + private static final String BUILTIN_PUBLISHER_EMAIL = "builtin-skill-publisher@example.invalid"; + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class); + + private final BuiltinSkillProperties properties; + private final BuiltinSkillPackageLoader packageLoader; + private final SkillMetadataParser metadataParser; + private final SkillPackageValidator packageValidator; + private final SkillPublishService skillPublishService; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillFileRepository skillFileRepository; + private final TransactionTemplate transactionTemplate; + + public BuiltinSkillInitializer(BuiltinSkillProperties properties, + BuiltinSkillPackageLoader packageLoader, + SkillMetadataParser metadataParser, + SkillPackageValidator packageValidator, + SkillPublishService skillPublishService, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillFileRepository skillFileRepository, + PlatformTransactionManager transactionManager) { + this.properties = properties; + this.packageLoader = packageLoader; + this.metadataParser = metadataParser; + this.packageValidator = packageValidator; + this.skillPublishService = skillPublishService; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillFileRepository = skillFileRepository; + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + @Override + public void run(ApplicationArguments args) { + if (!properties.isEnabled()) { + log.info("Built-in skill initialization is disabled"); + return; + } + + Namespace globalNamespace; + try { + globalNamespace = ensurePublisher(); + } catch (RuntimeException exception) { + log.error("Failed to prepare built-in skill publisher, skipping built-in skill initialization", + exception); + return; + } + if (globalNamespace == null) { + return; + } + + List packages; + try { + packages = packageLoader.loadPackages(); + } catch (Exception exception) { + log.error("Failed to load built-in skill packages", exception); + return; + } + + for (BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage : packages) { + try { + initializePackage(globalNamespace, skillPackage); + } catch (Exception exception) { + log.error("Failed to initialize built-in skill package [directory={}]", + skillPackage.directory(), exception); + } + } + } + + private Namespace ensurePublisher() { + return transactionTemplate.execute(status -> { + Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE) + .orElse(null); + if (globalNamespace == null) { + log.error("Missing built-in global namespace, skipping built-in skill initialization"); + return null; + } + + UserAccount publisher = userAccountRepository.findById(BUILTIN_PUBLISHER_ID) + .orElseGet(() -> new UserAccount( + BUILTIN_PUBLISHER_ID, + BUILTIN_PUBLISHER_NAME, + BUILTIN_PUBLISHER_EMAIL, + null + )); + publisher.setDisplayName(BUILTIN_PUBLISHER_NAME); + publisher.setEmail(BUILTIN_PUBLISHER_EMAIL); + publisher.setStatus(UserStatus.ACTIVE); + userAccountRepository.save(publisher); + + NamespaceMember member = namespaceMemberRepository + .findByNamespaceIdAndUserId(globalNamespace.getId(), BUILTIN_PUBLISHER_ID) + .orElseGet(() -> new NamespaceMember(globalNamespace.getId(), BUILTIN_PUBLISHER_ID, NamespaceRole.OWNER)); + if (member.getRole() != NamespaceRole.OWNER) { + member.setRole(NamespaceRole.OWNER); + } + namespaceMemberRepository.save(member); + return globalNamespace; + }); + } + + private void initializePackage(Namespace globalNamespace, + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage) { + List entries = skillPackage.entries(); + SkillMetadata metadata = parseMetadata(skillPackage.directory(), entries); + if (metadata.version() == null || metadata.version().isBlank()) { + log.error("Built-in skill package is missing an explicit version [directory={}]", + skillPackage.directory()); + return; + } + String skillSlug = SlugValidator.slugify(metadata.name()); + + ValidationResult validation = packageValidator.validate(entries); + if (!validation.passed() || validation.hasWarnings()) { + log.error("Built-in skill package failed validation [directory={}, slug={}, version={}, errors={}, warnings={}]", + skillPackage.directory(), skillSlug, metadata.version(), validation.errors(), validation.warnings()); + return; + } + + if (hasPublishedOtherOwnerConflict(globalNamespace.getId(), skillSlug)) { + log.warn("Skipping built-in skill because another owner already published the slug [directory={}, namespace={}, slug={}]", + skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug); + return; + } + + Optional existingBuiltInSkill = + skillRepository.findByNamespaceIdAndSlugAndOwnerId(globalNamespace.getId(), skillSlug, BUILTIN_PUBLISHER_ID); + if (existingBuiltInSkill.isPresent() + && shouldSkipExistingVersion(skillPackage, existingBuiltInSkill.get(), metadata.version())) { + return; + } + + publishPackage(skillPackage, skillSlug, metadata.version()); + } + + private SkillMetadata parseMetadata(String directory, List entries) { + PackageEntry skillMd = entries.stream() + .filter(entry -> "SKILL.md".equals(entry.path())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Built-in package missing SKILL.md: " + directory)); + return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); + } + + private boolean hasPublishedOtherOwnerConflict(Long namespaceId, String skillSlug) { + return skillRepository.findByNamespaceIdAndSlug(namespaceId, skillSlug).stream() + .filter(skill -> !BUILTIN_PUBLISHER_ID.equals(skill.getOwnerId())) + .anyMatch(skill -> !skillVersionRepository + .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty()); + } + + private boolean shouldSkipExistingVersion(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, + Skill skill, + String version) { + Optional existingVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version); + if (existingVersion.isEmpty()) { + return false; + } + + SkillVersion skillVersion = existingVersion.get(); + if (skillVersion.getStatus() != SkillVersionStatus.PUBLISHED) { + log.warn("Skipping built-in skill because the same version is not published [directory={}, skillId={}, version={}, status={}]", + skillPackage.directory(), skill.getId(), version, skillVersion.getStatus()); + return true; + } + + String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries()); + String existingFingerprint = BuiltinSkillFingerprints.fromFiles(skillFileRepository.findByVersionId(skillVersion.getId())); + if (currentFingerprint.equals(existingFingerprint)) { + log.info("Built-in skill version already exists, skipping [directory={}, skillId={}, version={}]", + skillPackage.directory(), skill.getId(), version); + } else { + log.warn("Skipping built-in skill because same published version has different content [directory={}, skillId={}, version={}]", + skillPackage.directory(), skill.getId(), version); + } + return true; + } + + private void publishPackage(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, + String skillSlug, + String version) { + try { + SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + GLOBAL_NAMESPACE, + skillPackage.entries(), + BUILTIN_PUBLISHER_ID, + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false + ); + log.info("Published built-in skill [directory={}, namespace={}, slug={}, version={}, status={}]", + skillPackage.directory(), GLOBAL_NAMESPACE, result.slug(), + result.version().getVersion(), result.version().getStatus()); + } catch (RuntimeException exception) { + ConcurrentVersionState concurrentVersionState = + findConcurrentPublishedBuiltInVersionState(skillPackage, skillSlug, version); + if (concurrentVersionState == ConcurrentVersionState.MATCHING) { + log.warn("Built-in skill was already published concurrently, skipping [directory={}, namespace={}, slug={}, version={}]", + skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version); + return; + } + if (concurrentVersionState == ConcurrentVersionState.DIFFERENT) { + log.warn("Skipping built-in skill because concurrently published same version has different content [directory={}, namespace={}, slug={}, version={}]", + skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version); + return; + } + throw exception; + } + } + + private ConcurrentVersionState findConcurrentPublishedBuiltInVersionState( + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, + String skillSlug, + String version) { + return namespaceRepository.findBySlug(GLOBAL_NAMESPACE) + .flatMap(namespace -> skillRepository.findByNamespaceIdAndSlugAndOwnerId( + namespace.getId(), + skillSlug, + BUILTIN_PUBLISHER_ID + )) + .flatMap(skill -> skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)) + .filter(skillVersion -> skillVersion.getStatus() == SkillVersionStatus.PUBLISHED) + .map(skillVersion -> { + String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries()); + String existingFingerprint = BuiltinSkillFingerprints.fromFiles( + skillFileRepository.findByVersionId(skillVersion.getId())); + if (currentFingerprint.equals(existingFingerprint)) { + return ConcurrentVersionState.MATCHING; + } + return ConcurrentVersionState.DIFFERENT; + }) + .orElse(ConcurrentVersionState.MISSING); + } + + private enum ConcurrentVersionState { + MISSING, + MATCHING, + DIFFERENT + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java new file mode 100644 index 00000000..9775691f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java @@ -0,0 +1,107 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.controller.support.SkillPackageContentTypeResolver; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.stereotype.Component; + +/** + * Loads directory-form built-in skill packages from classpath resources. + */ +@Component +public class BuiltinSkillPackageLoader { + + static final String BUILTIN_SKILLS_PATTERN = "classpath*:builtin-skills/*/SKILL.md"; + private static final String ROOT_MARKER = "builtin-skills/"; + private static final String SKILL_MD = "SKILL.md"; + + private final ResourcePatternResolver resourcePatternResolver; + + public BuiltinSkillPackageLoader() { + this(new PathMatchingResourcePatternResolver()); + } + + BuiltinSkillPackageLoader(ResourcePatternResolver resourcePatternResolver) { + this.resourcePatternResolver = resourcePatternResolver; + } + + public List loadPackages() throws IOException { + Set directories = discoverPackageDirectories(); + List packages = new ArrayList<>(); + for (String directory : directories) { + List packageEntries = loadPackageEntries(directory); + boolean hasSkillMd = packageEntries.stream().anyMatch(packageEntry -> SKILL_MD.equals(packageEntry.path())); + if (hasSkillMd) { + packages.add(new BuiltinSkillPackage(directory, packageEntries)); + } + } + return packages; + } + + private Set discoverPackageDirectories() throws IOException { + Resource[] skillMdResources = resourcePatternResolver.getResources(BUILTIN_SKILLS_PATTERN); + Set directories = new TreeSet<>(); + for (Resource resource : skillMdResources) { + String relativePath = relativeBuiltinPath(resource); + if (relativePath == null || relativePath.isBlank()) { + continue; + } + int separatorIndex = relativePath.indexOf('/'); + if (separatorIndex > 0 && SKILL_MD.equals(relativePath.substring(separatorIndex + 1))) { + directories.add(relativePath.substring(0, separatorIndex)); + } + } + return directories; + } + + private List loadPackageEntries(String directory) throws IOException { + Resource[] resources = resourcePatternResolver.getResources("classpath*:builtin-skills/" + directory + "/**"); + List entries = new ArrayList<>(); + for (Resource resource : resources) { + String relativePath = relativeBuiltinPath(resource); + if (relativePath == null || relativePath.isBlank() || relativePath.endsWith("/")) { + continue; + } + String directoryPrefix = directory + "/"; + if (!relativePath.startsWith(directoryPrefix) || directoryPrefix.length() == relativePath.length()) { + continue; + } + + String packagePath = SkillPackagePolicy.normalizeEntryPath(relativePath.substring(directoryPrefix.length())); + try (InputStream inputStream = resource.getInputStream()) { + byte[] content = inputStream.readAllBytes(); + entries.add(new PackageEntry( + packagePath, + content, + content.length, + SkillPackageContentTypeResolver.determineContentType(packagePath) + )); + } + } + return entries.stream() + .sorted(Comparator.comparing(PackageEntry::path)) + .toList(); + } + + private String relativeBuiltinPath(Resource resource) throws IOException { + String url = resource.getURL().toExternalForm(); + int markerIndex = url.lastIndexOf(ROOT_MARKER); + if (markerIndex < 0) { + return null; + } + return url.substring(markerIndex + ROOT_MARKER.length()); + } + + public record BuiltinSkillPackage(String directory, List entries) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java new file mode 100644 index 00000000..298a1af5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.bootstrap; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Configuration for publishing bundled example skills at application startup. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.builtin-skills") +public class BuiltinSkillProperties { + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 0a9fc793..481cda72 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -85,7 +85,7 @@ public class MultipartPackageExtractor { normalizedPath, content, content.length, - determineContentType(normalizedPath) + SkillPackageContentTypeResolver.determineContentType(normalizedPath) )); } } @@ -113,12 +113,4 @@ public class MultipartPackageExtractor { return path; } - private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; - return "application/octet-stream"; - } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index e2aa41c3..489928bd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -74,7 +74,7 @@ public class SkillPackageArchiveExtractor { normalizedPath, content, content.length, - determineContentType(normalizedPath) + SkillPackageContentTypeResolver.determineContentType(normalizedPath) )); zis.closeEntry(); } @@ -189,28 +189,4 @@ public class SkillPackageArchiveExtractor { return outputStream.toByteArray(); } - private String determineContentType(String filename) { - String lower = filename.toLowerCase(); - if (lower.endsWith(".py")) return "text/x-python"; - if (lower.endsWith(".json")) return "application/json"; - if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; - if (lower.endsWith(".txt")) return "text/plain"; - if (lower.endsWith(".md")) return "text/markdown"; - if (lower.endsWith(".html")) return "text/html"; - if (lower.endsWith(".css")) return "text/css"; - if (lower.endsWith(".csv")) return "text/csv"; - if (lower.endsWith(".xml")) return "application/xml"; - if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; - if (lower.endsWith(".ts")) return "text/typescript"; - if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".svg")) return "image/svg+xml"; - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".ico")) return "image/x-icon"; - if (lower.endsWith(".pdf")) return "application/pdf"; - if (lower.endsWith(".toml")) return "application/toml"; - return "application/octet-stream"; - } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java new file mode 100644 index 00000000..25224e96 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.support; + +/** + * Resolves package entry content types from filenames for upload and built-in package ingestion. + */ +public final class SkillPackageContentTypeResolver { + + private SkillPackageContentTypeResolver() { + } + + public static String determineContentType(String filename) { + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 2beaec70..e9c993f6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -66,7 +66,7 @@ public class ZipPackageExtractor { normalizedPath, content, content.length, - determineContentType(normalizedPath) + SkillPackageContentTypeResolver.determineContentType(normalizedPath) )); zis.closeEntry(); } @@ -119,28 +119,4 @@ public class ZipPackageExtractor { } } - private String determineContentType(String filename) { - String lower = filename.toLowerCase(); - if (lower.endsWith(".py")) return "text/x-python"; - if (lower.endsWith(".json")) return "application/json"; - if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; - if (lower.endsWith(".txt")) return "text/plain"; - if (lower.endsWith(".md")) return "text/markdown"; - if (lower.endsWith(".html")) return "text/html"; - if (lower.endsWith(".css")) return "text/css"; - if (lower.endsWith(".csv")) return "text/csv"; - if (lower.endsWith(".xml")) return "application/xml"; - if (lower.endsWith(".js")) return "text/javascript"; - if (lower.endsWith(".ts")) return "text/typescript"; - if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".svg")) return "image/svg+xml"; - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".ico")) return "image/x-icon"; - if (lower.endsWith(".pdf")) return "application/pdf"; - if (lower.endsWith(".toml")) return "application/toml"; - return "application/octet-stream"; - } } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a592b035..7d9a1cbc 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -93,6 +93,8 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: + builtin-skills: + enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} diff --git a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md new file mode 100644 index 00000000..4cff8bc4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md @@ -0,0 +1,5 @@ +# SkillHub Hello + +This built-in example skill is published to `@global` when SkillHub starts. + +Use it to verify that skill discovery and CLI installation are working in a new deployment. diff --git a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md new file mode 100644 index 00000000..8a313b0c --- /dev/null +++ b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md @@ -0,0 +1,8 @@ +--- +name: skillhub-hello +description: A built-in example skill that verifies SkillHub discovery and installation. +version: 1.0.0 +--- +# SkillHub Hello + +This skill is bundled with SkillHub as a minimal example for validating discovery and installation. diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java new file mode 100644 index 00000000..299a4ddb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -0,0 +1,383 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.skill.validation.ValidationResult; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BuiltinSkillInitializerTest { + + private static final String PUBLISHER_ID = BuiltinSkillInitializer.BUILTIN_PUBLISHER_ID; + + @Mock private BuiltinSkillPackageLoader packageLoader; + @Mock private SkillPackageValidator packageValidator; + @Mock private SkillPublishService skillPublishService; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private UserAccountRepository userAccountRepository; + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository skillVersionRepository; + @Mock private SkillFileRepository skillFileRepository; + @Mock private PlatformTransactionManager transactionManager; + + private BuiltinSkillProperties properties; + private BuiltinSkillInitializer initializer; + private Namespace globalNamespace; + + @BeforeEach + void setUp() { + properties = new BuiltinSkillProperties(); + globalNamespace = new Namespace("global", "Global", "system"); + ReflectionTestUtils.setField(globalNamespace, "id", 1L); + lenient().when(transactionManager.getTransaction(any())).thenAnswer(ignored -> new SimpleTransactionStatus()); + lenient().when(packageValidator.validate(any())).thenReturn(ValidationResult.pass()); + + initializer = new BuiltinSkillInitializer( + properties, + packageLoader, + new SkillMetadataParser(), + packageValidator, + skillPublishService, + namespaceRepository, + namespaceMemberRepository, + userAccountRepository, + skillRepository, + skillVersionRepository, + skillFileRepository, + transactionManager + ); + } + + @Test + void disabledInitializerDoesNotLoadPackages() throws Exception { + properties.setEnabled(false); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(packageLoader, never()).loadPackages(); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void firstStartupCreatesPublisherMembershipAndPublishesPackage() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) + .thenReturn(Optional.empty()); + when(skillPublishService.publishFromEntries( + eq("global"), + eq(skillPackage.entries()), + eq(PUBLISHER_ID), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false) + )).thenReturn(publishResult("1.0.0")); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(userAccountRepository).save(any(UserAccount.class)); + verify(namespaceMemberRepository).save(any(NamespaceMember.class)); + verify(skillPublishService).publishFromEntries( + "global", + skillPackage.entries(), + PUBLISHER_ID, + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false + ); + } + + @Test + void existingPublisherAndMembershipAreReusedIdempotently() throws Exception { + UserAccount existingPublisher = new UserAccount(PUBLISHER_ID, "Old name", "old@example.invalid", null); + NamespaceMember existingMember = new NamespaceMember(1L, PUBLISHER_ID, NamespaceRole.MEMBER); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace)); + when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.of(existingPublisher)); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID)) + .thenReturn(Optional.of(existingMember)); + when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(packageLoader.loadPackages()).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(userAccountRepository).save(existingPublisher); + verify(namespaceMemberRepository).save(existingMember); + org.assertj.core.api.Assertions.assertThat(existingMember.getRole()).isEqualTo(NamespaceRole.OWNER); + } + + @Test + void validationWarningsSkipPublishWithoutConfirmingWarnings() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(packageValidator.validate(skillPackage.entries())) + .thenReturn(new ValidationResult(true, List.of(), List.of("warning"))); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void samePublishedVersionWithSameFingerprintSkipsPublish() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + Skill skill = builtInSkill(11L); + SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) + .thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); + when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries())); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void samePublishedVersionWithDifferentFingerprintSkipsPublish() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + Skill skill = builtInSkill(11L); + SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) + .thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); + when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of( + new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key") + )); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void sameNonPublishedVersionSkipsPublish() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + Skill skill = builtInSkill(11L); + SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.UPLOADED); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) + .thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillFileRepository, never()).findByVersionId(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void newerBuiltInVersionPublishesWhenOlderVersionExists() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.1", "Hello"); + Skill skill = builtInSkill(11L); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) + .thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.1")).thenReturn(Optional.empty()); + when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) + .thenReturn(publishResult("1.0.1")); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService).publishFromEntries( + "global", + skillPackage.entries(), + PUBLISHER_ID, + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false + ); + } + + @Test + void userOwnedPublishedSlugSkipsBuiltInPublish() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + Skill otherSkill = new Skill(1L, "skillhub-hello", "user-1", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(otherSkill, "id", 33L); + SkillVersion otherPublishedVersion = version(33L, 44L, "1.0.0", SkillVersionStatus.PUBLISHED); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(33L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(otherPublishedVersion)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillRepository, never()).findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void publishFailureIsContainedAndDoesNotAbortStartup() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) + .thenReturn(Optional.empty()); + when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) + .thenThrow(new IllegalStateException("storage unavailable")); + + assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) + .doesNotThrowAnyException(); + } + + @Test + void concurrentPublishedSameFingerprintSkipsAfterPublishFailure() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + Skill skill = builtInSkill(11L); + SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) + .thenReturn(Optional.empty(), Optional.of(skill)); + when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) + .thenThrow(new IllegalStateException("version exists")); + when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); + when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries())); + + assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) + .doesNotThrowAnyException(); + } + + @Test + void concurrentPublishedDifferentFingerprintSkipsAfterPublishFailure() throws Exception { + BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); + Skill skill = builtInSkill(11L); + SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); + setupPublisher(); + when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) + .thenReturn(Optional.empty(), Optional.of(skill)); + when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) + .thenThrow(new IllegalStateException("version exists")); + when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); + when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of( + new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key") + )); + + assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) + .doesNotThrowAnyException(); + } + + private void setupPublisher() { + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace)); + when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID)).thenReturn(Optional.empty()); + when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + private BuiltinSkillPackageLoader.BuiltinSkillPackage packageWithVersion(String version, String body) { + byte[] skillMd = (""" + --- + name: skillhub-hello + description: SkillHub hello + version: %s + --- + # SkillHub Hello + %s + """.formatted(version, body)).getBytes(StandardCharsets.UTF_8); + byte[] readme = "# SkillHub Hello\n".getBytes(StandardCharsets.UTF_8); + return new BuiltinSkillPackageLoader.BuiltinSkillPackage("skillhub-hello", List.of( + new PackageEntry("README.md", readme, readme.length, "text/markdown"), + new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown") + )); + } + + private Skill builtInSkill(Long id) { + Skill skill = new Skill(1L, "skillhub-hello", PUBLISHER_ID, SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", id); + return skill; + } + + private SkillVersion version(Long skillId, Long versionId, String version, SkillVersionStatus status) { + SkillVersion skillVersion = new SkillVersion(skillId, version, PUBLISHER_ID); + skillVersion.setStatus(status); + ReflectionTestUtils.setField(skillVersion, "id", versionId); + return skillVersion; + } + + private SkillPublishService.PublishResult publishResult(String version) { + SkillVersion skillVersion = version(11L, 22L, version, SkillVersionStatus.PUBLISHED); + return new SkillPublishService.PublishResult(11L, "skillhub-hello", skillVersion); + } + + private List filesFor(Long versionId, List entries) { + return entries.stream() + .map(entry -> new SkillFile( + versionId, + entry.path(), + entry.size(), + entry.contentType(), + sha256(entry.content()), + "skills/11/" + versionId + "/" + entry.path() + )) + .toList(); + } + + private String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java new file mode 100644 index 00000000..143da879 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java @@ -0,0 +1,124 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import static org.assertj.core.api.Assertions.assertThat; + +class BuiltinSkillPackageLoaderTest { + + @TempDir + Path tempDir; + + @Test + void loadsSkillhubHelloFromClasspath() throws Exception { + BuiltinSkillPackageLoader loader = + new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver()); + + var packages = loader.loadPackages(); + + var skillhubHello = packages.stream() + .filter(skillPackage -> skillPackage.directory().equals("skillhub-hello")) + .findFirst() + .orElseThrow(); + assertThat(skillhubHello.entries()) + .extracting(entry -> entry.path()) + .containsExactly("README.md", "SKILL.md"); + assertThat(skillhubHello.entries()) + .allSatisfy(entry -> assertThat(entry.contentType()).isEqualTo("text/markdown")); + } + + @Test + void productionSkillhubHelloResourcePassesPackageValidation() throws Exception { + BuiltinSkillPackageLoader loader = + new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver()); + SkillPackageValidator validator = new SkillPackageValidator(new SkillMetadataParser()); + + var skillhubHello = loader.loadPackages().stream() + .filter(skillPackage -> skillPackage.directory().equals("skillhub-hello")) + .findFirst() + .orElseThrow(); + + var result = validator.validate(skillhubHello.entries()); + assertThat(result.passed()).isTrue(); + assertThat(result.warnings()).isEmpty(); + } + + @Test + void loadsBuiltInPackageFromJarClasspath() throws Exception { + Path jarPath = tempDir.resolve("builtin-skills.jar"); + try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(jarPath))) { + writeJarDirectory(jarOutputStream, "builtin-skills/"); + writeJarDirectory(jarOutputStream, "builtin-skills/skillhub-hello/"); + writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/SKILL.md", """ + --- + name: skillhub-hello + description: SkillHub hello + version: 1.0.0 + --- + # SkillHub Hello + """); + writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/README.md", "# SkillHub Hello\n"); + } + + try (URLClassLoader classLoader = new URLClassLoader( + new java.net.URL[]{jarPath.toUri().toURL()}, + null + )) { + BuiltinSkillPackageLoader loader = + new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver(classLoader)); + + var packages = loader.loadPackages(); + + assertThat(packages).hasSize(1); + assertThat(packages.getFirst().directory()).isEqualTo("skillhub-hello"); + assertThat(packages.getFirst().entries()) + .extracting(entry -> entry.path()) + .containsExactly("README.md", "SKILL.md"); + } + } + + @Test + void fingerprintsAreStableAcrossEntryOrdering() { + byte[] skillMd = """ + --- + name: demo + description: Demo + version: 1.0.0 + --- + # Demo + """.getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] readme = "# Demo\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var first = List.of( + new com.iflytek.skillhub.domain.skill.validation.PackageEntry( + "SKILL.md", skillMd, skillMd.length, "text/markdown"), + new com.iflytek.skillhub.domain.skill.validation.PackageEntry( + "README.md", readme, readme.length, "text/markdown") + ); + var second = List.of(first.get(1), first.get(0)); + + assertThat(BuiltinSkillFingerprints.fromEntries(first)) + .isEqualTo(BuiltinSkillFingerprints.fromEntries(second)); + } + + private void writeJarDirectory(JarOutputStream jarOutputStream, String name) throws Exception { + jarOutputStream.putNextEntry(new JarEntry(name)); + jarOutputStream.closeEntry(); + } + + private void writeJarEntry(JarOutputStream jarOutputStream, String name, String content) throws Exception { + jarOutputStream.putNextEntry(new JarEntry(name)); + jarOutputStream.write(content.getBytes(StandardCharsets.UTF_8)); + jarOutputStream.closeEntry(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java new file mode 100644 index 00000000..04461428 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.bootstrap; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.env.SystemEnvironmentPropertySource; +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; + +class BuiltinSkillPropertiesBindingTest { + + @Test + void defaultConfigEnablesBuiltinSkills() throws Exception { + BuiltinSkillProperties properties = bindProperties(Map.of()); + + assertThat(properties.isEnabled()).isTrue(); + } + + @Test + void environmentVariableCanDisableBuiltinSkills() throws Exception { + BuiltinSkillProperties properties = bindProperties(Map.of("SKILLHUB_BUILTIN_SKILLS_ENABLED", "false")); + + assertThat(properties.isEnabled()).isFalse(); + } + + private BuiltinSkillProperties bindProperties(Map envVars) throws Exception { + ConfigurableEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource("test-env", envVars)); + + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + for (org.springframework.core.env.PropertySource propertySource : + loader.load("application.yml", new ClassPathResource("application.yml"))) { + environment.getPropertySources().addLast(propertySource); + } + ConfigurationPropertySources.attach(environment); + + return Binder.get(environment) + .bind("skillhub.builtin-skills", BuiltinSkillProperties.class) + .orElseThrow(() -> new IllegalStateException("Failed to bind built-in skill properties")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java new file mode 100644 index 00000000..90166104 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.controller.support; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SkillPackageContentTypeResolverTest { + + @Test + void determinesKnownContentTypes() { + assertThat(SkillPackageContentTypeResolver.determineContentType("SKILL.md")).isEqualTo("text/markdown"); + assertThat(SkillPackageContentTypeResolver.determineContentType("script.py")).isEqualTo("text/x-python"); + assertThat(SkillPackageContentTypeResolver.determineContentType("config.yaml")).isEqualTo("application/x-yaml"); + assertThat(SkillPackageContentTypeResolver.determineContentType("image.png")).isEqualTo("image/png"); + assertThat(SkillPackageContentTypeResolver.determineContentType("archive.bin")).isEqualTo("application/octet-stream"); + } +} From 1ec93db0d6a4d40dd082b3c1a74056fc7c56404c Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:25:02 +0800 Subject: [PATCH 06/78] Revert "feat(bootstrap): initialize built-in skills (#481)" (#487) This reverts commit 90fc97e740dd5184e27699aaeeec746cf8b0afae. --- README.md | 7 - docs/20-builtin-skills-design.md | 382 ----------------- docs/openclaw-integration-en.md | 13 - docs/openclaw-integration.md | 13 - .../bootstrap/BuiltinSkillFingerprints.java | 42 -- .../bootstrap/BuiltinSkillInitializer.java | 295 -------------- .../bootstrap/BuiltinSkillPackageLoader.java | 107 ----- .../bootstrap/BuiltinSkillProperties.java | 21 - .../support/MultipartPackageExtractor.java | 10 +- .../support/SkillPackageArchiveExtractor.java | 26 +- .../SkillPackageContentTypeResolver.java | 35 -- .../support/ZipPackageExtractor.java | 26 +- .../src/main/resources/application.yml | 2 - .../builtin-skills/skillhub-hello/README.md | 5 - .../builtin-skills/skillhub-hello/SKILL.md | 8 - .../BuiltinSkillInitializerTest.java | 383 ------------------ .../BuiltinSkillPackageLoaderTest.java | 124 ------ .../BuiltinSkillPropertiesBindingTest.java | 47 --- .../SkillPackageContentTypeResolverTest.java | 17 - 19 files changed, 59 insertions(+), 1504 deletions(-) delete mode 100644 docs/20-builtin-skills-design.md delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java delete mode 100644 server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md delete mode 100644 server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java diff --git a/README.md b/README.md index 8f0e4860..d47ef5cc 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,6 @@ skillhub login --token sk_xxx --registry https://skill.xfyun.cn skillhub search pdf skillhub install pdf-parser --agent codex -# Verify the bundled example skill after a fresh deployment -skillhub search skillhub-hello -skillhub install skillhub-hello --agent codex - # List installed skills skillhub list ``` @@ -290,7 +286,6 @@ Recommended production baseline: - keep PostgreSQL / Redis bound to `127.0.0.1` - use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` - change `BOOTSTRAP_ADMIN_PASSWORD` to a strong password (`validate-release-config.sh` rejects the default `ChangeMe!2026`) -- set `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` if you do not want the bundled `skillhub-hello` skill initialized in `@global` - rotate or disable the bootstrap admin after initial setup - run `make validate-release-config` before `docker compose up -d` @@ -426,8 +421,6 @@ clawhub login --token YOUR_API_TOKEN npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill -npx clawhub search skillhub-hello -npx clawhub install skillhub-hello # Publish to global namespace npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 diff --git a/docs/20-builtin-skills-design.md b/docs/20-builtin-skills-design.md deleted file mode 100644 index d968df5a..00000000 --- a/docs/20-builtin-skills-design.md +++ /dev/null @@ -1,382 +0,0 @@ -# Built-in Skills Initialization Design - -## Context - -New SkillHub deployments currently start without a guaranteed installable skill in the registry. -Users must first understand publishing or find an external package before they can verify search, -detail, download, and CLI installation flows. - -This design adds a small, built-in example skill that is bundled with the Java service and published -automatically to `@global` during application startup. - -The MVP example skill is `skillhub-hello`. It is intentionally generic and does not encode an -AgentGuard-specific product decision. Future official skills can reuse the same initialization -mechanism. - -## Goals - -- Bundle one or more directory-form skills in the Java service resources. -- Enable built-in skill initialization by default. -- Publish built-in skills to the fixed `global` namespace. -- Publish as `PUBLIC` and `PUBLISHED` so the skill is immediately searchable and installable. -- Use a fixed system publisher, `builtin-skill-publisher`, for owner and audit traceability. -- Reuse the existing `SkillPublishService.publishFromEntries(...)` pipeline. -- Keep initialization idempotent across repeated container deployments. -- Treat published versions as immutable: same version with changed content is skipped with a warning. -- Avoid new seed state tables and distributed locks in the MVP. -- Keep initialization failures non-fatal to application startup. -- Document `skillhub-hello` as an out-of-the-box verification skill. - -## Non-Goals - -- No new database table for seed state. -- No Redis or database distributed lock. -- No zip-based built-in skill packages. -- No configurable target namespace; built-in skills always publish to `global`. -- No label creation or binding for `skillhub-hello`. -- No landing page or frontend recommendation slot. -- No direct SQL/JPA insertion into `skill`, `skill_version`, or `skill_file`. -- No changes to ordinary publish, review, promotion, or lifecycle behavior. - -## Key Decisions - -| Decision | Choice | Reason | -|----------|--------|--------| -| Source location | Java service classpath resources | The runtime artifact always contains the built-in package | -| Directory | `server/skillhub-app/src/main/resources/builtin-skills/` | Spring Boot resource packaging is predictable | -| First built-in skill | `skillhub-hello` | Generic verification skill, not product-specific | -| Startup default | Enabled | Supports out-of-the-box discovery and installation | -| Target namespace | Fixed `global` | Built-in examples are platform-level public skills | -| Publication state | `PUBLIC + PUBLISHED` | Immediately searchable and installable | -| Publisher | `builtin-skill-publisher` | Stable owner and audit source | -| Version mutability | Same version is never overwritten | Published versions remain reproducible | -| Seed state table | None | Existing skill/version/file records are enough for MVP idempotency | -| Distributed lock | None | Conflict-tolerant startup is sufficient for a small built-in set | -| Labels | None | MVP validates the built-in publish mechanism only | -| Failure behavior | Log and continue | A sample skill must not make the service unavailable | -| Package format | Directory only | Easier review and classpath loading | - -## Resource Layout - -Built-in skills live under the `skillhub-app` resource tree: - -```text -server/skillhub-app/src/main/resources/builtin-skills/ - skillhub-hello/ - SKILL.md - README.md -``` - -Each direct child directory under `builtin-skills/` is treated as one skill package. - -Rules: - -- The directory must contain root-level `SKILL.md`. -- File paths are relative to the skill directory. -- Files are converted into `PackageEntry` values. -- Files outside the skill directory are ignored. -- Zip packages are not supported in the MVP. - -Suggested `skillhub-hello/SKILL.md`: - -```markdown ---- -name: skillhub-hello -description: A built-in example skill that verifies SkillHub discovery and installation. -version: 1.0.0 ---- -# SkillHub Hello - -This skill is bundled with SkillHub as a minimal example for validating discovery and installation. -``` - -Published coordinate: - -```text -@global/skillhub-hello -``` - -ClawHub canonical slug: - -```text -skillhub-hello -``` - -## Configuration - -Add one configuration property: - -```yaml -skillhub: - builtin-skills: - enabled: true -``` - -Environment override: - -```bash -SKILLHUB_BUILTIN_SKILLS_ENABLED=false -``` - -The MVP does not expose a namespace or locations property. The implementation uses the fixed -classpath location: - -```text -classpath*:builtin-skills/*/SKILL.md -``` - -## Backend Design - -### Components - -Add the following app-layer bootstrap components: - -- `BuiltinSkillProperties` -- `BuiltinSkillPackageLoader` -- `BuiltinSkillInitializer` - -Responsibilities: - -| Component | Responsibility | -|-----------|----------------| -| `BuiltinSkillProperties` | Bind `skillhub.builtin-skills.enabled` | -| `BuiltinSkillPackageLoader` | Read classpath skill directories and construct `PackageEntry` values | -| `BuiltinSkillInitializer` | Ensure publisher, evaluate idempotency, and call the publish pipeline | - -These classes belong in: - -```text -server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/ -``` - -### Publish Pipeline - -The initializer must call the existing domain publish service: - -```java -skillPublishService.publishFromEntries( - "global", - entries, - "builtin-skill-publisher", - SkillVisibility.PUBLIC, - Set.of("SUPER_ADMIN"), - false -); -``` - -This preserves existing behavior for: - -- package policy validation -- `SKILL.md` parsing -- slug generation -- `Skill` creation or reuse -- `SkillVersion` creation -- `PUBLISHED` state assignment -- `latestVersionId` updates -- `SkillFile` records -- object storage writes -- bundle zip creation -- `SkillPublishedEvent` -- after-commit search index rebuild - -The initializer must not create skill/version/file rows directly. -Passing `false` for warning confirmation means built-in packages with validation warnings are treated -as package quality failures and skipped instead of being silently accepted. - -### System Publisher - -The initializer ensures this system user exists: - -```text -userId: builtin-skill-publisher -displayName: SkillHub Built-in Publisher -email: builtin-skill-publisher@example.invalid -``` - -Requirements: - -- Create `UserAccount` if missing. -- Ensure the user is an `OWNER` member of `@global`. -- Do not create a local login credential. -- Do not require a persisted platform role binding. -- Pass `Set.of("SUPER_ADMIN")` only for the publish call to reuse auto-publish behavior. - -## Idempotency And Version Policy - -### Content Fingerprint - -The initializer computes a package fingerprint from current classpath resources: - -1. Sort entries by normalized path. -2. Hash each file content with SHA-256. -3. Build a canonical stream of `path + fileSha256`. -4. Hash that stream to produce the package fingerprint. - -For an existing published version, the initializer recomputes the same fingerprint from `skill_file`: - -1. Query the target `SkillVersion`. -2. Query its `SkillFile` rows. -3. Sort by `filePath`. -4. Build the canonical stream from `filePath + sha256`. -5. Hash that stream. - -No new persistence field is added. - -### Startup Rules - -For each built-in skill: - -| Existing state | Action | -|----------------|--------| -| Skill does not exist | Publish | -| Skill exists, same version does not exist | Publish new version | -| Same version is `PUBLISHED` and fingerprint matches | Skip | -| Same version is `PUBLISHED` and fingerprint differs | Warn and skip | -| Same version exists but is not `PUBLISHED` | Warn and skip | - -Same-version content changes must bump the version in `SKILL.md`. The initializer must never -overwrite a published version. - -### Concurrent Startup - -The MVP does not use a distributed lock. - -If multiple application instances start at the same time: - -- each instance performs the idempotency check; -- one instance may publish first; -- later instances may hit an existing-version conflict; -- conflict handling should re-read the existing version and skip when a valid published version is present; -- conflicts must be logged but must not fail application startup. - -## Failure Behavior - -Built-in skill initialization is best-effort. - -Rules: - -- Failure in one built-in skill does not prevent other built-in skills from being processed. -- Any initialization failure is logged at error level. -- Same-version fingerprint drift is logged at warning level. -- Same-version matching content is logged at info level. -- Successful publishing is logged at info level. -- Exceptions are contained inside the initializer and do not abort Spring Boot startup. - -Log context should include: - -- skill directory -- resolved slug -- resolved version -- namespace `global` -- action -- error message when applicable - -## Object Storage And Search - -Classpath resources are only the source for initialization. Published files still go through the -configured object storage backend: - -- LocalFile -- MinIO -- S3 - -The initializer does not write object storage directly. - -Search index updates also remain event-driven. The publish service emits `SkillPublishedEvent`, and -the existing search listener rebuilds the search document after transaction commit. - -## User Experience - -The MVP does not add frontend UI. - -After startup, users can discover and install the built-in skill through existing flows: - -- search for `skillhub-hello`; -- open the skill detail page; -- copy the existing install command; -- install with ClawHub/OpenClaw. - -Expected command: - -```bash -npx clawhub install skillhub-hello --registry -``` - -Because labels and recommendation slots are out of scope, this design does not guarantee permanent -homepage prominence for `skillhub-hello`. - -## Documentation - -Update the user-facing docs to mention the built-in verification skill: - -- `README.md` -- `docs/openclaw-integration.md` -- `docs/openclaw-integration-en.md` - -Recommended example: - -```bash -npx clawhub search skillhub-hello --registry -npx clawhub install skillhub-hello --registry -``` - -The docs should explain: - -- `skillhub-hello` is bundled with SkillHub; -- it validates registry search and installation; -- operators can disable initialization with `SKILLHUB_BUILTIN_SKILLS_ENABLED=false`. - -## Testing Strategy - -### Unit Tests - -Add backend tests for: - -- `enabled=false` skips all publishing. -- first startup publishes `skillhub-hello`; -- same version and same fingerprint skips; -- same version and different fingerprint warns and skips; -- same version in a non-`PUBLISHED` state warns and skips; -- publish exceptions are swallowed after logging; -- missing system publisher is created; -- missing `@global` membership is created; -- loader reads classpath directories into stable `PackageEntry` order; -- loader reports a directory missing `SKILL.md`. - -### Local Validation - -Run: - -```bash -make test-backend-app -``` - -If implementation touches runtime packaging or staging startup behavior, also run: - -```bash -make staging -``` - -### Manual Validation - -After local startup: - -```bash -curl "http://localhost:8080/api/web/skills?q=skillhub-hello" -npx clawhub search skillhub-hello --registry http://localhost:8080 -npx clawhub install skillhub-hello --registry http://localhost:8080 -``` - -## Future Extensions - -Potential follow-up work: - -- external filesystem source locations; -- zip package support; -- seed state table; -- distributed lock; -- official label binding; -- landing page official/recommended slot; -- AgentGuard or other official skills built on the same mechanism. - -These are outside the MVP. diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 77d93e5e..32cb2878 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -72,10 +72,6 @@ npx clawhub search find-skills npx clawhub search find-skills --limit 5 npx clawhub inspect find-skills -# Bundled verification skill initialized by default on new deployments -npx clawhub search skillhub-hello -npx clawhub inspect skillhub-hello - # Help npx clawhub search --help npx clawhub inspect --help @@ -104,9 +100,6 @@ npx clawhub list npx clawhub --dir ~/.claude/skills install find-skills CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills -# Install the bundled verification skill -npx clawhub install skillhub-hello - # Help npx clawhub install --help npx clawhub update --help @@ -217,12 +210,6 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com clawhub login --token sk_your_api_token_here ``` -New SkillHub deployments initialize the bundled `skillhub-hello` skill in `@global` by default. To disable built-in skill initialization, set this before starting the backend: - -```bash -export SKILLHUB_BUILTIN_SKILLS_ENABLED=false -``` - ## FAQ ### Q: How do I switch back to public ClawHub? diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md index 353676c3..f85f588e 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -72,10 +72,6 @@ npx clawhub search find-skills npx clawhub search find-skills --limit 5 npx clawhub inspect find-skills -# 新部署默认内置的验证 Skill -npx clawhub search skillhub-hello -npx clawhub inspect skillhub-hello - # 使用帮助 npx clawhub search --help npx clawhub inspect --help @@ -104,9 +100,6 @@ npx clawhub list npx clawhub --dir ~/.claude/skills install find-skills CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills -# 安装默认内置的验证 Skill -npx clawhub install skillhub-hello - # 使用帮助 npx clawhub install --help npx clawhub update --help @@ -217,12 +210,6 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com clawhub login --token sk_your_api_token_here ``` -SkillHub 新部署默认会在 `@global` 初始化内置 `skillhub-hello`。如需关闭内置 Skill 初始化,请在启动后端前设置: - -```bash -export SKILLHUB_BUILTIN_SKILLS_ENABLED=false -``` - ## 常见问题 ### Q: 如何切换回公共 ClawHub? diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java deleted file mode 100644 index 836f733f..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import com.iflytek.skillhub.domain.skill.SkillFile; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Comparator; -import java.util.HexFormat; -import java.util.List; - -final class BuiltinSkillFingerprints { - - private BuiltinSkillFingerprints() { - } - - static String fromEntries(List entries) { - StringBuilder canonical = new StringBuilder(); - entries.stream() - .sorted(Comparator.comparing(PackageEntry::path)) - .map(entry -> entry.path() + "\0" + sha256(entry.content())) - .forEach(line -> canonical.append(line).append('\n')); - return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); - } - - static String fromFiles(List files) { - StringBuilder canonical = new StringBuilder(); - files.stream() - .sorted(Comparator.comparing(SkillFile::getFilePath)) - .map(file -> file.getFilePath() + "\0" + file.getSha256()) - .forEach(line -> canonical.append(line).append('\n')); - return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); - } - - private static String sha256(byte[] content) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(content)); - } catch (Exception exception) { - throw new IllegalStateException("Failed to calculate SHA-256 fingerprint", exception); - } - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java deleted file mode 100644 index 21c58f95..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ /dev/null @@ -1,295 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import com.iflytek.skillhub.domain.namespace.Namespace; -import com.iflytek.skillhub.domain.namespace.NamespaceMember; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.skill.Skill; -import com.iflytek.skillhub.domain.skill.SkillFileRepository; -import com.iflytek.skillhub.domain.skill.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; -import com.iflytek.skillhub.domain.skill.SkillVersionStatus; -import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; -import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; -import com.iflytek.skillhub.domain.skill.validation.ValidationResult; -import com.iflytek.skillhub.domain.namespace.SlugValidator; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.domain.user.UserStatus; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.TransactionTemplate; - -/** - * Publishes bundled example skills into the global namespace during startup. - */ -@Component -public class BuiltinSkillInitializer implements ApplicationRunner { - - static final String BUILTIN_PUBLISHER_ID = "builtin-skill-publisher"; - static final String GLOBAL_NAMESPACE = "global"; - private static final String BUILTIN_PUBLISHER_NAME = "SkillHub Built-in Publisher"; - private static final String BUILTIN_PUBLISHER_EMAIL = "builtin-skill-publisher@example.invalid"; - private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class); - - private final BuiltinSkillProperties properties; - private final BuiltinSkillPackageLoader packageLoader; - private final SkillMetadataParser metadataParser; - private final SkillPackageValidator packageValidator; - private final SkillPublishService skillPublishService; - private final NamespaceRepository namespaceRepository; - private final NamespaceMemberRepository namespaceMemberRepository; - private final UserAccountRepository userAccountRepository; - private final SkillRepository skillRepository; - private final SkillVersionRepository skillVersionRepository; - private final SkillFileRepository skillFileRepository; - private final TransactionTemplate transactionTemplate; - - public BuiltinSkillInitializer(BuiltinSkillProperties properties, - BuiltinSkillPackageLoader packageLoader, - SkillMetadataParser metadataParser, - SkillPackageValidator packageValidator, - SkillPublishService skillPublishService, - NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository, - UserAccountRepository userAccountRepository, - SkillRepository skillRepository, - SkillVersionRepository skillVersionRepository, - SkillFileRepository skillFileRepository, - PlatformTransactionManager transactionManager) { - this.properties = properties; - this.packageLoader = packageLoader; - this.metadataParser = metadataParser; - this.packageValidator = packageValidator; - this.skillPublishService = skillPublishService; - this.namespaceRepository = namespaceRepository; - this.namespaceMemberRepository = namespaceMemberRepository; - this.userAccountRepository = userAccountRepository; - this.skillRepository = skillRepository; - this.skillVersionRepository = skillVersionRepository; - this.skillFileRepository = skillFileRepository; - this.transactionTemplate = new TransactionTemplate(transactionManager); - } - - @Override - public void run(ApplicationArguments args) { - if (!properties.isEnabled()) { - log.info("Built-in skill initialization is disabled"); - return; - } - - Namespace globalNamespace; - try { - globalNamespace = ensurePublisher(); - } catch (RuntimeException exception) { - log.error("Failed to prepare built-in skill publisher, skipping built-in skill initialization", - exception); - return; - } - if (globalNamespace == null) { - return; - } - - List packages; - try { - packages = packageLoader.loadPackages(); - } catch (Exception exception) { - log.error("Failed to load built-in skill packages", exception); - return; - } - - for (BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage : packages) { - try { - initializePackage(globalNamespace, skillPackage); - } catch (Exception exception) { - log.error("Failed to initialize built-in skill package [directory={}]", - skillPackage.directory(), exception); - } - } - } - - private Namespace ensurePublisher() { - return transactionTemplate.execute(status -> { - Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE) - .orElse(null); - if (globalNamespace == null) { - log.error("Missing built-in global namespace, skipping built-in skill initialization"); - return null; - } - - UserAccount publisher = userAccountRepository.findById(BUILTIN_PUBLISHER_ID) - .orElseGet(() -> new UserAccount( - BUILTIN_PUBLISHER_ID, - BUILTIN_PUBLISHER_NAME, - BUILTIN_PUBLISHER_EMAIL, - null - )); - publisher.setDisplayName(BUILTIN_PUBLISHER_NAME); - publisher.setEmail(BUILTIN_PUBLISHER_EMAIL); - publisher.setStatus(UserStatus.ACTIVE); - userAccountRepository.save(publisher); - - NamespaceMember member = namespaceMemberRepository - .findByNamespaceIdAndUserId(globalNamespace.getId(), BUILTIN_PUBLISHER_ID) - .orElseGet(() -> new NamespaceMember(globalNamespace.getId(), BUILTIN_PUBLISHER_ID, NamespaceRole.OWNER)); - if (member.getRole() != NamespaceRole.OWNER) { - member.setRole(NamespaceRole.OWNER); - } - namespaceMemberRepository.save(member); - return globalNamespace; - }); - } - - private void initializePackage(Namespace globalNamespace, - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage) { - List entries = skillPackage.entries(); - SkillMetadata metadata = parseMetadata(skillPackage.directory(), entries); - if (metadata.version() == null || metadata.version().isBlank()) { - log.error("Built-in skill package is missing an explicit version [directory={}]", - skillPackage.directory()); - return; - } - String skillSlug = SlugValidator.slugify(metadata.name()); - - ValidationResult validation = packageValidator.validate(entries); - if (!validation.passed() || validation.hasWarnings()) { - log.error("Built-in skill package failed validation [directory={}, slug={}, version={}, errors={}, warnings={}]", - skillPackage.directory(), skillSlug, metadata.version(), validation.errors(), validation.warnings()); - return; - } - - if (hasPublishedOtherOwnerConflict(globalNamespace.getId(), skillSlug)) { - log.warn("Skipping built-in skill because another owner already published the slug [directory={}, namespace={}, slug={}]", - skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug); - return; - } - - Optional existingBuiltInSkill = - skillRepository.findByNamespaceIdAndSlugAndOwnerId(globalNamespace.getId(), skillSlug, BUILTIN_PUBLISHER_ID); - if (existingBuiltInSkill.isPresent() - && shouldSkipExistingVersion(skillPackage, existingBuiltInSkill.get(), metadata.version())) { - return; - } - - publishPackage(skillPackage, skillSlug, metadata.version()); - } - - private SkillMetadata parseMetadata(String directory, List entries) { - PackageEntry skillMd = entries.stream() - .filter(entry -> "SKILL.md".equals(entry.path())) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Built-in package missing SKILL.md: " + directory)); - return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); - } - - private boolean hasPublishedOtherOwnerConflict(Long namespaceId, String skillSlug) { - return skillRepository.findByNamespaceIdAndSlug(namespaceId, skillSlug).stream() - .filter(skill -> !BUILTIN_PUBLISHER_ID.equals(skill.getOwnerId())) - .anyMatch(skill -> !skillVersionRepository - .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED) - .isEmpty()); - } - - private boolean shouldSkipExistingVersion(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, - Skill skill, - String version) { - Optional existingVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version); - if (existingVersion.isEmpty()) { - return false; - } - - SkillVersion skillVersion = existingVersion.get(); - if (skillVersion.getStatus() != SkillVersionStatus.PUBLISHED) { - log.warn("Skipping built-in skill because the same version is not published [directory={}, skillId={}, version={}, status={}]", - skillPackage.directory(), skill.getId(), version, skillVersion.getStatus()); - return true; - } - - String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries()); - String existingFingerprint = BuiltinSkillFingerprints.fromFiles(skillFileRepository.findByVersionId(skillVersion.getId())); - if (currentFingerprint.equals(existingFingerprint)) { - log.info("Built-in skill version already exists, skipping [directory={}, skillId={}, version={}]", - skillPackage.directory(), skill.getId(), version); - } else { - log.warn("Skipping built-in skill because same published version has different content [directory={}, skillId={}, version={}]", - skillPackage.directory(), skill.getId(), version); - } - return true; - } - - private void publishPackage(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, - String skillSlug, - String version) { - try { - SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( - GLOBAL_NAMESPACE, - skillPackage.entries(), - BUILTIN_PUBLISHER_ID, - SkillVisibility.PUBLIC, - Set.of("SUPER_ADMIN"), - false - ); - log.info("Published built-in skill [directory={}, namespace={}, slug={}, version={}, status={}]", - skillPackage.directory(), GLOBAL_NAMESPACE, result.slug(), - result.version().getVersion(), result.version().getStatus()); - } catch (RuntimeException exception) { - ConcurrentVersionState concurrentVersionState = - findConcurrentPublishedBuiltInVersionState(skillPackage, skillSlug, version); - if (concurrentVersionState == ConcurrentVersionState.MATCHING) { - log.warn("Built-in skill was already published concurrently, skipping [directory={}, namespace={}, slug={}, version={}]", - skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version); - return; - } - if (concurrentVersionState == ConcurrentVersionState.DIFFERENT) { - log.warn("Skipping built-in skill because concurrently published same version has different content [directory={}, namespace={}, slug={}, version={}]", - skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version); - return; - } - throw exception; - } - } - - private ConcurrentVersionState findConcurrentPublishedBuiltInVersionState( - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, - String skillSlug, - String version) { - return namespaceRepository.findBySlug(GLOBAL_NAMESPACE) - .flatMap(namespace -> skillRepository.findByNamespaceIdAndSlugAndOwnerId( - namespace.getId(), - skillSlug, - BUILTIN_PUBLISHER_ID - )) - .flatMap(skill -> skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)) - .filter(skillVersion -> skillVersion.getStatus() == SkillVersionStatus.PUBLISHED) - .map(skillVersion -> { - String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries()); - String existingFingerprint = BuiltinSkillFingerprints.fromFiles( - skillFileRepository.findByVersionId(skillVersion.getId())); - if (currentFingerprint.equals(existingFingerprint)) { - return ConcurrentVersionState.MATCHING; - } - return ConcurrentVersionState.DIFFERENT; - }) - .orElse(ConcurrentVersionState.MISSING); - } - - private enum ConcurrentVersionState { - MISSING, - MATCHING, - DIFFERENT - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java deleted file mode 100644 index 9775691f..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import com.iflytek.skillhub.controller.support.SkillPackageContentTypeResolver; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; -import org.springframework.core.io.Resource; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.core.io.support.ResourcePatternResolver; -import org.springframework.stereotype.Component; - -/** - * Loads directory-form built-in skill packages from classpath resources. - */ -@Component -public class BuiltinSkillPackageLoader { - - static final String BUILTIN_SKILLS_PATTERN = "classpath*:builtin-skills/*/SKILL.md"; - private static final String ROOT_MARKER = "builtin-skills/"; - private static final String SKILL_MD = "SKILL.md"; - - private final ResourcePatternResolver resourcePatternResolver; - - public BuiltinSkillPackageLoader() { - this(new PathMatchingResourcePatternResolver()); - } - - BuiltinSkillPackageLoader(ResourcePatternResolver resourcePatternResolver) { - this.resourcePatternResolver = resourcePatternResolver; - } - - public List loadPackages() throws IOException { - Set directories = discoverPackageDirectories(); - List packages = new ArrayList<>(); - for (String directory : directories) { - List packageEntries = loadPackageEntries(directory); - boolean hasSkillMd = packageEntries.stream().anyMatch(packageEntry -> SKILL_MD.equals(packageEntry.path())); - if (hasSkillMd) { - packages.add(new BuiltinSkillPackage(directory, packageEntries)); - } - } - return packages; - } - - private Set discoverPackageDirectories() throws IOException { - Resource[] skillMdResources = resourcePatternResolver.getResources(BUILTIN_SKILLS_PATTERN); - Set directories = new TreeSet<>(); - for (Resource resource : skillMdResources) { - String relativePath = relativeBuiltinPath(resource); - if (relativePath == null || relativePath.isBlank()) { - continue; - } - int separatorIndex = relativePath.indexOf('/'); - if (separatorIndex > 0 && SKILL_MD.equals(relativePath.substring(separatorIndex + 1))) { - directories.add(relativePath.substring(0, separatorIndex)); - } - } - return directories; - } - - private List loadPackageEntries(String directory) throws IOException { - Resource[] resources = resourcePatternResolver.getResources("classpath*:builtin-skills/" + directory + "/**"); - List entries = new ArrayList<>(); - for (Resource resource : resources) { - String relativePath = relativeBuiltinPath(resource); - if (relativePath == null || relativePath.isBlank() || relativePath.endsWith("/")) { - continue; - } - String directoryPrefix = directory + "/"; - if (!relativePath.startsWith(directoryPrefix) || directoryPrefix.length() == relativePath.length()) { - continue; - } - - String packagePath = SkillPackagePolicy.normalizeEntryPath(relativePath.substring(directoryPrefix.length())); - try (InputStream inputStream = resource.getInputStream()) { - byte[] content = inputStream.readAllBytes(); - entries.add(new PackageEntry( - packagePath, - content, - content.length, - SkillPackageContentTypeResolver.determineContentType(packagePath) - )); - } - } - return entries.stream() - .sorted(Comparator.comparing(PackageEntry::path)) - .toList(); - } - - private String relativeBuiltinPath(Resource resource) throws IOException { - String url = resource.getURL().toExternalForm(); - int markerIndex = url.lastIndexOf(ROOT_MARKER); - if (markerIndex < 0) { - return null; - } - return url.substring(markerIndex + ROOT_MARKER.length()); - } - - public record BuiltinSkillPackage(String directory, List entries) { - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java deleted file mode 100644 index 298a1af5..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -/** - * Configuration for publishing bundled example skills at application startup. - */ -@Component -@ConfigurationProperties(prefix = "skillhub.builtin-skills") -public class BuiltinSkillProperties { - private boolean enabled = true; - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 481cda72..0a9fc793 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -85,7 +85,7 @@ public class MultipartPackageExtractor { normalizedPath, content, content.length, - SkillPackageContentTypeResolver.determineContentType(normalizedPath) + determineContentType(normalizedPath) )); } } @@ -113,4 +113,12 @@ public class MultipartPackageExtractor { return path; } + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index 489928bd..e2aa41c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -74,7 +74,7 @@ public class SkillPackageArchiveExtractor { normalizedPath, content, content.length, - SkillPackageContentTypeResolver.determineContentType(normalizedPath) + determineContentType(normalizedPath) )); zis.closeEntry(); } @@ -189,4 +189,28 @@ public class SkillPackageArchiveExtractor { return outputStream.toByteArray(); } + private String determineContentType(String filename) { + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java deleted file mode 100644 index 25224e96..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.iflytek.skillhub.controller.support; - -/** - * Resolves package entry content types from filenames for upload and built-in package ingestion. - */ -public final class SkillPackageContentTypeResolver { - - private SkillPackageContentTypeResolver() { - } - - public static String determineContentType(String filename) { - String lower = filename.toLowerCase(); - if (lower.endsWith(".py")) return "text/x-python"; - if (lower.endsWith(".json")) return "application/json"; - if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; - if (lower.endsWith(".txt")) return "text/plain"; - if (lower.endsWith(".md")) return "text/markdown"; - if (lower.endsWith(".html")) return "text/html"; - if (lower.endsWith(".css")) return "text/css"; - if (lower.endsWith(".csv")) return "text/csv"; - if (lower.endsWith(".xml")) return "application/xml"; - if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; - if (lower.endsWith(".ts")) return "text/typescript"; - if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".svg")) return "image/svg+xml"; - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".ico")) return "image/x-icon"; - if (lower.endsWith(".pdf")) return "application/pdf"; - if (lower.endsWith(".toml")) return "application/toml"; - return "application/octet-stream"; - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index e9c993f6..2beaec70 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -66,7 +66,7 @@ public class ZipPackageExtractor { normalizedPath, content, content.length, - SkillPackageContentTypeResolver.determineContentType(normalizedPath) + determineContentType(normalizedPath) )); zis.closeEntry(); } @@ -119,4 +119,28 @@ public class ZipPackageExtractor { } } + private String determineContentType(String filename) { + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 7d9a1cbc..a592b035 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -93,8 +93,6 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: - builtin-skills: - enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} diff --git a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md deleted file mode 100644 index 4cff8bc4..00000000 --- a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# SkillHub Hello - -This built-in example skill is published to `@global` when SkillHub starts. - -Use it to verify that skill discovery and CLI installation are working in a new deployment. diff --git a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md deleted file mode 100644 index 8a313b0c..00000000 --- a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: skillhub-hello -description: A built-in example skill that verifies SkillHub discovery and installation. -version: 1.0.0 ---- -# SkillHub Hello - -This skill is bundled with SkillHub as a minimal example for validating discovery and installation. diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java deleted file mode 100644 index 299a4ddb..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ /dev/null @@ -1,383 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import com.iflytek.skillhub.domain.namespace.Namespace; -import com.iflytek.skillhub.domain.namespace.NamespaceMember; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.skill.Skill; -import com.iflytek.skillhub.domain.skill.SkillFile; -import com.iflytek.skillhub.domain.skill.SkillFileRepository; -import com.iflytek.skillhub.domain.skill.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; -import com.iflytek.skillhub.domain.skill.SkillVersionStatus; -import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; -import com.iflytek.skillhub.domain.skill.validation.ValidationResult; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.HexFormat; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.DefaultApplicationArguments; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.SimpleTransactionStatus; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BuiltinSkillInitializerTest { - - private static final String PUBLISHER_ID = BuiltinSkillInitializer.BUILTIN_PUBLISHER_ID; - - @Mock private BuiltinSkillPackageLoader packageLoader; - @Mock private SkillPackageValidator packageValidator; - @Mock private SkillPublishService skillPublishService; - @Mock private NamespaceRepository namespaceRepository; - @Mock private NamespaceMemberRepository namespaceMemberRepository; - @Mock private UserAccountRepository userAccountRepository; - @Mock private SkillRepository skillRepository; - @Mock private SkillVersionRepository skillVersionRepository; - @Mock private SkillFileRepository skillFileRepository; - @Mock private PlatformTransactionManager transactionManager; - - private BuiltinSkillProperties properties; - private BuiltinSkillInitializer initializer; - private Namespace globalNamespace; - - @BeforeEach - void setUp() { - properties = new BuiltinSkillProperties(); - globalNamespace = new Namespace("global", "Global", "system"); - ReflectionTestUtils.setField(globalNamespace, "id", 1L); - lenient().when(transactionManager.getTransaction(any())).thenAnswer(ignored -> new SimpleTransactionStatus()); - lenient().when(packageValidator.validate(any())).thenReturn(ValidationResult.pass()); - - initializer = new BuiltinSkillInitializer( - properties, - packageLoader, - new SkillMetadataParser(), - packageValidator, - skillPublishService, - namespaceRepository, - namespaceMemberRepository, - userAccountRepository, - skillRepository, - skillVersionRepository, - skillFileRepository, - transactionManager - ); - } - - @Test - void disabledInitializerDoesNotLoadPackages() throws Exception { - properties.setEnabled(false); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(packageLoader, never()).loadPackages(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void firstStartupCreatesPublisherMembershipAndPublishesPackage() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.empty()); - when(skillPublishService.publishFromEntries( - eq("global"), - eq(skillPackage.entries()), - eq(PUBLISHER_ID), - eq(SkillVisibility.PUBLIC), - eq(Set.of("SUPER_ADMIN")), - eq(false) - )).thenReturn(publishResult("1.0.0")); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(userAccountRepository).save(any(UserAccount.class)); - verify(namespaceMemberRepository).save(any(NamespaceMember.class)); - verify(skillPublishService).publishFromEntries( - "global", - skillPackage.entries(), - PUBLISHER_ID, - SkillVisibility.PUBLIC, - Set.of("SUPER_ADMIN"), - false - ); - } - - @Test - void existingPublisherAndMembershipAreReusedIdempotently() throws Exception { - UserAccount existingPublisher = new UserAccount(PUBLISHER_ID, "Old name", "old@example.invalid", null); - NamespaceMember existingMember = new NamespaceMember(1L, PUBLISHER_ID, NamespaceRole.MEMBER); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace)); - when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.of(existingPublisher)); - when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID)) - .thenReturn(Optional.of(existingMember)); - when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(packageLoader.loadPackages()).thenReturn(List.of()); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(userAccountRepository).save(existingPublisher); - verify(namespaceMemberRepository).save(existingMember); - org.assertj.core.api.Assertions.assertThat(existingMember.getRole()).isEqualTo(NamespaceRole.OWNER); - } - - @Test - void validationWarningsSkipPublishWithoutConfirmingWarnings() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(packageValidator.validate(skillPackage.entries())) - .thenReturn(new ValidationResult(true, List.of(), List.of("warning"))); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void samePublishedVersionWithSameFingerprintSkipsPublish() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries())); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void samePublishedVersionWithDifferentFingerprintSkipsPublish() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of( - new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key") - )); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void sameNonPublishedVersionSkipsPublish() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.UPLOADED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillFileRepository, never()).findByVersionId(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void newerBuiltInVersionPublishesWhenOlderVersionExists() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.1", "Hello"); - Skill skill = builtInSkill(11L); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.1")).thenReturn(Optional.empty()); - when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) - .thenReturn(publishResult("1.0.1")); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService).publishFromEntries( - "global", - skillPackage.entries(), - PUBLISHER_ID, - SkillVisibility.PUBLIC, - Set.of("SUPER_ADMIN"), - false - ); - } - - @Test - void userOwnedPublishedSlugSkipsBuiltInPublish() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill otherSkill = new Skill(1L, "skillhub-hello", "user-1", SkillVisibility.PUBLIC); - ReflectionTestUtils.setField(otherSkill, "id", 33L); - SkillVersion otherPublishedVersion = version(33L, 44L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); - when(skillVersionRepository.findBySkillIdAndStatus(33L, SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of(otherPublishedVersion)); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillRepository, never()).findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void publishFailureIsContainedAndDoesNotAbortStartup() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.empty()); - when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) - .thenThrow(new IllegalStateException("storage unavailable")); - - assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) - .doesNotThrowAnyException(); - } - - @Test - void concurrentPublishedSameFingerprintSkipsAfterPublishFailure() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.empty(), Optional.of(skill)); - when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) - .thenThrow(new IllegalStateException("version exists")); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries())); - - assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) - .doesNotThrowAnyException(); - } - - @Test - void concurrentPublishedDifferentFingerprintSkipsAfterPublishFailure() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.empty(), Optional.of(skill)); - when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) - .thenThrow(new IllegalStateException("version exists")); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of( - new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key") - )); - - assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) - .doesNotThrowAnyException(); - } - - private void setupPublisher() { - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace)); - when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.empty()); - when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID)).thenReturn(Optional.empty()); - when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); - } - - private BuiltinSkillPackageLoader.BuiltinSkillPackage packageWithVersion(String version, String body) { - byte[] skillMd = (""" - --- - name: skillhub-hello - description: SkillHub hello - version: %s - --- - # SkillHub Hello - %s - """.formatted(version, body)).getBytes(StandardCharsets.UTF_8); - byte[] readme = "# SkillHub Hello\n".getBytes(StandardCharsets.UTF_8); - return new BuiltinSkillPackageLoader.BuiltinSkillPackage("skillhub-hello", List.of( - new PackageEntry("README.md", readme, readme.length, "text/markdown"), - new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown") - )); - } - - private Skill builtInSkill(Long id) { - Skill skill = new Skill(1L, "skillhub-hello", PUBLISHER_ID, SkillVisibility.PUBLIC); - ReflectionTestUtils.setField(skill, "id", id); - return skill; - } - - private SkillVersion version(Long skillId, Long versionId, String version, SkillVersionStatus status) { - SkillVersion skillVersion = new SkillVersion(skillId, version, PUBLISHER_ID); - skillVersion.setStatus(status); - ReflectionTestUtils.setField(skillVersion, "id", versionId); - return skillVersion; - } - - private SkillPublishService.PublishResult publishResult(String version) { - SkillVersion skillVersion = version(11L, 22L, version, SkillVersionStatus.PUBLISHED); - return new SkillPublishService.PublishResult(11L, "skillhub-hello", skillVersion); - } - - private List filesFor(Long versionId, List entries) { - return entries.stream() - .map(entry -> new SkillFile( - versionId, - entry.path(), - entry.size(), - entry.contentType(), - sha256(entry.content()), - "skills/11/" + versionId + "/" + entry.path() - )) - .toList(); - } - - private String sha256(byte[] content) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(content)); - } catch (Exception exception) { - throw new IllegalStateException(exception); - } - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java deleted file mode 100644 index 143da879..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; -import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; -import java.net.URLClassLoader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.jar.JarEntry; -import java.util.jar.JarOutputStream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; - -import static org.assertj.core.api.Assertions.assertThat; - -class BuiltinSkillPackageLoaderTest { - - @TempDir - Path tempDir; - - @Test - void loadsSkillhubHelloFromClasspath() throws Exception { - BuiltinSkillPackageLoader loader = - new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver()); - - var packages = loader.loadPackages(); - - var skillhubHello = packages.stream() - .filter(skillPackage -> skillPackage.directory().equals("skillhub-hello")) - .findFirst() - .orElseThrow(); - assertThat(skillhubHello.entries()) - .extracting(entry -> entry.path()) - .containsExactly("README.md", "SKILL.md"); - assertThat(skillhubHello.entries()) - .allSatisfy(entry -> assertThat(entry.contentType()).isEqualTo("text/markdown")); - } - - @Test - void productionSkillhubHelloResourcePassesPackageValidation() throws Exception { - BuiltinSkillPackageLoader loader = - new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver()); - SkillPackageValidator validator = new SkillPackageValidator(new SkillMetadataParser()); - - var skillhubHello = loader.loadPackages().stream() - .filter(skillPackage -> skillPackage.directory().equals("skillhub-hello")) - .findFirst() - .orElseThrow(); - - var result = validator.validate(skillhubHello.entries()); - assertThat(result.passed()).isTrue(); - assertThat(result.warnings()).isEmpty(); - } - - @Test - void loadsBuiltInPackageFromJarClasspath() throws Exception { - Path jarPath = tempDir.resolve("builtin-skills.jar"); - try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(jarPath))) { - writeJarDirectory(jarOutputStream, "builtin-skills/"); - writeJarDirectory(jarOutputStream, "builtin-skills/skillhub-hello/"); - writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/SKILL.md", """ - --- - name: skillhub-hello - description: SkillHub hello - version: 1.0.0 - --- - # SkillHub Hello - """); - writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/README.md", "# SkillHub Hello\n"); - } - - try (URLClassLoader classLoader = new URLClassLoader( - new java.net.URL[]{jarPath.toUri().toURL()}, - null - )) { - BuiltinSkillPackageLoader loader = - new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver(classLoader)); - - var packages = loader.loadPackages(); - - assertThat(packages).hasSize(1); - assertThat(packages.getFirst().directory()).isEqualTo("skillhub-hello"); - assertThat(packages.getFirst().entries()) - .extracting(entry -> entry.path()) - .containsExactly("README.md", "SKILL.md"); - } - } - - @Test - void fingerprintsAreStableAcrossEntryOrdering() { - byte[] skillMd = """ - --- - name: demo - description: Demo - version: 1.0.0 - --- - # Demo - """.getBytes(java.nio.charset.StandardCharsets.UTF_8); - byte[] readme = "# Demo\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); - var first = List.of( - new com.iflytek.skillhub.domain.skill.validation.PackageEntry( - "SKILL.md", skillMd, skillMd.length, "text/markdown"), - new com.iflytek.skillhub.domain.skill.validation.PackageEntry( - "README.md", readme, readme.length, "text/markdown") - ); - var second = List.of(first.get(1), first.get(0)); - - assertThat(BuiltinSkillFingerprints.fromEntries(first)) - .isEqualTo(BuiltinSkillFingerprints.fromEntries(second)); - } - - private void writeJarDirectory(JarOutputStream jarOutputStream, String name) throws Exception { - jarOutputStream.putNextEntry(new JarEntry(name)); - jarOutputStream.closeEntry(); - } - - private void writeJarEntry(JarOutputStream jarOutputStream, String name, String content) throws Exception { - jarOutputStream.putNextEntry(new JarEntry(name)); - jarOutputStream.write(content.getBytes(StandardCharsets.UTF_8)); - jarOutputStream.closeEntry(); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java deleted file mode 100644 index 04461428..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.boot.context.properties.source.ConfigurationPropertySources; -import org.springframework.boot.env.YamlPropertySourceLoader; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.StandardEnvironment; -import org.springframework.core.env.SystemEnvironmentPropertySource; -import org.springframework.core.io.ClassPathResource; - -import static org.assertj.core.api.Assertions.assertThat; - -class BuiltinSkillPropertiesBindingTest { - - @Test - void defaultConfigEnablesBuiltinSkills() throws Exception { - BuiltinSkillProperties properties = bindProperties(Map.of()); - - assertThat(properties.isEnabled()).isTrue(); - } - - @Test - void environmentVariableCanDisableBuiltinSkills() throws Exception { - BuiltinSkillProperties properties = bindProperties(Map.of("SKILLHUB_BUILTIN_SKILLS_ENABLED", "false")); - - assertThat(properties.isEnabled()).isFalse(); - } - - private BuiltinSkillProperties bindProperties(Map envVars) throws Exception { - ConfigurableEnvironment environment = new StandardEnvironment(); - environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource("test-env", envVars)); - - YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); - for (org.springframework.core.env.PropertySource propertySource : - loader.load("application.yml", new ClassPathResource("application.yml"))) { - environment.getPropertySources().addLast(propertySource); - } - ConfigurationPropertySources.attach(environment); - - return Binder.get(environment) - .bind("skillhub.builtin-skills", BuiltinSkillProperties.class) - .orElseThrow(() -> new IllegalStateException("Failed to bind built-in skill properties")); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java deleted file mode 100644 index 90166104..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.iflytek.skillhub.controller.support; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class SkillPackageContentTypeResolverTest { - - @Test - void determinesKnownContentTypes() { - assertThat(SkillPackageContentTypeResolver.determineContentType("SKILL.md")).isEqualTo("text/markdown"); - assertThat(SkillPackageContentTypeResolver.determineContentType("script.py")).isEqualTo("text/x-python"); - assertThat(SkillPackageContentTypeResolver.determineContentType("config.yaml")).isEqualTo("application/x-yaml"); - assertThat(SkillPackageContentTypeResolver.determineContentType("image.png")).isEqualTo("image/png"); - assertThat(SkillPackageContentTypeResolver.determineContentType("archive.bin")).isEqualTo("application/octet-stream"); - } -} From 04caf21e76b2564bd60bfb3a6b325b9b5cb7938a Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:28:12 +0800 Subject: [PATCH 07/78] fix(audit): resolve 8-hour timezone offset in audit log timestamps (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Audit log timestamps displayed 8 hours later than actual time when JVM default timezone != UTC. Root cause: `audit_log.created_at` was `TIMESTAMP without time zone`, and `rs.getTimestamp()` interprets bare values using JVM timezone. ## Solution ### Backend - **V42 migration**: Upgrade `audit_log.created_at` from `TIMESTAMP` to `TIMESTAMPTZ`, anchor historical data as UTC via `USING created_at AT TIME ZONE 'UTC'` (same pattern as V18/V19/V23/V25/V36) - **Read path**: `AdminAuditLogAppService.readInstant()` uses `rs.getObject(col, OffsetDateTime.class).toInstant()`, result independent of JVM timezone - **Write path (filter params)**: `startTime`/`endTime` binding changed from `Timestamp.from()` to `OffsetDateTime.ofInstant(instant, ZoneOffset.UTC)` via `toUtcOffsetDateTime()` helper, symmetric with read path ### Migration Safety - `SET LOCAL lock_timeout = '30s'` (transaction-scoped, won't leak to pool) - `DO $$ ... IF data_type = 'timestamp without time zone' THEN ... ELSE ... END $$` idempotent guard with dual-branch `RAISE NOTICE` - Safe retry: re-running won't double-apply `AT TIME ZONE 'UTC'` ### Test Coverage (10 tests, 477 total suite) - `rowMapper_readsCreatedAtAsInstant` — UTC offset regression - `rowMapper_normalisesNonUtcOffsetToInstant` — Non-UTC offset (+08:00) - `rowMapper_returnsNullTimestampWhenColumnIsNull` — Null path - `rowMapper_isIndependentOfJvmDefaultTimezone` — JVM TZ=Asia/Shanghai drift prevention with `verify(rs, never()).getTimestamp()` - `@ParameterizedTest buildWhereClause_bindsTimeRangeAsOffsetDateTime` — 3 cases (both/startOnly/endOnly) for filter param binding - `@BeforeEach setUp()` — Mock isolation to prevent cross-test stub accumulation ## Quality Gates - [x] `make test-backend-app` passes (477 tests, 0 failures) - [x] No Controller changes, `make generate-api` not needed - [x] No frontend changes, typecheck/lint/e2e not needed ## Deployment V42 must run before new code (guaranteed by Spring Boot startup sequence → Flyway executes before app accepts traffic). Rolling deployment: - New pod + migrated column: correct - Old pod + migrated column: old code reads TIMESTAMPTZ correctly (pgjdbc returns absolute instant) ## Related Docs - `docs/15-backend-time-governance-plan.md` §3.1: V42 progress registered - `docs/16-backend-time-inventory.md` §3.1: V42 listed - Same migration pattern: V18/V19/V23/V25/V36 --- docs/15-backend-time-governance-plan.md | 4 + docs/16-backend-time-inventory.md | 2 + .../service/AdminAuditLogAppService.java | 24 +++- .../V42__audit_log_created_at_timestamptz.sql | 39 +++++ .../service/AdminAuditLogAppServiceTest.java | 135 +++++++++++++++++- 5 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V42__audit_log_created_at_timestamptz.sql diff --git a/docs/15-backend-time-governance-plan.md b/docs/15-backend-time-governance-plan.md index 17952e50..6db6eac6 100644 --- a/docs/15-backend-time-governance-plan.md +++ b/docs/15-backend-time-governance-plan.md @@ -57,6 +57,10 @@ - 数据库列统一为 `TIMESTAMPTZ` - 读写都按 UTC 绝对时间处理 +进度登记: + +- `audit_log.created_at` 已通过 V42 迁移到 `TIMESTAMPTZ`,详见 `docs/16-backend-time-inventory.md` §3.1 + ### 3.2 业务输入时间 适用场景: diff --git a/docs/16-backend-time-inventory.md b/docs/16-backend-time-inventory.md index 565b444f..d0c8bfcb 100644 --- a/docs/16-backend-time-inventory.md +++ b/docs/16-backend-time-inventory.md @@ -131,6 +131,8 @@ - `review_task.submitted_at / reviewed_at` - `promotion_request.submitted_at / reviewed_at` - `idempotency_record.created_at / expires_at` +- `V42__audit_log_created_at_timestamptz.sql` + - `audit_log.created_at` ### 3.2 当前状态 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java index 0de4d935..4ca927b7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java @@ -8,8 +8,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import java.sql.Timestamp; +import java.sql.ResultSet; +import java.sql.SQLException; import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.Collection; import java.util.List; @@ -109,7 +112,7 @@ public class AdminAuditLogAppService { rs.getString("request_id"), rs.getString("target_type"), toResourceId(rs.getObject("target_id")), - toInstant(rs.getTimestamp("created_at"))) + readInstant(rs, "created_at")) ); return new PageResponse<>(items, total == null ? 0 : total, page, size); @@ -151,15 +154,21 @@ public class AdminAuditLogAppService { } if (startTime != null) { clause.append(" AND al.created_at >= :startTime"); - parameters.addValue("startTime", Timestamp.from(startTime)); + parameters.addValue("startTime", toUtcOffsetDateTime(startTime)); } if (endTime != null) { clause.append(" AND al.created_at <= :endTime"); - parameters.addValue("endTime", Timestamp.from(endTime)); + parameters.addValue("endTime", toUtcOffsetDateTime(endTime)); } return clause.toString(); } + // Bind via OffsetDateTime so pgjdbc sends a TIMESTAMPTZ literal anchored to UTC, + // bypassing JVM-default-timezone interpretation that caused the 8h-offset bug. + private static OffsetDateTime toUtcOffsetDateTime(Instant instant) { + return OffsetDateTime.ofInstant(instant, ZoneOffset.UTC); + } + private String renderDetails(String detailJson, String targetType, Object targetId) { if (StringUtils.hasText(detailJson)) { return detailJson; @@ -170,8 +179,11 @@ public class AdminAuditLogAppService { return targetType + ":" + targetId; } - private Instant toInstant(Timestamp timestamp) { - return timestamp == null ? null : timestamp.toInstant(); + // Read via getObject(OffsetDateTime.class) to bypass JVM-TZ interpretation + // that caused the 8h-offset bug (getTimestamp() applies JVM default TZ). + private static Instant readInstant(ResultSet rs, String column) throws SQLException { + OffsetDateTime odt = rs.getObject(column, OffsetDateTime.class); + return odt == null ? null : odt.toInstant(); } private String toResourceId(Object targetId) { diff --git a/server/skillhub-app/src/main/resources/db/migration/V42__audit_log_created_at_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V42__audit_log_created_at_timestamptz.sql new file mode 100644 index 00000000..5939f144 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V42__audit_log_created_at_timestamptz.sql @@ -0,0 +1,39 @@ +-- Fix audit_log.created_at timezone issue +-- Background: TIMESTAMP (without timezone) causes 8-hour offset when JVM timezone != UTC +-- Solution: Upgrade to TIMESTAMPTZ and anchor existing data as UTC +-- Related: docs/15-backend-time-governance-plan.md section 3.1 +-- +-- Operational notes: +-- * ALTER COLUMN ... TYPE rewrites the entire audit_log table and rebuilds +-- idx_audit_log_created_at, idx_audit_log_actor_time, idx_audit_log_action_time +-- under ACCESS EXCLUSIVE lock. Run during a low-traffic window. +-- * Before applying in production, check table size: +-- SELECT pg_size_pretty(pg_total_relation_size('audit_log')); +-- Tables in the multi-GB range may need a maintenance window. +-- * SET LOCAL lock_timeout below makes a contended ALTER fail fast (rather than +-- queueing behind long-running readers); operators may re-run the migration +-- after clearing contention. The DO block guards against re-running on a +-- column that has already been migrated, so retries are safe. + +SET LOCAL lock_timeout = '30s'; + +DO $$ +DECLARE + current_type text; +BEGIN + SELECT data_type + INTO current_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'audit_log' + AND column_name = 'created_at'; + + IF current_type = 'timestamp without time zone' THEN + ALTER TABLE audit_log + ALTER COLUMN created_at TYPE TIMESTAMPTZ + USING created_at AT TIME ZONE 'UTC'; + RAISE NOTICE 'V42: audit_log.created_at -> TIMESTAMPTZ (UTC anchored)'; + ELSE + RAISE NOTICE 'V42: audit_log.created_at already % (skipped)', current_type; + END IF; +END $$; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java index 2b2b255e..3687f414 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java @@ -2,13 +2,21 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.dto.AuditLogItemResponse; import com.iflytek.skillhub.dto.PageResponse; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import java.sql.ResultSet; import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.List; +import java.util.TimeZone; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; @@ -16,8 +24,14 @@ import static org.mockito.Mockito.*; class AdminAuditLogAppServiceTest { - private final NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class); - private final AdminAuditLogAppService service = new AdminAuditLogAppService(jdbcTemplate); + private NamedParameterJdbcTemplate jdbcTemplate; + private AdminAuditLogAppService service; + + @BeforeEach + void setUp() { + jdbcTemplate = mock(NamedParameterJdbcTemplate.class); + service = new AdminAuditLogAppService(jdbcTemplate); + } @Test void listAuditLogs_returnsJdbcBackedPage() { @@ -62,4 +76,121 @@ class AdminAuditLogAppServiceTest { any(MapSqlParameterSource.class), any(RowMapper.class)); } + + /** + * Regression for the 8-hour offset bug: row mapper must read created_at via + * getObject(OffsetDateTime.class) so the returned Instant is independent of + * the JVM default timezone. + */ + @Test + void rowMapper_readsCreatedAtAsInstant() throws Exception { + RowMapper rowMapper = captureRowMapper(); + ResultSet rs = stubRowWithCreatedAt( + OffsetDateTime.of(2026, 5, 29, 8, 53, 0, 0, ZoneOffset.UTC)); + + AuditLogItemResponse item = rowMapper.mapRow(rs, 0); + + assertThat(item).isNotNull(); + assertThat(item.timestamp()).isEqualTo(Instant.parse("2026-05-29T08:53:00Z")); + verify(rs, never()).getTimestamp(anyString()); + } + + @Test + void rowMapper_normalisesNonUtcOffsetToInstant() throws Exception { + RowMapper rowMapper = captureRowMapper(); + ResultSet rs = stubRowWithCreatedAt( + OffsetDateTime.of(2026, 5, 29, 16, 53, 0, 0, ZoneOffset.ofHours(8))); + + AuditLogItemResponse item = rowMapper.mapRow(rs, 0); + + assertThat(item.timestamp()).isEqualTo(Instant.parse("2026-05-29T08:53:00Z")); + } + + @Test + void rowMapper_returnsNullTimestampWhenColumnIsNull() throws Exception { + RowMapper rowMapper = captureRowMapper(); + ResultSet rs = stubRowWithCreatedAt(null); + + AuditLogItemResponse item = rowMapper.mapRow(rs, 0); + + assertThat(item.timestamp()).isNull(); + } + + @ParameterizedTest + @CsvSource(nullValues = "NULL", value = { + "2026-03-13T00:00:00Z, 2026-03-14T00:00:00Z", + "2026-03-13T00:00:00Z, NULL", + "NULL, 2026-03-14T00:00:00Z" + }) + void buildWhereClause_bindsTimeRangeAsOffsetDateTime(String startStr, String endStr) { + when(jdbcTemplate.queryForObject(contains("COUNT(*)"), any(MapSqlParameterSource.class), eq(Long.class))) + .thenReturn(0L); + when(jdbcTemplate.query(contains("FROM audit_log"), any(MapSqlParameterSource.class), any(RowMapper.class))) + .thenReturn(List.of()); + Instant startTime = startStr == null ? null : Instant.parse(startStr); + Instant endTime = endStr == null ? null : Instant.parse(endStr); + + service.listAuditLogs(0, 20, null, null, null, null, null, null, startTime, endTime); + + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(MapSqlParameterSource.class); + verify(jdbcTemplate).query(contains("FROM audit_log"), paramsCaptor.capture(), any(RowMapper.class)); + MapSqlParameterSource params = paramsCaptor.getValue(); + if (startTime != null) { + assertThat(params.getValue("startTime")) + .isEqualTo(OffsetDateTime.ofInstant(startTime, ZoneOffset.UTC)); + } else { + assertThat(params.hasValue("startTime")).isFalse(); + } + if (endTime != null) { + assertThat(params.getValue("endTime")) + .isEqualTo(OffsetDateTime.ofInstant(endTime, ZoneOffset.UTC)); + } else { + assertThat(params.hasValue("endTime")).isFalse(); + } + } + + @Test + void rowMapper_isIndependentOfJvmDefaultTimezone() throws Exception { + TimeZone original = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + RowMapper rowMapper = captureRowMapper(); + ResultSet rs = stubRowWithCreatedAt( + OffsetDateTime.of(2026, 5, 29, 8, 53, 0, 0, ZoneOffset.UTC)); + + AuditLogItemResponse item = rowMapper.mapRow(rs, 0); + + assertThat(item).isNotNull(); + assertThat(item.timestamp()).isEqualTo(Instant.parse("2026-05-29T08:53:00Z")); + verify(rs, never()).getTimestamp(anyString()); + } finally { + TimeZone.setDefault(original); + } + } + + @SuppressWarnings("unchecked") + private RowMapper captureRowMapper() { + when(jdbcTemplate.queryForObject(contains("COUNT(*)"), any(MapSqlParameterSource.class), eq(Long.class))) + .thenReturn(0L); + ArgumentCaptor> captor = ArgumentCaptor.forClass(RowMapper.class); + when(jdbcTemplate.query(contains("FROM audit_log"), any(MapSqlParameterSource.class), captor.capture())) + .thenReturn(List.of()); + service.listAuditLogs(0, 20, null, null, null, null, null, null, null, null); + return captor.getValue(); + } + + private static ResultSet stubRowWithCreatedAt(OffsetDateTime createdAt) throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(1L); + when(rs.getString("action")).thenReturn("PROMOTION_SUBMIT"); + when(rs.getString("actor_user_id")).thenReturn("user-1"); + when(rs.getString("display_name")).thenReturn("alice"); + when(rs.getString("detail_json")).thenReturn("{}"); + when(rs.getString("target_type")).thenReturn("PROMOTION"); + when(rs.getObject("target_id")).thenReturn(42L); + when(rs.getString("client_ip")).thenReturn("127.0.0.1"); + when(rs.getString("request_id")).thenReturn("req-1"); + when(rs.getObject("created_at", OffsetDateTime.class)).thenReturn(createdAt); + return rs; + } } From 2bb7dedaf4057bd0062ff8e3f04a35fee05c174e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:20:34 +0800 Subject: [PATCH 08/78] feat(cli,domain): support non-global namespace skill download (#497) * feat(cli,domain): support non-global namespace skill download Parse namespace from skill name using -- separator (e.g., astroclaw--api-gateway) so users don't need --namespace flag. Allow anonymous download for any PUBLIC skill regardless of namespace. CLI changes: - Add cli/src/shared/skill-name-parser.ts utility - Update install and remove commands to parse skill name argument - 10 unit tests covering edge cases Domain changes: - SkillDownloadService.isAnonymousDownloadAllowed: drop namespace type check, only require PUBLIC visibility - Update test to expect success for team-namespace public skill Synced from SAAS commit 26c67e31b1221249cf9b73321d1b726d8ba6e6df * fix(cli): use bun:test instead of vitest in skill-name-parser test --- cli/src/commands/install.ts | 8 +- cli/src/commands/remove.ts | 8 +- cli/src/shared/skill-name-parser.ts | 27 ++++++ .../unit/shared/skill-name-parser.test.ts | 90 +++++++++++++++++++ .../skill/service/SkillDownloadService.java | 8 +- .../service/SkillDownloadServiceTest.java | 26 ++++-- 6 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 cli/src/shared/skill-name-parser.ts create mode 100644 cli/test/unit/shared/skill-name-parser.test.ts diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index e9937a7b..0feed791 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -5,6 +5,7 @@ import { installSkill } from '../services/install-service' import { resolveInstallTargets } from '../agents/resolver' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' +import { parseSkillName } from '../shared/skill-name-parser' export interface InstallCommandOptions { namespace?: string | undefined @@ -74,7 +75,7 @@ async function defaultPromptScope(): Promise<'user' | 'project'> { } export async function installCommand( - slug: string, + skillNameArg: string, options: InstallCommandOptions, deps: InstallCommandDeps = {} ): Promise { @@ -92,7 +93,10 @@ export async function installCommand( const credentialsStore = new CredentialsStore() const registry = resolveRegistry(options, process.env, await configStore.read()) const token = resolveToken(options, process.env, await credentialsStore.getToken(registry)) - const namespace = options.namespace ?? 'global' + + const parsed = parseSkillName(skillNameArg) + const namespace = options.namespace ?? parsed.namespace + const slug = parsed.slug const resolveTargets = deps.resolveInstallTargets ?? resolveInstallTargets const targets = await resolveTargets({ diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index 67f47f1d..4e8543b7 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -5,6 +5,7 @@ import { resolveRegistry, resolveToken } from '../services/registry-service' import { removeLocalSkill } from '../services/remove-service' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' +import { parseSkillName } from '../shared/skill-name-parser' export interface RemoveCommandOptions { agent?: string[] | undefined @@ -17,7 +18,7 @@ export interface RemoveCommandOptions { json?: boolean | undefined } -export async function removeCommand(slug: string, options: RemoveCommandOptions): Promise { +export async function removeCommand(skillNameArg: string, options: RemoveCommandOptions): Promise { if (options.all && options.agent?.length) { throw new CliError('--all cannot be used with --agent', EXIT.usage) } @@ -29,9 +30,12 @@ export async function removeCommand(slug: string, options: RemoveCommandOptions) const credentialsStore = new CredentialsStore() const registry = resolveRegistry(options, process.env, await configStore.read()) + const parsed = parseSkillName(skillNameArg) + const namespace = options.namespace ?? parsed.namespace + const slug = parsed.slug + if (options.remote) { const token = resolveToken(options, process.env, await credentialsStore.getToken(registry)) - const namespace = options.namespace ?? 'global' if (!options.hard && process.stdout.isTTY) { const prompts = await import('prompts') diff --git a/cli/src/shared/skill-name-parser.ts b/cli/src/shared/skill-name-parser.ts new file mode 100644 index 00000000..05e0662b --- /dev/null +++ b/cli/src/shared/skill-name-parser.ts @@ -0,0 +1,27 @@ +export interface ParsedSkillName { + namespace: string + slug: string +} + +export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName { + const separatorIndex = skillName.indexOf('--') + + if (separatorIndex <= 0) { + return { + namespace: defaultNamespace, + slug: separatorIndex === 0 ? skillName.slice(2) : skillName + } + } + + if (separatorIndex === skillName.length - 2) { + return { + namespace: defaultNamespace, + slug: skillName.slice(0, -2) + } + } + + return { + namespace: skillName.slice(0, separatorIndex), + slug: skillName.slice(separatorIndex + 2) + } +} diff --git a/cli/test/unit/shared/skill-name-parser.test.ts b/cli/test/unit/shared/skill-name-parser.test.ts new file mode 100644 index 00000000..b86771ce --- /dev/null +++ b/cli/test/unit/shared/skill-name-parser.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from 'bun:test' +import { parseSkillName } from '../../../src/shared/skill-name-parser' + +describe('parseSkillName', () => { + describe('with namespace--slug format', () => { + test('should parse namespace and slug separated by double dash', () => { + const result = parseSkillName('astroclaw--api-gateway') + expect(result).toEqual({ + namespace: 'astroclaw', + slug: 'api-gateway' + }) + }) + + test('should handle namespace and slug with single dashes', () => { + const result = parseSkillName('my-org--my-skill-name') + expect(result).toEqual({ + namespace: 'my-org', + slug: 'my-skill-name' + }) + }) + + test('should handle multiple double dashes by using first as separator', () => { + const result = parseSkillName('namespace--slug--with--dashes') + expect(result).toEqual({ + namespace: 'namespace', + slug: 'slug--with--dashes' + }) + }) + }) + + describe('with slug only format', () => { + test('should use default namespace when no separator present', () => { + const result = parseSkillName('api-gateway') + expect(result).toEqual({ + namespace: 'global', + slug: 'api-gateway' + }) + }) + + test('should use custom default namespace when provided', () => { + const result = parseSkillName('api-gateway', 'myorg') + expect(result).toEqual({ + namespace: 'myorg', + slug: 'api-gateway' + }) + }) + + test('should handle slug with single dashes', () => { + const result = parseSkillName('my-skill-name') + expect(result).toEqual({ + namespace: 'global', + slug: 'my-skill-name' + }) + }) + }) + + describe('edge cases', () => { + test('should handle separator at start', () => { + const result = parseSkillName('--api-gateway') + expect(result).toEqual({ + namespace: 'global', + slug: 'api-gateway' + }) + }) + + test('should handle separator at end', () => { + const result = parseSkillName('astroclaw--') + expect(result).toEqual({ + namespace: 'global', + slug: 'astroclaw' + }) + }) + + test('should handle empty string', () => { + const result = parseSkillName('') + expect(result).toEqual({ + namespace: 'global', + slug: '' + }) + }) + + test('should handle just separator', () => { + const result = parseSkillName('--') + expect(result).toEqual({ + namespace: 'global', + slug: '' + }) + }) + }) +}) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 3bb194ff..6a62b749 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,7 +4,6 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -268,7 +267,7 @@ public class SkillDownloadService { Skill skill, String currentUserId, Map userNsRoles) { - if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { @@ -276,9 +275,8 @@ public class SkillDownloadService { } } - private boolean isAnonymousDownloadAllowed(Namespace namespace, Skill skill) { - return namespace.getType() == NamespaceType.GLOBAL - && skill.getVisibility() == SkillVisibility.PUBLIC; + private boolean isAnonymousDownloadAllowed(Skill skill) { + return skill.getVisibility() == SkillVisibility.PUBLIC; } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index ba24003b..4e8251d1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -318,26 +318,38 @@ class SkillDownloadServiceTest { } @Test - void testDownloadVersion_RejectsAnonymousForTeamNamespacePublicSkill() throws Exception { + void testDownloadVersion_AllowsAnonymousForTeamNamespacePublicSkill() throws Exception { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); setId(namespace, 2L); namespace.setType(NamespaceType.TEAM); Skill skill = new Skill(2L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setDisplayName("Demo Skill"); skill.setStatus(SkillStatus.ACTIVE); skill.setLatestVersionId(10L); + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(version)); + when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(10L)).thenReturn(List.of( + new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"))); + when(objectStorageService.exists("skills/1/10/SKILL.md")).thenReturn(true); + when(objectStorageService.getObject("skills/1/10/SKILL.md")).thenReturn(new ByteArrayInputStream("test".getBytes())); - assertThrows(DomainForbiddenException.class, () -> - service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of())); + SkillDownloadService.DownloadResult result = service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of()); - verify(visibilityChecker, never()).canAccess(any(), any(), anyMap()); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + assertNotNull(result); + assertEquals("Demo Skill-1.0.0.zip", result.filename()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); + verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } private void setId(Object entity, Long id) throws Exception { From 31b25fb6c56890e1f19034c58fe9d2f5a13397c7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:21:33 +0800 Subject: [PATCH 09/78] fix(domain): clear skill.latest_version_id before deleting skill_version (#495) The PG FK constraint fk_skill_latest_version blocks deleting a SkillVersion whenever Skill.latest_version_id still references it. Two services had the wrong order: - SkillPublishService.deleteReplaceableVersionArtifacts: triggered when re-uploading the same version (UPLOADED -> overwritten). Reproduced by AstronClaw client retrying personal-skills upload. - SkillGovernanceService.deleteVersion: triggered when admin deletes a draft version that happens to be skill.latest_version_id. Fix: clear skill.latest_version_id and flush BEFORE deleting the SkillVersion row, so PG sees no live reference at delete time. Synced from SAAS commit 4626f0c117d9c0544c4dc1115c3aac7468f0d277 --- .../domain/skill/service/SkillGovernanceService.java | 5 ++++- .../domain/skill/service/SkillPublishService.java | 12 ++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) 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 820bbba9..bbb748f8 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 @@ -182,12 +182,15 @@ public class SkillGovernanceService { deleteStorageAfterCommit(skill, namespaceSlug, storageKeys); skillFileRepository.deleteByVersionId(version.getId()); securityScanService.softDeleteByVersionId(version.getId()); - skillVersionRepository.delete(version); + // FK 约束 fk_skill_latest_version 阻止删除 skill_version 当 skill.latest_version_id 还指向它。 + // 必须先解开引用并 flush,让 PG 在 delete 时看不到引用。 if (version.getId().equals(skill.getLatestVersionId())) { skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId())); skill.setUpdatedBy(actorUserId); skillRepository.save(skill); + skillRepository.flush(); } + skillVersionRepository.delete(version); auditLogService.record( actorUserId, "DELETE_SKILL_VERSION", 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 fed90cfc..d4271bf8 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 @@ -564,6 +564,14 @@ public class SkillPublishService { throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); } + // FK 约束 fk_skill_latest_version 阻止删除 skill_version 当 skill.latest_version_id 还指向它。 + // 必须先解开引用并 flush,让 PG 在 delete 时看不到引用。 + if (version.getId().equals(skill.getLatestVersionId())) { + skill.setLatestVersionId(null); + skillRepository.save(skill); + skillRepository.flush(); + } + reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) .ifPresent(reviewTaskRepository::delete); @@ -579,10 +587,6 @@ public class SkillPublishService { securityScanService.softDeleteByVersionId(version.getId()); skillVersionRepository.delete(version); skillVersionRepository.flush(); - - if (version.getId().equals(skill.getLatestVersionId())) { - skill.setLatestVersionId(null); - } } private String resolveNamespaceSlug(Long namespaceId) { From 9bb6b31db70daa0d4a42bf95e064e6bf60cc3bb6 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 5 Jun 2026 16:16:55 +0800 Subject: [PATCH 10/78] feat(bootstrap): sync built-in skills from cloud manifest Signed-off-by: dongmucat <1127093059@qq.com> --- .../iflytek/skillhub/SkillhubApplication.java | 7 +- .../bootstrap/BuiltinSkillInitializer.java | 333 +++++++++++++++++ .../bootstrap/BuiltinSkillManifestLoader.java | 108 ++++++ .../BuiltinSkillPackageExtractor.java | 90 +++++ .../bootstrap/BuiltinSkillProperties.java | 17 + .../BuiltinSkillRemotePackageDownloader.java | 142 +++++++ .../src/main/resources/application.yml | 2 + .../resources/builtin-skills/manifest.json | 3 + .../BuiltinSkillInitializerTest.java | 347 ++++++++++++++++++ .../BuiltinSkillManifestLoaderTest.java | 157 ++++++++ .../BuiltinSkillPackageExtractorTest.java | 84 +++++ .../BuiltinSkillPropertiesBindingTest.java | 47 +++ ...iltinSkillRemotePackageDownloaderTest.java | 219 +++++++++++ 13 files changed, 1555 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java create mode 100644 server/skillhub-app/src/main/resources/builtin-skills/manifest.json create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java index bef19708..71ee7de8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub; +import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties; import com.iflytek.skillhub.config.ProfileFieldPolicyProperties; import com.iflytek.skillhub.config.ProfileModerationProperties; import org.springframework.boot.SpringApplication; @@ -10,7 +11,11 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties * Main Spring Boot entry point for the SkillHub backend application. */ @SpringBootApplication -@EnableConfigurationProperties({ProfileModerationProperties.class, ProfileFieldPolicyProperties.class}) +@EnableConfigurationProperties({ + BuiltinSkillProperties.class, + ProfileModerationProperties.class, + ProfileFieldPolicyProperties.class +}) public class SkillhubApplication { public static void main(String[] args) { SpringApplication.run(SkillhubApplication.class, args); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java new file mode 100644 index 00000000..7ff69a68 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -0,0 +1,333 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Best-effort startup synchronizer for remotely hosted built-in skill packages. + */ +@Component +public class BuiltinSkillInitializer implements ApplicationRunner { + + static final String GLOBAL_NAMESPACE = "global"; + static final String SYSTEM_PUBLISHER_ID = "builtin-skill-publisher"; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class); + private static final Set SYSTEM_PUBLISHER_ROLES = Set.of("SUPER_ADMIN"); + + private final BuiltinSkillProperties properties; + private final BuiltinSkillManifestLoader manifestLoader; + private final BuiltinSkillRemotePackageDownloader downloader; + private final BuiltinSkillPackageExtractor extractor; + private final SkillMetadataParser metadataParser; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillFileRepository skillFileRepository; + private final SkillPublishService skillPublishService; + + public BuiltinSkillInitializer( + BuiltinSkillProperties properties, + BuiltinSkillManifestLoader manifestLoader, + BuiltinSkillRemotePackageDownloader downloader, + BuiltinSkillPackageExtractor extractor, + SkillMetadataParser metadataParser, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillFileRepository skillFileRepository, + SkillPublishService skillPublishService) { + this.properties = properties; + this.manifestLoader = manifestLoader; + this.downloader = downloader; + this.extractor = extractor; + this.metadataParser = metadataParser; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillFileRepository = skillFileRepository; + this.skillPublishService = skillPublishService; + } + + @Override + public void run(ApplicationArguments args) { + if (!properties.isEnabled()) { + log.info("Built-in skill startup synchronization is disabled"); + return; + } + + Optional namespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE); + if (namespace.isEmpty()) { + log.warn("Global namespace '{}' does not exist, skipping built-in skill synchronization", + GLOBAL_NAMESPACE); + return; + } + + List items = manifestLoader.load(); + if (items.isEmpty()) { + log.info("No built-in skill manifest items to synchronize"); + return; + } + + try { + ensureSystemPublisher(namespace.get()); + } catch (RuntimeException exception) { + log.error("Failed to initialize built-in skill system publisher, skipping synchronization: {}", + exception.getMessage(), exception); + return; + } + + for (ManifestItem item : items) { + try { + syncItem(namespace.get(), item); + } catch (Exception exception) { + log.error( + "Failed to synchronize built-in skill slug={} version={}: {}", + item.slug(), + item.version(), + exception.getMessage(), + exception + ); + } + } + } + + private void ensureSystemPublisher(Namespace namespace) { + userAccountRepository.findById(SYSTEM_PUBLISHER_ID) + .orElseGet(() -> userAccountRepository.save(new UserAccount( + SYSTEM_PUBLISHER_ID, + "Built-in Skill Publisher", + null, + null + ))); + + if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), SYSTEM_PUBLISHER_ID).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember( + namespace.getId(), + SYSTEM_PUBLISHER_ID, + NamespaceRole.OWNER + )); + } + } + + private void syncItem(Namespace namespace, ManifestItem item) throws Exception { + Optional packageBytes = downloader.download(URI.create(item.url())); + if (packageBytes.isEmpty()) { + log.warn("Skipping built-in skill slug={} version={} because package download failed", + item.slug(), item.version()); + return; + } + + SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get()); + List entries = extractionResult.entries(); + SkillMetadata metadata = parseSkillMetadata(entries); + String packageSlug = SlugValidator.slugify(metadata.name()); + if (!item.slug().equals(packageSlug)) { + log.warn( + "Skipping built-in skill manifest slug={} version={} because package slug is {}", + item.slug(), + item.version(), + packageSlug + ); + return; + } + if (!item.version().equals(metadata.version())) { + log.warn( + "Skipping built-in skill slug={} because manifest version {} does not match package version {}", + item.slug(), + item.version(), + metadata.version() + ); + return; + } + + if (shouldSkipExisting(namespace.getId(), item, entries)) { + return; + } + + try { + skillPublishService.publishFromEntries( + GLOBAL_NAMESPACE, + entries, + SYSTEM_PUBLISHER_ID, + SkillVisibility.PUBLIC, + SYSTEM_PUBLISHER_ROLES, + false + ); + log.info("Published built-in skill slug={} version={} to @{}", + item.slug(), item.version(), GLOBAL_NAMESPACE); + } catch (RuntimeException exception) { + if (isAlreadyPublishedWithSameFingerprint(namespace.getId(), item, entries)) { + log.info("Built-in skill slug={} version={} was published concurrently, skipping", + item.slug(), item.version()); + return; + } + log.error("Failed to publish built-in skill slug={} version={}: {}", + item.slug(), item.version(), exception.getMessage(), exception); + } + } + + private SkillMetadata parseSkillMetadata(List entries) { + PackageEntry skillMd = entries.stream() + .filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH)); + return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); + } + + private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + boolean hasNonBuiltinOwner = existingSkills.stream() + .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); + if (hasNonBuiltinOwner) { + log.warn("Skipping built-in skill slug={} because the slug is already owned by another user", + item.slug()); + return true; + } + + Optional builtinSkill = existingSkills.stream() + .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) + .findFirst(); + if (builtinSkill.isEmpty()) { + return false; + } + + Optional existingVersion = skillVersionRepository + .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); + if (existingVersion.isEmpty()) { + return false; + } + + SkillVersion version = existingVersion.get(); + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + log.info("Skipping built-in skill slug={} version={} because existing version status is {}", + item.slug(), item.version(), version.getStatus()); + return true; + } + + String packageFingerprint = computeFingerprint(entries); + String existingFingerprint = computeFingerprint(version); + if (packageFingerprint.equals(existingFingerprint)) { + log.info("Skipping built-in skill slug={} version={} because it is already published", + item.slug(), item.version()); + } else { + log.warn( + "Skipping built-in skill slug={} version={} because published fingerprint differs: existing={}, package={}", + item.slug(), + item.version(), + existingFingerprint, + packageFingerprint + ); + } + return true; + } + + private boolean isAlreadyPublishedWithSameFingerprint(Long namespaceId, ManifestItem item, List entries) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + for (Skill skill : existingSkills) { + if (!SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) { + continue; + } + Optional version = skillVersionRepository + .findBySkillIdAndVersion(skill.getId(), item.version()); + if (version.isPresent() && version.get().getStatus() == SkillVersionStatus.PUBLISHED) { + String packageFingerprint = computeFingerprint(entries); + String existingFingerprint = computeFingerprint(version.get()); + if (packageFingerprint.equals(existingFingerprint)) { + return true; + } + log.warn( + "Built-in skill slug={} version={} was published concurrently with different content: existing={}, package={}", + item.slug(), + item.version(), + existingFingerprint, + packageFingerprint + ); + return false; + } + } + return false; + } + + private String computeFingerprint(SkillVersion version) { + List files = skillFileRepository.findByVersionId(version.getId()).stream() + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .toList(); + return computeFingerprintFromFileDigests(files.stream() + .map(file -> new FileDigest(file.getFilePath(), file.getSha256())) + .toList()); + } + + private String computeFingerprint(List entries) { + return computeFingerprintFromFileDigests(entries.stream() + .map(entry -> new FileDigest(entry.path(), sha256(entry.content()))) + .toList()); + } + + private String computeFingerprintFromFileDigests(List files) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (FileDigest file : files.stream().sorted(Comparator.comparing(FileDigest::path)).toList()) { + String line = file.path() + ":" + file.sha256() + "\n"; + digest.update(line.getBytes(StandardCharsets.UTF_8)); + } + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } catch (Exception exception) { + throw new IllegalStateException("Failed to compute built-in skill fingerprint", exception); + } + } + + private static String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (Exception exception) { + throw new IllegalStateException("Failed to compute built-in skill file digest", exception); + } + } + + private record FileDigest(String path, String sha256) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java new file mode 100644 index 00000000..487179a4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.bootstrap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class BuiltinSkillManifestLoader { + + static final String MANIFEST_LOCATION = "classpath:builtin-skills/manifest.json"; + static final int MAX_ITEMS = 100; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillManifestLoader.class); + + private final ObjectMapper objectMapper; + private final ResourceLoader resourceLoader; + + public BuiltinSkillManifestLoader(ObjectMapper objectMapper, ResourceLoader resourceLoader) { + this.objectMapper = objectMapper; + this.resourceLoader = resourceLoader; + } + + public List load() { + Resource resource = resourceLoader.getResource(MANIFEST_LOCATION); + if (!resource.exists()) { + log.warn("Built-in skill manifest not found at {}", MANIFEST_LOCATION); + return List.of(); + } + + JsonNode root; + try (InputStream inputStream = resource.getInputStream()) { + root = objectMapper.readTree(inputStream); + } catch (IOException | RuntimeException ex) { + log.warn("Failed to read built-in skill manifest at {}: {}", MANIFEST_LOCATION, ex.getMessage()); + return List.of(); + } + + if (root == null || root.isNull()) { + log.warn("Built-in skill manifest at {} is empty", MANIFEST_LOCATION); + return List.of(); + } + + JsonNode skillsNode = root.path("skills"); + if (!skillsNode.isArray()) { + log.warn("Built-in skill manifest at {} does not contain an array field 'skills'", MANIFEST_LOCATION); + return List.of(); + } + + List items = new ArrayList<>(); + Set seenSlugVersions = new HashSet<>(); + int totalEntries = skillsNode.size(); + if (totalEntries > MAX_ITEMS) { + log.warn("Built-in skill manifest has {} entries, only the first {} entries will be processed", + totalEntries, MAX_ITEMS); + } + int limit = Math.min(totalEntries, MAX_ITEMS); + for (int index = 0; index < limit; index++) { + JsonNode itemNode = skillsNode.get(index); + String slug = text(itemNode, "slug"); + String version = text(itemNode, "version"); + String url = text(itemNode, "url"); + if (!StringUtils.hasText(slug) || !StringUtils.hasText(version) || !StringUtils.hasText(url)) { + log.warn("Skipping built-in skill manifest item {} because slug, version, and url are required", index); + continue; + } + try { + SlugValidator.validate(slug); + } catch (RuntimeException ex) { + log.warn("Skipping built-in skill manifest item {} because slug is invalid [slug={}]: {}", + index, slug, ex.getMessage()); + continue; + } + + String key = slug + "\n" + version; + if (!seenSlugVersions.add(key)) { + log.warn("Skipping duplicate built-in skill manifest item for slug={} version={}", slug, version); + continue; + } + + items.add(new ManifestItem(slug, version, url)); + } + return List.copyOf(items); + } + + private static String text(JsonNode node, String fieldName) { + JsonNode value = node.get(fieldName); + if (value == null || !value.isTextual()) { + return ""; + } + return value.asText().trim(); + } + + public record ManifestItem(String slug, String version, String url) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java new file mode 100644 index 00000000..9cd44cd6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java @@ -0,0 +1,90 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Component +public class BuiltinSkillPackageExtractor { + + private final SkillPackageArchiveExtractor archiveExtractor; + + public BuiltinSkillPackageExtractor(SkillPackageArchiveExtractor archiveExtractor) { + this.archiveExtractor = archiveExtractor; + } + + public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException { + assertRootSkillMd(zipBytes); + SkillPackageArchiveExtractor.ExtractionResult result = + archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes)); + boolean hasRootSkillMd = result.entries().stream() + .anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())); + if (!hasRootSkillMd) { + throw new IllegalArgumentException("Built-in skill package must contain root " + SkillPackagePolicy.SKILL_MD_PATH); + } + return result; + } + + private void assertRootSkillMd(byte[] zipBytes) throws IOException { + try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (!entry.isDirectory() && SkillPackagePolicy.SKILL_MD_PATH.equals(entry.getName())) { + return; + } + zipInputStream.closeEntry(); + } + } + throw new IllegalArgumentException("Built-in skill package must contain root " + SkillPackagePolicy.SKILL_MD_PATH); + } + + private record ByteArrayMultipartFile(byte[] bytes) implements MultipartFile { + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return "builtin-skill.zip"; + } + + @Override + public String getContentType() { + return "application/zip"; + } + + @Override + public boolean isEmpty() { + return bytes.length == 0; + } + + @Override + public long getSize() { + return bytes.length; + } + + @Override + public byte[] getBytes() { + return bytes.clone(); + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(java.io.File dest) throws IOException { + throw new UnsupportedOperationException("Built-in skill zip adapter is read-only"); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java new file mode 100644 index 00000000..6fd3626d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.bootstrap; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "skillhub.builtin-skills") +public class BuiltinSkillProperties { + + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java new file mode 100644 index 00000000..c2819d3f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; + +@Component +public class BuiltinSkillRemotePackageDownloader { + + static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + static final String ALLOWED_HOST = "bjcdn.openstorage.cn"; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillRemotePackageDownloader.class); + private static final Pattern IPV4_LITERAL = Pattern.compile("\\d{1,3}(\\.\\d{1,3}){3}"); + + private final long maxPackageSize; + private final HttpClient httpClient; + + @Autowired + public BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties) { + this( + properties, + HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + ); + } + + BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties, HttpClient httpClient) { + this.maxPackageSize = properties.getMaxPackageSize(); + this.httpClient = httpClient; + } + + public Optional download(URI uri) { + if (!isAllowedUrl(uri)) { + log.warn("Skipping built-in skill package download because URL is not allowed: {}", safeUrl(uri)); + return Optional.empty(); + } + + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(REQUEST_TIMEOUT) + .GET() + .build(); + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + log.warn("Failed to download built-in skill package from {}: HTTP {}", + safeUrl(uri), + response.statusCode()); + return Optional.empty(); + } + try (InputStream body = response.body()) { + return readBounded(body); + } + } catch (IOException ex) { + log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage()); + return Optional.empty(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while downloading built-in skill package from {}", safeUrl(uri)); + return Optional.empty(); + } catch (RuntimeException ex) { + log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage()); + return Optional.empty(); + } + } + + HttpClient httpClient() { + return httpClient; + } + + static boolean isAllowedUrl(URI uri) { + if (uri == null || !"https".equalsIgnoreCase(uri.getScheme())) { + return false; + } + if (uri.getRawUserInfo() != null) { + return false; + } + int port = uri.getPort(); + if (port != -1 && port != 443) { + return false; + } + String host = uri.getHost(); + if (host == null) { + return false; + } + String normalizedHost = host.toLowerCase(Locale.ROOT); + if (isDisallowedHostLiteral(normalizedHost)) { + return false; + } + return normalizedHost.equals(ALLOWED_HOST) || normalizedHost.endsWith("." + ALLOWED_HOST); + } + + private Optional readBounded(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + long totalRead = 0; + int read; + while ((read = inputStream.read(buffer)) != -1) { + totalRead += read; + if (totalRead > maxPackageSize) { + log.warn("Built-in skill package download exceeded max package size: {} bytes (max: {})", + totalRead, + maxPackageSize); + return Optional.empty(); + } + outputStream.write(buffer, 0, read); + } + return Optional.of(outputStream.toByteArray()); + } + + private static boolean isDisallowedHostLiteral(String host) { + return "localhost".equals(host) + || IPV4_LITERAL.matcher(host).matches() + || host.contains(":"); + } + + private static String safeUrl(URI uri) { + if (uri == null) { + return ""; + } + String host = uri.getHost(); + String path = uri.getRawPath(); + return (host == null ? "" : host) + (path == null ? "" : path); + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a592b035..7d9a1cbc 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -93,6 +93,8 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: + builtin-skills: + enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} diff --git a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json new file mode 100644 index 00000000..2b485e41 --- /dev/null +++ b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json @@ -0,0 +1,3 @@ +{ + "skills": [] +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java new file mode 100644 index 00000000..4e3322bb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -0,0 +1,347 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@ExtendWith(MockitoExtension.class) +class BuiltinSkillInitializerTest { + + private static final String GLOBAL = "global"; + private static final String PUBLISHER = "builtin-skill-publisher"; + private static final ManifestItem ITEM = new ManifestItem( + "agentguard", + "1.0.0", + "https://bjcdn.openstorage.cn/skills/agentguard.zip" + ); + + @Mock private BuiltinSkillManifestLoader manifestLoader; + @Mock private BuiltinSkillRemotePackageDownloader downloader; + @Mock private BuiltinSkillPackageExtractor extractor; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private UserAccountRepository userAccountRepository; + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository skillVersionRepository; + @Mock private SkillFileRepository skillFileRepository; + @Mock private SkillPublishService skillPublishService; + + private BuiltinSkillProperties properties; + private BuiltinSkillInitializer initializer; + private Namespace globalNamespace; + + @BeforeEach + void setUp() { + properties = new BuiltinSkillProperties(); + initializer = new BuiltinSkillInitializer( + properties, + manifestLoader, + downloader, + extractor, + new SkillMetadataParser(), + namespaceRepository, + namespaceMemberRepository, + userAccountRepository, + skillRepository, + skillVersionRepository, + skillFileRepository, + skillPublishService + ); + globalNamespace = new Namespace(GLOBAL, "Global", "system"); + ReflectionTestUtils.setField(globalNamespace, "id", 1L); + } + + @Test + void skipsWhenDisabled() { + properties.setEnabled(false); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(manifestLoader, never()).load(); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsAllItemsWhenGlobalNamespaceDoesNotExist() { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.empty()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(manifestLoader, never()).load(); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsExistingSkillOwnedByAnotherUser() throws Exception { + Skill otherSkill = skill(100L, "agentguard", "someone-else"); + givenExtractedPackage(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(otherSkill)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsPublishedSameVersionWhenFingerprintMatches() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + List entries = packageEntries("agentguard", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsPublishedSameVersionWhenFingerprintDiffers() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + givenExtractedPackage(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( + new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("changed"), "storage-key") + )); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsExistingSameVersionWhenNotPublished() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion uploaded = version(200L, 100L, "1.0.0", SkillVersionStatus.UPLOADED); + givenExtractedPackage(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(uploaded)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsWhenManifestSlugDoesNotMatchPackageMetadata() throws Exception { + givenExtractedPackage(packageEntries("other-skill", "1.0.0", "same")); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsWhenManifestVersionDoesNotMatchPackageMetadata() throws Exception { + givenExtractedPackage(packageEntries("agentguard", "1.0.1", "same")); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void publishesNewVersionToGlobalAsPublicWithSystemPublisher() throws Exception { + List entries = packageEntries("agentguard", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor> entriesCaptor = ArgumentCaptor.captor(); + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), + entriesCaptor.capture(), + eq(PUBLISHER), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false) + ); + assertThat(entriesCaptor.getValue()).isEqualTo(entries); + } + + @Test + void createsSystemPublisherAndGlobalMembershipBeforePublishing() throws Exception { + List entries = packageEntries("agentguard", "1.0.0", "same"); + givenExtractedPackage(entries); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.empty()); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getId()).isEqualTo(PUBLISHER); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().getUserId()).isEqualTo(PUBLISHER); + assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.OWNER); + } + + @Test + void treatsConcurrentDuplicatePublishedVersionAsCompleted() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + List entries = packageEntries("agentguard", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")) + .thenReturn(List.of()) + .thenReturn(List.of(builtinSkill)); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false))) + .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + } + + @Test + void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + givenExtractedPackage(packageEntries("agentguard", "1.0.0", "new-content")); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")) + .thenReturn(List.of()) + .thenReturn(List.of(builtinSkill)); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false))) + .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( + new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("old-content"), "storage-key") + )); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillFileRepository).findByVersionId(200L); + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + } + + private void givenExtractedPackage() throws Exception { + givenExtractedPackage(packageEntries("agentguard", "1.0.0", "same")); + } + + private void givenExtractedPackage(List entries) throws Exception { + byte[] bytes = "zip".getBytes(StandardCharsets.UTF_8); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + lenient().when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + lenient().when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(downloader.download(URI.create(ITEM.url()))).thenReturn(Optional.of(bytes)); + when(extractor.extract(bytes)).thenReturn(new SkillPackageArchiveExtractor.ExtractionResult(entries, List.of())); + } + + private static UserAccount systemPublisher() { + return new UserAccount(PUBLISHER, "Built-in Skill Publisher", null, null); + } + + private static Skill skill(Long id, String slug, String ownerId) { + Skill skill = new Skill(1L, slug, ownerId, SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", id); + return skill; + } + + private static SkillVersion version(Long id, Long skillId, String version, SkillVersionStatus status) { + SkillVersion skillVersion = new SkillVersion(skillId, version, PUBLISHER); + ReflectionTestUtils.setField(skillVersion, "id", id); + skillVersion.setStatus(status); + return skillVersion; + } + + private static List packageEntries(String name, String version, String readme) { + byte[] skillMd = (""" + --- + name: %s + description: Built-in guardrails + version: %s + --- + # %s + """).formatted(name, version, name).getBytes(StandardCharsets.UTF_8); + byte[] readmeBytes = readme.getBytes(StandardCharsets.UTF_8); + return List.of( + new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown"), + new PackageEntry("README.md", readmeBytes, readmeBytes.length, "text/markdown") + ); + } + + private static List skillFilesFor(List entries, Long versionId) { + return entries.stream() + .map(entry -> new SkillFile( + versionId, + entry.path(), + entry.size(), + entry.contentType(), + sha256(entry.content()), + "storage-key/" + entry.path() + )) + .toList(); + } + + private static String sha256(String content) { + return sha256(content.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java new file mode 100644 index 00000000..39ea4b38 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java @@ -0,0 +1,157 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ResourceLoader; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +class BuiltinSkillManifestLoaderTest { + + @Test + void loadsManifestItemsInOrder() { + BuiltinSkillManifestLoader loader = loaderWith(""" + { + "skills": [ + {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/agentguard.zip"}, + {"slug": "agentguard", "version": "1.1.0", "url": "https://cdn.bjcdn.openstorage.cn/agentguard.zip"} + ] + } + """); + + List items = loader.load(); + + assertThat(items) + .extracting(BuiltinSkillManifestLoader.ManifestItem::version) + .containsExactly("1.0.0", "1.1.0"); + } + + @Test + void returnsEmptyListWhenManifestIsMissing() { + BuiltinSkillManifestLoader loader = new BuiltinSkillManifestLoader( + new ObjectMapper(), + new ResourceLoader() { + @Override + public org.springframework.core.io.Resource getResource(String location) { + return new MissingResource(); + } + + @Override + public ClassLoader getClassLoader() { + return getClass().getClassLoader(); + } + } + ); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void returnsEmptyListWhenManifestIsMalformed() { + BuiltinSkillManifestLoader loader = loaderWith("{not-json"); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void returnsEmptyListWhenManifestIsEmpty() { + BuiltinSkillManifestLoader loader = loaderWith(""); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void skipsItemsWithMissingHumanFieldsAndDuplicateSlugVersion() { + BuiltinSkillManifestLoader loader = loaderWith(""" + { + "skills": [ + {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/first.zip"}, + {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/second.zip"}, + {"slug": "InvalidUppercase", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/invalid.zip"}, + {"slug": "", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/blank.zip"}, + {"slug": "missing-version", "url": "https://bjcdn.openstorage.cn/missing-version.zip"}, + {"slug": "missing-url", "version": "1.0.0"}, + {"slug": "valid-after-invalid", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/valid.zip"} + ] + } + """); + + List items = loader.load(); + + assertThat(items) + .extracting(BuiltinSkillManifestLoader.ManifestItem::url) + .containsExactly( + "https://bjcdn.openstorage.cn/first.zip", + "https://bjcdn.openstorage.cn/valid.zip" + ); + } + + @Test + void capsManifestEntriesAtOneHundredRawEntries() { + StringBuilder json = new StringBuilder("{\"skills\":["); + for (int i = 0; i < 101; i++) { + if (i > 0) { + json.append(','); + } + if (i == 0) { + json.append("{\"slug\":\"\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/blank.zip\"}"); + } else { + json.append("{\"slug\":\"skill-").append(i) + .append("\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/skill-") + .append(i) + .append(".zip\"}"); + } + } + json.append("]}"); + + BuiltinSkillManifestLoader loader = loaderWith(json.toString()); + + assertThat(loader.load()).hasSize(99); + } + + private BuiltinSkillManifestLoader loaderWith(String content) { + ResourceLoader resourceLoader = new ResourceLoader() { + @Override + public org.springframework.core.io.Resource getResource(String location) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) { + @Override + public boolean exists() { + return true; + } + + @Override + public String getDescription() { + return "test manifest"; + } + }; + } + + @Override + public ClassLoader getClassLoader() { + return getClass().getClassLoader(); + } + }; + return new BuiltinSkillManifestLoader(new ObjectMapper(), resourceLoader); + } + + static class MissingResource extends ByteArrayResource { + + MissingResource() { + super(new byte[0]); + } + + @Override + public boolean exists() { + return false; + } + + @Override + public String getDescription() { + return "missing manifest"; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java new file mode 100644 index 00000000..99f710b9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -0,0 +1,84 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +class BuiltinSkillPackageExtractorTest { + + private final BuiltinSkillPackageExtractor extractor = new BuiltinSkillPackageExtractor( + new SkillPackageArchiveExtractor(new SkillPublishProperties()) + ); + + @Test + void extractsZipBytesThroughArchiveExtractor() throws Exception { + byte[] zip = zip( + entry("SKILL.md", """ + --- + name: agentguard + version: 1.0.0 + --- + # AgentGuard + """), + entry("README.md", "# Readme") + ); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extract(zip); + + assertThat(result.entries()) + .extracting(entry -> entry.path()) + .containsExactly("SKILL.md", "README.md"); + } + + @Test + void rejectsZipWithoutRootSkillMd() throws Exception { + byte[] zip = zip(entry("README.md", "# Readme")); + + assertThatThrownBy(() -> extractor.extract(zip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(SkillPackagePolicy.SKILL_MD_PATH); + } + + @Test + void rejectsZipWithOnlyNestedSkillMd() throws Exception { + byte[] zip = zip(entry("agentguard/SKILL.md", """ + --- + name: agentguard + version: 1.0.0 + --- + # AgentGuard + """)); + + assertThatThrownBy(() -> extractor.extract(zip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(SkillPackagePolicy.SKILL_MD_PATH); + } + + private static ZipSource entry(String path, String content) { + return new ZipSource(path, content.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] zip(ZipSource... sources) throws Exception { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (ZipSource source : sources) { + zipOutputStream.putNextEntry(new ZipEntry(source.path())); + zipOutputStream.write(source.content()); + zipOutputStream.closeEntry(); + } + } + return outputStream.toByteArray(); + } + + record ZipSource(String path, byte[] content) { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java new file mode 100644 index 00000000..174b8347 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +import java.util.Map; + +class BuiltinSkillPropertiesBindingTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfig.class); + + @Test + void enabledDefaultsToTrue() { + contextRunner.run((context) -> { + BuiltinSkillProperties properties = context.getBean(BuiltinSkillProperties.class); + + assertThat(properties.isEnabled()).isTrue(); + }); + } + + @Test + void bindsEnabledFromEnvironmentStyleProperty() { + contextRunner + .withInitializer((context) -> context.getEnvironment().getPropertySources().addFirst( + new SystemEnvironmentPropertySource( + "test-env", + Map.of("SKILLHUB_BUILTIN_SKILLS_ENABLED", "false") + ) + )) + .run((context) -> { + BuiltinSkillProperties properties = context.getBean(BuiltinSkillProperties.class); + + assertThat(properties.isEnabled()).isFalse(); + }); + } + + @Configuration + @EnableConfigurationProperties(BuiltinSkillProperties.class) + static class TestConfig { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java new file mode 100644 index 00000000..9dbf2cdf --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java @@ -0,0 +1,219 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +class BuiltinSkillRemotePackageDownloaderTest { + + @Test + void acceptsAllowedHttpsCdnHostsOnly() { + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://bjcdn.openstorage.cn/a.zip"))) + .isTrue(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://assets.bjcdn.openstorage.cn/a.zip"))) + .isTrue(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("http://bjcdn.openstorage.cn/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://evil.com/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://user:pass@bjcdn.openstorage.cn/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://bjcdn.openstorage.cn:8443/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://127.0.0.1/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://localhost/a.zip"))) + .isFalse(); + } + + @Test + void defaultHttpClientDoesNotFollowRedirects() { + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader(new SkillPublishProperties()); + + assertThat(downloader.httpClient().followRedirects()).isEqualTo(HttpClient.Redirect.NEVER); + assertThat(downloader.httpClient().connectTimeout()).contains(Duration.ofSeconds(5)); + } + + @Test + void downloadsAllowedUrlWithThirtySecondRequestTimeout() { + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1, 2, 3}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).contains(new byte[] {1, 2, 3}); + assertThat(client.lastRequest.timeout()).contains(Duration.ofSeconds(30)); + } + + @Test + void rejectsRedirectResponsesWithoutReadingLocation() { + FakeHttpClient client = new FakeHttpClient(302, new byte[] {1}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(client.sendCalls).isEqualTo(1); + } + + @Test + void rejectedUrlDoesNotSendHttpRequest() { + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://example.com/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(client.sendCalls).isZero(); + } + + @Test + void stopsReadingWhenResponseExceedsMaxPackageSize() { + SkillPublishProperties properties = new SkillPublishProperties(); + properties.setMaxPackageSize(2); + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1, 2, 3}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader(properties, client); + + assertThat(downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip"))).isEmpty(); + } + + static class FakeHttpClient extends HttpClient { + + private final int statusCode; + private final byte[] body; + private HttpRequest lastRequest; + private int sendCalls; + + FakeHttpClient(int statusCode, byte[] body) { + this.statusCode = statusCode; + this.body = body; + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.of(Duration.ofSeconds(5)); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public SSLParameters sslParameters() { + return null; + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws IOException { + lastRequest = request; + sendCalls++; + @SuppressWarnings("unchecked") + T responseBody = (T) new ByteArrayInputStream(body); + return new FakeResponse<>(request, statusCode, responseBody); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler + ) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler + ) { + throw new UnsupportedOperationException(); + } + } + + record FakeResponse(HttpRequest request, int statusCode, T body) implements HttpResponse { + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(java.util.Map.of(), (name, value) -> true); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + } +} From 0f752e23058442444e5df4f0b18876928649d948 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 5 Jun 2026 17:16:25 +0800 Subject: [PATCH 11/78] docs(bootstrap): add built-in skill cloud setup guide Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 264 ++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/20-cloud-url-builtin-skills-setup.md diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md new file mode 100644 index 00000000..1ddc054a --- /dev/null +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -0,0 +1,264 @@ +# 云存储链接内置 Skills 配置指南 + +本文说明如何通过仓库内 manifest 配置 SkillHub 内置 Skills,以及应用启动时这些 Skills 如何从云存储同步到 `@global` 空间。 + +适用场景: + +- 希望 SkillHub 新部署实例默认带有一批官方内置 Skills。 +- 不希望把完整 Skill 包目录长期放在代码仓库和镜像中。 +- 内置 Skill 包已经上传到官方可控的云存储域名。 + +## 1. 方案概览 + +内置 Skills 不再以本地目录包的形式直接随仓库维护。当前方案只在仓库中维护一个 manifest 文件,应用启动时根据 manifest 中的云存储 URL 下载 zip 包,并通过 SkillHub 现有发布链路发布到 `@global`。 + +流程: + +```text +维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用启动 -> 读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见 +``` + +核心文件: + +```text +server/skillhub-app/src/main/resources/builtin-skills/manifest.json +``` + +首版 manifest 只需要维护三个字段: + +- `slug`:Skill 在 `@global` 下的 slug。 +- `version`:期望同步的 Skill 版本。 +- `url`:Skill zip 包的云存储 HTTPS 链接。 + +## 2. Manifest 配置 + +manifest 文件格式如下: + +```json +{ + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.0.0.zip" + } + ] +} +``` + +可以配置多个 Skills,也可以为同一个 `slug` 配置多个版本: + +```json +{ + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.0.0.zip" + }, + { + "slug": "skillhub-hello", + "version": "1.1.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.1.0.zip" + }, + { + "slug": "skillhub-guide", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-guide-1.0.0.zip" + } + ] +} +``` + +配置要求: + +- `skills` 必须是数组。 +- 每一项必须同时填写 `slug`、`version`、`url`。 +- `slug` 必须符合 SkillHub slug 规则。 +- 同一个 `slug + version` 重复出现时,只处理第一条,后续重复项会被跳过。 +- manifest 最多处理前 100 条 entries。 +- 同一个 `slug` 的多个版本建议按从旧到新的顺序排列;运行时按 manifest 文件顺序处理,不做自动版本排序。 + +## 3. Skill 包要求 + +manifest 中的 `url` 必须指向 zip 包。zip 包需要满足 SkillHub Skill 包协议: + +- zip 根目录必须包含 `SKILL.md`。 +- `SKILL.md` frontmatter 中必须包含合法的 `name`、`description`、`version` 等元数据。 +- `SKILL.md` 中的 `name` 经过 slug 归一化后,必须等于 manifest 中的 `slug`。 +- `SKILL.md` 中的 `version` 必须等于 manifest 中的 `version`。 +- 包内容仍会经过 SkillHub 现有发布校验,包括文件数量、文件大小、扩展名、文件类型等规则。 + +示例: + +```text +skillhub-hello-1.0.0.zip +├── SKILL.md +├── README.md +└── scripts/ + └── check.js +``` + +不推荐的结构: + +```text +skillhub-hello-1.0.0.zip +└── skillhub-hello/ + └── SKILL.md +``` + +原因是内置 Skill 同步要求根目录存在 `SKILL.md`,不会把嵌套目录中的 `SKILL.md` 当作入口。 + +## 4. URL 安全限制 + +内置 Skill 同步由后端在启动时主动下载远程文件,因此 URL 有严格限制。 + +首版只允许: + +- `https://` 协议。 +- host 为 `bjcdn.openstorage.cn`。 +- host 为 `bjcdn.openstorage.cn` 的子域名,例如 `assets.bjcdn.openstorage.cn`。 +- 默认 HTTPS 端口,或显式 `:443`。 + +以下 URL 会被跳过: + +- `http://...` +- 非 `bjcdn.openstorage.cn` 及其子域名。 +- 带 userinfo 的 URL,例如 `https://user:pass@bjcdn.openstorage.cn/file.zip`。 +- 非 443 端口,例如 `https://bjcdn.openstorage.cn:8443/file.zip`。 +- `localhost`、IP 地址、IPv6 literal 等 host。 +- 需要 HTTP redirect 才能拿到文件的链接。 + +如果某一项 URL 不符合规则,SkillHub 会记录日志并跳过该项,不会阻塞应用启动。 + +## 5. 启动同步流程 + +应用启动时同步器只执行一次。 + +详细流程: + +1. 检查 `skillhub.builtin-skills.enabled` 是否开启。 +2. 读取 `classpath:builtin-skills/manifest.json`。 +3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。 +4. 确保系统发布者 `builtin-skill-publisher` 存在,并且是 `@global` 的 `OWNER`。 +5. 按 manifest 顺序处理每一个 item。 +6. 下载对应 zip 包。 +7. 解包并校验根目录 `SKILL.md`。 +8. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 +9. 检查是否已存在同名 Skill 或同版本。 +10. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 +11. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。 + +同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务,因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。 + +## 6. 幂等与冲突处理 + +内置 Skill 同步支持重复启动和多次部署。 + +幂等键: + +```text +@global/{slug} + version +``` + +行为说明: + +| 场景 | 行为 | +|---|---| +| `@global/{slug}` 不存在 | 发布 manifest 中的 Skill | +| `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | +| 同版本已存在且已发布,内容一致 | 跳过 | +| 同版本已存在且已发布,但内容不一致 | 跳过并记录 warning | +| 同版本已存在但不是 `PUBLISHED` | 跳过并记录日志 | +| `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 跳过并记录 warning | + +这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill。 + +如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 + +## 7. 开关配置 + +内置 Skill 同步默认开启。 + +Spring 配置项: + +```yaml +skillhub: + builtin-skills: + enabled: true +``` + +环境变量: + +```dotenv +SKILLHUB_BUILTIN_SKILLS_ENABLED=true +``` + +如需禁用启动同步: + +```dotenv +SKILLHUB_BUILTIN_SKILLS_ENABLED=false +``` + +禁用后,应用启动时不会读取 manifest,也不会下载或发布任何内置 Skill。 + +## 8. 维护流程 + +新增一个内置 Skill 的推荐步骤: + +1. 准备 Skill 包,并确认 zip 根目录包含 `SKILL.md`。 +2. 检查 `SKILL.md` 中的 `name` 和 `version`。 +3. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的官方云存储路径。 +4. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。 +5. 确保 manifest 中的 `slug` 等于 `SKILL.md name` 归一化后的 slug。 +6. 确保 manifest 中的 `version` 等于 `SKILL.md version`。 +7. 本地或测试环境启动 SkillHub,查看后端日志确认同步结果。 +8. 在 Web UI 或 API 中确认 `@global/{slug}` 已公开可见。 + +更新一个已有内置 Skill 的推荐步骤: + +1. 不要覆盖已经发布过的旧版本 zip 内容。 +2. 在 `SKILL.md` 中提升 `version`。 +3. 重新打包并上传新的 zip 文件。 +4. 在 manifest 中新增一条同 `slug`、新 `version` 的记录。 +5. 保留旧版本记录,除非产品明确不再需要该旧版本在新实例中预置。 + +不推荐: + +- 修改旧版本 zip 内容但保持同一个 `version`。 +- 把 URL 指向会发生内容变化的临时对象。 +- 使用需要登录、签名跳转或重定向的下载链接。 + +## 9. 日志与排查 + +启动时可以通过后端日志观察同步结果。 + +常见日志含义: + +| 日志含义 | 处理建议 | +|---|---| +| manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath | +| slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 | +| slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 | +| URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 | +| package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | +| package must contain SKILL.md | 检查 zip 根目录是否存在 `SKILL.md` | +| manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | +| slug is already owned by another user | 说明 `@global/{slug}` 已被非内置发布者占用,内置同步不会覆盖 | +| published fingerprint differs | 同一内置版本已存在但内容不同,需要人工确认是否错误覆盖了远程包 | + +如果某个 manifest item 失败,后续 item 仍会继续处理,应用启动也会继续。 + +## 10. 验收检查 + +配置或新增内置 Skill 后,建议至少完成以下检查: + +- manifest JSON 格式合法。 +- 每个 item 都包含 `slug`、`version`、`url`。 +- URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。 +- zip 根目录包含 `SKILL.md`。 +- `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。 +- `SKILL.md version` 与 manifest `version` 一致。 +- 启动日志没有该 item 的 warning 或 error。 +- Web UI 中可以看到 `@global/{slug}`。 +- Skill 可被匿名或登录用户按公开 Skill 规则发现。 From 531d59caf273fce620c1c33c6520960dbef309a6 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:24:51 +0800 Subject: [PATCH 12/78] feat(my-skills): add keyword search, namespace filter and clickable pagination (#493) * feat(my-skills): add keyword search, namespace filter and clickable pagination Add comprehensive filtering and search capabilities to the My Skills page: - Keyword search: search by skill name, slug, or description - Namespace filter: filter skills by namespace - Clickable pagination: page number buttons with smart ellipsis - State preservation: sync search state to URL, restore when returning from detail page - Debounced search: 300ms debounce to avoid excessive queries - Fix: hide stale rejected preview badge when newer version is published Backend changes: - MySkillAppService: add keyword and namespace filtering logic - SkillLifecycleProjectionService: only show preview versions newer than published - MeController: add keyword and namespace query parameters - 6 new test cases covering search and filter scenarios Frontend changes: - my-skills.tsx: search input, namespace dropdown, URL state sync - pagination.tsx: clickable page numbers with ellipsis - use-user-queries.ts: prevent flicker on query transitions - skill-detail.tsx: remove invalid rejected badge display - router.tsx: URL parameter validation - i18n: add search-related translation keys Synced from SAAS commits: - 939fa749 (feat: search and filters) - dc14df6c (fix: search flicker) - 0168ea81 (fix: rejected badge) - c9eefa93 (fix: stale preview) Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(tests): address test failures in PR #493 Backend test fixes: - Remove unnecessary Mockito stubbing for filtered-out skills - Add missing findBySkillIdAndStatus stub for published version lookup - Update MeController test mocks to match new method signature (keyword, namespace params) Frontend fixes: - Fix TypeScript error: useMyNamespaces returns ManagedNamespace[] not PagedResponse - Add type annotation for namespace map callback parameter E2E test fix: - Update URL regex to allow query parameters (returnTo from search page) Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(tests): resolve test failures in PR #493 Backend: - Remove unnecessary mock stubbings for skillId 2 and 3 in MySkillAppServiceTest.listMySkills_combinesKeywordNamespaceAndStatusFilters - The test filters results to only return skill with id=1, so mocks for id 2 and 3 were never called, causing UnnecessaryStubbingException Frontend: - Add missing mocks for useLocation, useSearch, useMyNamespaces, and useDebounce in my-skills.test.ts - MySkillsPage component uses these hooks but the test setup didn't provide mocks, causing 'No QueryClient set' and 'No export' errors All 4 frontend tests now pass locally. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../controller/portal/MeController.java | 4 +- .../skillhub/service/MySkillAppService.java | 90 +++++++++-- .../skillhub/controller/MeControllerTest.java | 4 +- .../service/MySkillAppServiceTest.java | 101 +++++++++++- .../SkillLifecycleProjectionService.java | 58 +++---- web/e2e/public-skill-detail-anonymous.spec.ts | 2 +- web/src/api/client.ts | 8 +- web/src/app/router.tsx | 6 + web/src/i18n/locales/en.json | 9 +- web/src/i18n/locales/zh.json | 9 +- web/src/pages/dashboard/my-skills.test.ts | 10 ++ web/src/pages/dashboard/my-skills.tsx | 150 +++++++++++++++--- web/src/pages/search.tsx | 2 +- web/src/pages/skill-detail.tsx | 3 +- web/src/shared/components/pagination.tsx | 68 +++++++- web/src/shared/hooks/use-user-queries.ts | 7 +- 16 files changed, 447 insertions(+), 84 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index f5869eb1..0dd5f2ba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -34,6 +34,8 @@ public class MeController extends BaseApiController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String filter, + @RequestParam(required = false) String q, + @RequestParam(required = false) String namespace, @AuthenticationPrincipal PlatformPrincipal principal) { if (principal == null) { throw new UnauthorizedException("error.auth.required"); @@ -41,7 +43,7 @@ public class MeController extends BaseApiController { return ok( "response.success.read", - mySkillAppService.listMySkills(principal.userId(), page, size, filter, principal.platformRoles()) + mySkillAppService.listMySkills(principal.userId(), page, size, filter, q, namespace, principal.platformRoles()) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 9ad9e6f3..e36dc201 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.service; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; @@ -36,6 +38,7 @@ public class MySkillAppService { private final SkillSubscriptionRepository skillSubscriptionRepository; private final MySkillQueryRepository mySkillQueryRepository; private final SkillLifecycleProjectionService skillLifecycleProjectionService; + private final NamespaceRepository namespaceRepository; public MySkillAppService( SkillRepository skillRepository, @@ -43,17 +46,19 @@ public class MySkillAppService { SkillStarRepository skillStarRepository, SkillSubscriptionRepository skillSubscriptionRepository, MySkillQueryRepository mySkillQueryRepository, - SkillLifecycleProjectionService skillLifecycleProjectionService) { + SkillLifecycleProjectionService skillLifecycleProjectionService, + NamespaceRepository namespaceRepository) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillStarRepository = skillStarRepository; this.skillSubscriptionRepository = skillSubscriptionRepository; this.mySkillQueryRepository = mySkillQueryRepository; this.skillLifecycleProjectionService = skillLifecycleProjectionService; + this.namespaceRepository = namespaceRepository; } public PageResponse listMySkills(String userId, int page, int size) { - return listMySkills(userId, page, size, null, java.util.Set.of()); + return listMySkills(userId, page, size, null, null, null, java.util.Set.of()); } public PageResponse listMySkills(String userId, @@ -61,10 +66,27 @@ public class MySkillAppService { int size, String filter, java.util.Set platformRoles) { + return listMySkills(userId, page, size, filter, null, null, platformRoles); + } + + public PageResponse listMySkills(String userId, + int page, + int size, + String filter, + String keyword, + String namespace, + java.util.Set platformRoles) { MySkillFilter normalizedFilter = parseFilter(filter); - Page skillPage = normalizedFilter == MySkillFilter.ALL - ? skillRepository.findByOwnerId(userId, PageRequest.of(page, size)) - : filterSkillsByLifecycle(userId, page, size, normalizedFilter, platformRoles); + + Page skillPage; + if (normalizedFilter == MySkillFilter.ALL + && (keyword == null || keyword.isBlank()) + && (namespace == null || namespace.isBlank())) { + skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size)); + } else { + skillPage = filterSkills(userId, page, size, normalizedFilter, keyword, namespace, platformRoles); + } + List items = mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId); return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); @@ -118,15 +140,34 @@ public class MySkillAppService { return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize()); } - private Page filterSkillsByLifecycle(String userId, - int page, - int size, - MySkillFilter filter, - java.util.Set platformRoles) { + private Page filterSkills(String userId, + int page, + int size, + MySkillFilter filter, + String keyword, + String namespace, + java.util.Set platformRoles) { List skills = skillRepository.findByOwnerId(userId); + + // Namespace filter + Long namespaceId = null; + if (namespace != null && !namespace.isBlank()) { + namespaceId = namespaceRepository.findBySlug(namespace.trim()) + .map(Namespace::getId) + .orElse(-1L); + } + + final Long finalNamespaceId = namespaceId; + String normalizedKeyword = keyword != null && !keyword.isBlank() + ? keyword.trim().toLowerCase(java.util.Locale.ROOT) + : null; + List filtered = skills.stream() + .filter(skill -> matchesNamespace(skill, finalNamespaceId)) + .filter(skill -> matchesKeyword(skill, normalizedKeyword)) .filter(skill -> matchesFilter(skill, filter, platformRoles)) .toList(); + int fromIndex = Math.min(page * size, filtered.size()); int toIndex = Math.min(fromIndex + size, filtered.size()); return new PageImpl<>( @@ -136,6 +177,35 @@ public class MySkillAppService { ); } + private boolean matchesNamespace(Skill skill, Long namespaceId) { + if (namespaceId == null) { + return true; + } + if (namespaceId == -1L) { + return false; + } + return skill.getNamespaceId().equals(namespaceId); + } + + private boolean matchesKeyword(Skill skill, String keyword) { + if (keyword == null) { + return true; + } + String displayName = skill.getDisplayName() != null ? skill.getDisplayName().toLowerCase(java.util.Locale.ROOT) : ""; + String slug = skill.getSlug() != null ? skill.getSlug().toLowerCase(java.util.Locale.ROOT) : ""; + String summary = skill.getSummary() != null ? skill.getSummary().toLowerCase(java.util.Locale.ROOT) : ""; + + return displayName.contains(keyword) || slug.contains(keyword) || summary.contains(keyword); + } + + private Page filterSkillsByLifecycle(String userId, + int page, + int size, + MySkillFilter filter, + java.util.Set platformRoles) { + return filterSkills(userId, page, size, filter, null, null, platformRoles); + } + private boolean matchesFilter(Skill skill, MySkillFilter filter, java.util.Set platformRoles) { if (filter == MySkillFilter.HIDDEN) { return platformRoles.contains("SUPER_ADMIN") && skill.isHidden(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index af16f4dd..e4e7b80b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -56,7 +56,7 @@ class MeControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); - given(mySkillAppService.listMySkills("user-42", 1, 5, null, Set.of("USER"))) + given(mySkillAppService.listMySkills("user-42", 1, 5, null, null, null, Set.of("USER"))) .willReturn(new PageResponse<>( List.of(new SkillSummaryResponse( 7L, @@ -103,7 +103,7 @@ class MeControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) ); - given(mySkillAppService.listMySkills("user-42", 0, 10, "HIDDEN", Set.of("SUPER_ADMIN"))) + given(mySkillAppService.listMySkills("user-42", 0, 10, "HIDDEN", null, null, Set.of("SUPER_ADMIN"))) .willReturn(new PageResponse<>(List.of(), 0, 0, 10)); mockMvc.perform(get("/api/v1/me/skills") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index c4faf136..7c8bbb3e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -75,7 +75,8 @@ class MySkillAppServiceTest { skillStarRepository, skillSubscriptionRepository, mySkillQueryRepository, - skillLifecycleProjectionService + skillLifecycleProjectionService, + namespaceRepository ); } @@ -265,6 +266,104 @@ class MySkillAppServiceTest { assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("REJECTED"); } + @Test + void listMySkills_hidesStaleRejectedVersionOlderThanPublished() { + Skill skill = createSkill(6L, 101L, "recovered-skill", "user-1"); + SkillVersion rejectedVersion = createVersion(6L, 60L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z"); + SkillVersion publishedVersion = createVersion(6L, 61L, "2.0.0", SkillVersionStatus.PUBLISHED, "2026-03-16T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); + given(skillVersionRepository.findBySkillIdAndStatus(6L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); + given(skillVersionRepository.findBySkillId(6L)).willReturn(List.of(rejectedVersion, publishedVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("PUBLISHED"); + assertThat(result.items().get(0).headlineVersion().version()).isEqualTo("2.0.0"); + assertThat(result.items().get(0).ownerPreviewVersion()).isNull(); + } + + @Test + void listMySkills_filtersByKeywordAcrossDisplayNameSlugAndSummary() { + Skill alpha = createSkill(1L, 101L, "alpha-tool", "user-1"); + alpha.setDisplayName("Alpha Assistant"); + Skill beta = createSkill(2L, 101L, "beta-tool", "user-1"); + beta.setDisplayName("Beta Tool"); + beta.setSummary("This tool helps with alpha testing"); + Skill gamma = createSkill(3L, 101L, "gamma-tool", "user-1"); + gamma.setDisplayName("Gamma Service"); + SkillVersion publishedVersion = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(alpha, beta, gamma)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(publishedVersion)); + given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of()); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, null, "alpha", null, Set.of("USER")); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.items()).extracting("slug") + .containsExactlyInAnyOrder("alpha-tool", "beta-tool"); + } + + @Test + void listMySkills_filtersByNamespaceSlug() { + Skill aiSkill = createSkill(1L, 101L, "ai-tool", "user-1"); + Skill mlSkill = createSkill(2L, 102L, "ml-tool", "user-1"); + SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiSkill, mlSkill)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1)); + given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai"))); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, null, null, "team-ai", Set.of("USER")); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.items()).extracting("slug").containsExactly("ai-tool"); + } + + @Test + void listMySkills_returnsEmptyWhenNamespaceSlugNotFound() { + Skill skill = createSkill(1L, 101L, "ai-tool", "user-1"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill)); + given(namespaceRepository.findBySlug("missing-namespace")).willReturn(java.util.Optional.empty()); + + var result = service.listMySkills("user-1", 0, 10, null, null, "missing-namespace", Set.of("USER")); + + assertThat(result.total()).isZero(); + assertThat(result.items()).isEmpty(); + } + + @Test + void listMySkills_combinesKeywordNamespaceAndStatusFilters() { + Skill aiAlpha = createSkill(1L, 101L, "ai-alpha", "user-1"); + aiAlpha.setDisplayName("AI Alpha"); + Skill aiBeta = createSkill(2L, 101L, "ai-beta", "user-1"); + aiBeta.setDisplayName("AI Beta"); + Skill mlAlpha = createSkill(3L, 102L, "ml-alpha", "user-1"); + mlAlpha.setDisplayName("ML Alpha"); + SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + SkillVersion v2 = createVersion(2L, 20L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z"); + SkillVersion v3 = createVersion(3L, 30L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiAlpha, aiBeta, mlAlpha)); + given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(v1)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1)); + given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai"))); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, "PUBLISHED", "alpha", "team-ai", Set.of("USER")); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.items()).extracting("slug").containsExactly("ai-alpha"); + } + + private Skill createSkill(Long id, Long namespaceId, String slug, String ownerId) { Skill skill = new Skill(namespaceId, slug, ownerId, SkillVisibility.PUBLIC); skill.setDisplayName(slug); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 711ff60d..368ae850 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -39,6 +39,10 @@ public class SkillLifecycleProjectionService { ResolutionMode resolutionMode ) {} + private static final Comparator RECENCY = Comparator + .comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())); + private final SkillVersionRepository skillVersionRepository; public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository) { @@ -46,22 +50,26 @@ public class SkillLifecycleProjectionService { } public Projection projectForViewer(Skill skill, String currentUserId, Map userNsRoles) { - VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill)); - VersionProjection ownerPreviewVersion = toProjection(resolveOwnerPendingPreview(skill, currentUserId, userNsRoles)); - VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion; - ResolutionMode resolutionMode = headlineVersion == null - ? ResolutionMode.NONE - : publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW; - return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode); + SkillVersion published = resolvePublishedVersion(skill); + SkillVersion preview = canManage(skill, currentUserId, userNsRoles) + ? resolveNewerNonPublishedVersion(skill, published) + : null; + return buildProjection(published, preview); } public Projection projectForOwnerSummary(Skill skill) { - VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill)); - VersionProjection ownerPreviewVersion = toProjection(resolveNewestNonPublishedVersion(skill)); + SkillVersion published = resolvePublishedVersion(skill); + SkillVersion preview = resolveNewerNonPublishedVersion(skill, published); + return buildProjection(published, preview); + } + + private Projection buildProjection(SkillVersion published, SkillVersion preview) { + VersionProjection publishedVersion = toProjection(published); + VersionProjection ownerPreviewVersion = toProjection(preview); VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion; - ResolutionMode resolutionMode = headlineVersion == null - ? ResolutionMode.NONE - : publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW; + ResolutionMode resolutionMode = headlineVersion == null ? ResolutionMode.NONE + : publishedVersion != null ? ResolutionMode.PUBLISHED + : ResolutionMode.OWNER_PREVIEW; return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode); } @@ -116,27 +124,19 @@ public class SkillLifecycleProjectionService { } /** - * Returns the newest non-published version the owner can preview. - * Includes PENDING_REVIEW, REJECTED, DRAFT, SCANNING, SCAN_FAILED — any status - * that isn't already covered by the published projection and isn't yanked. + * Returns the newest non-published version (PENDING_REVIEW, REJECTED, DRAFT, SCANNING, + * SCAN_FAILED) that represents a NEW round of work layered on top of the current published + * version. A non-published version that is older than the published version is treated as + * settled history (e.g. an early rejected attempt later superseded by a published release) + * and is intentionally not surfaced, so the owner does not see a stale preview/rejected badge + * next to an already-published skill. */ - private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId, Map userNsRoles) { - if (!canManage(skill, currentUserId, userNsRoles)) { - return null; - } + private SkillVersion resolveNewerNonPublishedVersion(Skill skill, SkillVersion publishedVersion) { return skillVersionRepository.findBySkillId(skill.getId()).stream() - .filter(v -> v.getStatus() != SkillVersionStatus.PUBLISHED - && v.getStatus() != SkillVersionStatus.YANKED) - .max(versionComparator()) - .orElse(null); - } - - private SkillVersion resolveNewestNonPublishedVersion(Skill skill) { - List versions = skillVersionRepository.findBySkillId(skill.getId()); - return versions.stream() .filter(version -> version.getStatus() != SkillVersionStatus.PUBLISHED && version.getStatus() != SkillVersionStatus.YANKED) - .max(versionComparator()) + .filter(version -> publishedVersion == null || RECENCY.compare(version, publishedVersion) > 0) + .max(RECENCY) .orElse(null); } diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts index 56ba8885..568deff6 100644 --- a/web/e2e/public-skill-detail-anonymous.spec.ts +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -36,7 +36,7 @@ test.describe('Public Skill Detail Anonymous Access (Real API)', () => { await card.click() - await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}$`)) + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}(\\?|$)`)) await expect(page).not.toHaveURL(/\/login\?returnTo=/) await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() await expect(page.getByText('Install', { exact: true })).toBeVisible() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56a..16d2e7fe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1024,13 +1024,19 @@ export const governanceApi = { } export const meApi = { - async getSkills(params?: { page?: number; size?: number; filter?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { + async getSkills(params?: { page?: number; size?: number; filter?: string; q?: string; namespace?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { const searchParams = new URLSearchParams() searchParams.set('page', String(params?.page ?? 0)) searchParams.set('size', String(params?.size ?? 10)) if (params?.filter) { searchParams.set('filter', params.filter) } + if (params?.q) { + searchParams.set('q', params.q) + } + if (params?.namespace) { + searchParams.set('namespace', params.namespace) + } return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`) }, diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 8cabf0b3..b053ca79 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -253,6 +253,12 @@ const dashboardSkillsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/skills', beforeLoad: requireAuth, + validateSearch: (search: Record): { page?: number; q?: string; namespace?: string; filter?: string } => ({ + page: typeof search.page === 'number' ? search.page : undefined, + q: typeof search.q === 'string' && search.q ? search.q : undefined, + namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined, + filter: typeof search.filter === 'string' && search.filter ? search.filter : undefined, + }), component: MySkillsPage, }) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3ff964a4..9c408cae 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -339,6 +339,12 @@ "mySkills": { "title": "My Skills", "subtitle": "Manage your published skills", + "searchPlaceholder": "Search by name, slug, or description", + "namespaceFilterLabel": "Filter by namespace", + "namespaceFilterAll": "All namespaces", + "clearSearch": "Clear filters", + "emptySearchTitle": "No matching skills", + "emptySearchDescription": "Try adjusting your keyword or switching namespace.", "filters": { "ALL": "All", "PENDING_REVIEW": "Pending Review", @@ -1277,7 +1283,8 @@ "prev": "Previous", "next": "Next", "pagePrefix": "Page", - "pageSuffix": "" + "pageSuffix": "", + "goToPage": "Go to page {{page}}" }, "user": { "menu": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 85cdc73b..b2940eda 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -339,6 +339,12 @@ "mySkills": { "title": "我的技能", "subtitle": "管理你发布的技能", + "searchPlaceholder": "搜索技能名称、Slug 或描述", + "namespaceFilterLabel": "按命名空间过滤", + "namespaceFilterAll": "全部命名空间", + "clearSearch": "清除筛选", + "emptySearchTitle": "未找到匹配的技能", + "emptySearchDescription": "试试调整关键字或切换命名空间", "filters": { "ALL": "全部", "PENDING_REVIEW": "待审核", @@ -1278,7 +1284,8 @@ "prev": "上一页", "next": "下一页", "pagePrefix": "第", - "pageSuffix": "页" + "pageSuffix": "页", + "goToPage": "第 {{page}} 页" }, "user": { "menu": { diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index f2c58112..c82faf93 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -8,6 +8,8 @@ const useMySkillsMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, + useLocation: () => ({ pathname: '/dashboard/skills' }), + useSearch: () => ({}), })) vi.mock('react-i18next', async () => { @@ -69,6 +71,14 @@ vi.mock('@/shared/hooks/use-user-queries', () => ({ useSubmitPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), })) +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespaces: () => ({ data: [] }), +})) + +vi.mock('@/shared/hooks/use-debounce', () => ({ + useDebounce: (value: string) => value, +})) + vi.mock('@/shared/lib/skill-lifecycle', () => ({ getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 78192c32..0d90f928 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -1,22 +1,28 @@ -import { useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries' +import { useDebounce } from '@/shared/hooks/use-debounce' import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' +import { buildReturnTo } from '@/shared/lib/auth-route' import { ApiError } from '@/api/client' import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters' const PAGE_SIZE = 10 +const ALL_NAMESPACES_VALUE = '__all_namespaces__' /** * Dashboard page for skills owned by the current user. @@ -36,18 +42,61 @@ function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' export function MySkillsPage() { const navigate = useNavigate() + const location = useLocation() + const search = useSearch({ from: '/dashboard/skills' }) const { t } = useTranslation() const { hasRole } = useAuth() - const [page, setPage] = useState(0) - const [filter, setFilter] = useState('ALL') + + // The URL is the source of truth for page / filter / namespace / keyword so the + // search context survives navigating into a skill and back via the returnTo link. + const page = search.page ?? 0 + const filter = (search.filter as MySkillFilter) ?? 'ALL' + const namespaceFilter = search.namespace ?? '' + const keyword = search.q ?? '' + + // Keep an instant-feedback copy of the keyword input, debounced before it is + // pushed to the URL so each keystroke does not create a history entry or query. + const [keywordInput, setKeywordInput] = useState(keyword) + const debouncedKeyword = useDebounce(keywordInput.trim(), 300) + const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) - const { data: skillPage, isLoading } = useMySkills({ page, size: PAGE_SIZE, filter: filter === 'ALL' ? undefined : filter }) + + const updateSearch = (next: Partial, options?: { replace?: boolean }) => { + navigate({ + to: '/dashboard/skills', + search: (prev) => ({ ...prev, ...next }), + replace: options?.replace, + }) + } + + // Push the debounced keyword to the URL (reset page to 0 when search changes) + useEffect(() => { + if (debouncedKeyword !== keyword) { + updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true }) + } + }, [debouncedKeyword]) + + // Sync keywordInput when navigating back via returnTo + useEffect(() => { + setKeywordInput(keyword) + }, [keyword]) + + const { data: skillPage, isLoading } = useMySkills({ + page, + size: PAGE_SIZE, + filter: filter === 'ALL' ? undefined : filter, + q: keyword || undefined, + namespace: namespaceFilter || undefined, + }) + const { data: namespaceOptions } = useMyNamespaces() + const skills = skillPage?.items ?? [] const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1 const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN')) + const hasActiveSearch = keyword.trim() !== '' || namespaceFilter !== '' const emptyStateKey = getMySkillEmptyStateKey(filter) const archiveMutation = useArchiveSkill() const unarchiveMutation = useUnarchiveSkill() @@ -57,10 +106,15 @@ export function MySkillsPage() { const handleSkillClick = (namespace: string, slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, - search: { returnTo: '/dashboard/skills' }, + search: { returnTo: buildReturnTo(location) }, }) } + const handleClearSearch = () => { + setKeywordInput('') + updateSearch({ q: undefined, namespace: undefined, page: 0 }) + } + const handleUpdateSkill = (namespace: string, visibility?: string) => { navigate({ to: '/dashboard/publish', @@ -238,21 +292,61 @@ export function MySkillsPage() { )} /> -
- {availableFilters.map((option) => ( - + ) : null} +
+ +
+ {availableFilters.map((option) => ( + + ))} +
{skillPage && skillPage.total > 0 ? ( @@ -400,17 +494,23 @@ export function MySkillsPage() { {skillPage.total > PAGE_SIZE ? ( - + updateSearch({ page: next })} /> ) : null} ) : ( navigate({ to: '/dashboard/publish' })}> - {t('mySkills.publishSkill')} - + hasActiveSearch ? ( + + ) : ( + + ) } /> )} diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 58c421db..a0865909 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -186,7 +186,7 @@ export function SearchPage() { } const handleSkillClick = (namespace: string, slug: string) => { - navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) + navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, search: { returnTo: `${window.location.pathname}${window.location.search}` } }) } const filteredStarredSkills = starredOnly diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 086f85a0..43c05b7f 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -172,7 +172,6 @@ export function SkillDetailPage() { && ['PENDING_REVIEW', 'SCANNING', 'SCAN_FAILED'].includes(headlineVersion?.status ?? '') const hasPendingOwnerPreview = ownerPreviewVersion?.status === 'PENDING_REVIEW' const hasRejectedOwnerPreview = ownerPreviewVersion?.status === 'REJECTED' - const hasRejectedVersion = versions?.some((v) => v.status === 'REJECTED') ?? false const hasPublishedPendingReview = Boolean(publishedVersion && hasPendingOwnerPreview) const canInteract = skill?.canInteract ?? true const canReport = skill?.canReport ?? true @@ -762,7 +761,7 @@ export function SkillDetailPage() { {t('skillDetail.versionStatusPendingReview')} )} - {!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview || hasRejectedVersion) && skill.canManageLifecycle && ( + {!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && ( {t('skillDetail.rejectedBadge')} diff --git a/web/src/shared/components/pagination.tsx b/web/src/shared/components/pagination.tsx index 5f54d904..069dbd85 100644 --- a/web/src/shared/components/pagination.tsx +++ b/web/src/shared/components/pagination.tsx @@ -7,8 +7,43 @@ interface PaginationProps { onPageChange: (page: number) => void } +type PageItem = number | 'ellipsis' + +/** + * Builds the list of page slots to render. Always shows the first and last page, + * the current page, and one neighbour on each side, collapsing the rest into + * ellipsis markers. Pages are 0-indexed internally; labels are 1-indexed. + */ +function buildPageItems(current: number, totalPages: number): PageItem[] { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i) + } + + const items: PageItem[] = [] + const first = 0 + const last = totalPages - 1 + const start = Math.max(first + 1, current - 1) + const end = Math.min(last - 1, current + 1) + + items.push(first) + if (start > first + 1) { + items.push('ellipsis') + } + for (let i = start; i <= end; i += 1) { + items.push(i) + } + if (end < last - 1) { + items.push('ellipsis') + } + items.push(last) + + return items +} + export function Pagination({ page, totalPages, onPageChange }: PaginationProps) { const { t } = useTranslation() + const pageItems = buildPageItems(page, totalPages) + return (
-
- {t('pagination.pagePrefix')} - {page + 1} - / - {totalPages} - {t('pagination.pageSuffix') && {t('pagination.pageSuffix')}} + +
+ {pageItems.map((item, index) => + item === 'ellipsis' ? ( + + ) : ( + + ), + )}
+
) } + +export function InstallCommand({ namespace, slug }: InstallCommandProps) { + const { t } = useTranslation() + const baseUrl = useMemo(() => getBaseUrl(), []) + const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug]) + const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug]) + + return ( + + + + {t('skillDetail.installMethodClawhub')} + + + {t('skillDetail.installMethodSkillhub')} + + + + + + + + + + ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 9c408cae..ea5e049d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -807,6 +807,8 @@ "namespaceLabel": "Namespace", "loginToRate": "Login to star and rate", "install": "Install", + "installMethodClawhub": "ClawHub CLI", + "installMethodSkillhub": "SkillHub CLI", "download": "Download", "labelsSectionTitle": "Labels", "labelsSectionDescription": "Attach or remove recommended labels that help users filter and discover this skill.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b2940eda..b8f0cd7a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -807,6 +807,8 @@ "namespaceLabel": "命名空间", "loginToRate": "登录后可以收藏和评分", "install": "安装", + "installMethodClawhub": "ClawHub CLI", + "installMethodSkillhub": "SkillHub CLI", "download": "下载", "labelsSectionTitle": "标签管理", "labelsSectionDescription": "为这个技能挂载或移除推荐标签,帮助用户筛选和发现。", diff --git a/web/src/shared/ui/tabs.test.ts b/web/src/shared/ui/tabs.test.ts index cc3d1909..9f54f4a9 100644 --- a/web/src/shared/ui/tabs.test.ts +++ b/web/src/shared/ui/tabs.test.ts @@ -1,3 +1,5 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs' @@ -28,4 +30,29 @@ describe('Tabs components', () => { expect(typeof TabsContent).toBe('function') expect(TabsContent.name).toBe('TabsContent') }) + + it('renders semantic tablist, tab, and tabpanel roles', () => { + const html = renderToStaticMarkup( + createElement(Tabs, { + defaultValue: 'clawhub', + children: [ + createElement(TabsList, { + key: 'list', + children: [ + createElement(TabsTrigger, { key: 'clawhub', value: 'clawhub', children: 'ClawHub CLI' }), + createElement(TabsTrigger, { key: 'skillhub', value: 'skillhub', children: 'SkillHub CLI' }), + ], + }), + createElement(TabsContent, { key: 'clawhub-content', value: 'clawhub', children: 'clawhub command' }), + createElement(TabsContent, { key: 'skillhub-content', value: 'skillhub', children: 'skillhub command' }), + ], + }), + ) + + expect(html).toContain('role="tablist"') + expect(html).toContain('role="tab"') + expect(html).toContain('aria-selected="true"') + expect(html).toContain('aria-selected="false"') + expect(html).toContain('role="tabpanel"') + }) }) diff --git a/web/src/shared/ui/tabs.tsx b/web/src/shared/ui/tabs.tsx index 70984e9e..05a40f31 100644 --- a/web/src/shared/ui/tabs.tsx +++ b/web/src/shared/ui/tabs.tsx @@ -43,6 +43,7 @@ interface TabsListProps { export function TabsList({ children, className }: TabsListProps) { return (
context.setValue(value)} data-state={isActive ? 'active' : 'inactive'} className={cn( @@ -96,5 +99,5 @@ export function TabsContent({ value, children, className }: TabsContentProps) { if (context.value !== value) return null - return
{children}
+ return
{children}
} From 6e094f4199f61b6e3b5cc886b58c6c81c9682aa3 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 10:46:30 +0800 Subject: [PATCH 14/78] fix(skill): cap namespace bundle downloads Signed-off-by: dongmucat <1127093059@qq.com> --- .../src/main/resources/messages.properties | 3 + .../src/main/resources/messages_zh.properties | 3 + .../skill/service/SkillDownloadService.java | 76 ++++++- .../service/SkillDownloadServiceTest.java | 152 ++++++++++++++ web/src/api/generated/schema.d.ts | 195 ++++++++++++++++-- 5 files changed, 409 insertions(+), 20 deletions(-) diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 32a072e6..cbe51214 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -144,6 +144,9 @@ error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED statu error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish error.skill.version.notDownloadable=Version ''{0}'' is not available for download error.namespace.skills.download.empty=No downloadable skills found in namespace ''{0}'' +error.namespace.skills.download.selectionRequired=Anonymous namespace bundle downloads require explicit skill selection +error.namespace.skills.download.tooMany=Namespace bundle download supports up to {0} skills at a time +error.namespace.skills.download.tooLarge=Namespace bundle download supports up to {0} bytes at a time # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 057c2f03..55c35ce8 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -144,6 +144,9 @@ error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无 error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 error.skill.version.notDownloadable=版本"{0}"不可下载 error.namespace.skills.download.empty=命名空间“{0}”下没有可下载的技能 +error.namespace.skills.download.selectionRequired=匿名命名空间批量下载需要显式选择技能 +error.namespace.skills.download.tooMany=命名空间批量下载一次最多支持 {0} 个技能 +error.namespace.skills.download.tooLarge=命名空间批量下载一次最多支持 {0} 字节 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index aed9a91b..05b32944 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -37,6 +37,8 @@ import java.util.zip.ZipOutputStream; @Service public class SkillDownloadService { private static final Logger log = LoggerFactory.getLogger(SkillDownloadService.class); + private static final int MAX_NAMESPACE_BUNDLE_SKILL_COUNT = 20; + private static final long MAX_NAMESPACE_BUNDLE_TOTAL_BYTES = 100L * 1024 * 1024; private final NamespaceRepository namespaceRepository; private final SkillRepository skillRepository; @@ -182,18 +184,56 @@ public class SkillDownloadService { .map(slug -> slug.trim().replaceFirst("^@", "")) .toList()); - List entries = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) + if (currentUserId == null && selected.isEmpty()) { + throw new DomainBadRequestException("error.namespace.skills.download.selectionRequired"); + } + + List candidates = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) .stream() .filter(skill -> selected.isEmpty() || selected.contains(skill.getSlug())) .sorted(Comparator.comparing(Skill::getSlug)) - .map(skill -> toNamespaceBundleEntry(namespace, skill, currentUserId, userNsRoles)) + .map(skill -> toNamespaceBundleCandidate(namespace, skill, currentUserId, userNsRoles)) .flatMap(java.util.Optional::stream) .toList(); - if (entries.isEmpty()) { + if (candidates.isEmpty()) { throw new DomainBadRequestException("error.namespace.skills.download.empty", namespaceSlug); } + if (candidates.size() > MAX_NAMESPACE_BUNDLE_SKILL_COUNT) { + throw new DomainBadRequestException( + "error.namespace.skills.download.tooMany", + MAX_NAMESPACE_BUNDLE_SKILL_COUNT + ); + } + + long estimatedBundleBytes = candidates.stream() + .mapToLong(this::estimateNamespaceBundleEntryBytes) + .sum(); + if (estimatedBundleBytes > MAX_NAMESPACE_BUNDLE_TOTAL_BYTES) { + throw new DomainBadRequestException( + "error.namespace.skills.download.tooLarge", + MAX_NAMESPACE_BUNDLE_TOTAL_BYTES + ); + } + + List entries = candidates.stream() + .map(candidate -> new NamespaceBundleEntry( + candidate.skill(), + candidate.version(), + buildDownloadResult(candidate.skill(), candidate.version()))) + .toList(); + + long totalBundleBytes = entries.stream() + .mapToLong(entry -> entry.downloadResult().contentLength()) + .sum(); + if (totalBundleBytes > MAX_NAMESPACE_BUNDLE_TOTAL_BYTES) { + throw new DomainBadRequestException( + "error.namespace.skills.download.tooLarge", + MAX_NAMESPACE_BUNDLE_TOTAL_BYTES + ); + } + byte[] bundle = createNamespaceBundle(namespace.getSlug(), entries); entries.forEach(entry -> recordPublishedDownload(entry.skill(), entry.version())); @@ -207,6 +247,23 @@ public class SkillDownloadService { ); } + private long estimateNamespaceBundleEntryBytes(NamespaceBundleCandidate candidate) { + String storageKey = buildBundleStorageKey(candidate.skill(), candidate.version()); + if (objectStorageService.exists(storageKey)) { + return objectStorageService.getMetadata(storageKey).size(); + } + + List files = skillFileRepository.findByVersionId(candidate.version().getId()).stream() + .filter(file -> objectStorageService.exists(file.getStorageKey())) + .toList(); + if (files.isEmpty()) { + throw new DomainBadRequestException("error.skill.bundle.notFound"); + } + return files.stream() + .mapToLong(file -> file.getFileSize() != null ? file.getFileSize() : 0L) + .sum(); + } + private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); assertDownloadableVersion(skill, version); @@ -219,7 +276,7 @@ public class SkillDownloadService { return result; } - private java.util.Optional toNamespaceBundleEntry( + private java.util.Optional toNamespaceBundleCandidate( Namespace namespace, Skill skill, String currentUserId, @@ -235,7 +292,7 @@ public class SkillDownloadService { if (version.getStatus() != SkillVersionStatus.PUBLISHED) { return java.util.Optional.empty(); } - return java.util.Optional.of(new NamespaceBundleEntry(skill, version, buildDownloadResult(skill, version))); + return java.util.Optional.of(new NamespaceBundleCandidate(skill, version)); } private boolean canIncludeInNamespaceBundle( @@ -276,9 +333,12 @@ public class SkillDownloadService { private record NamespaceBundleEntry(Skill skill, SkillVersion version, DownloadResult downloadResult) { } + private record NamespaceBundleCandidate(Skill skill, SkillVersion version) { + } + private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { - String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + String storageKey = buildBundleStorageKey(skill, version); DownloadResult result; if (objectStorageService.exists(storageKey)) { @@ -305,6 +365,10 @@ public class SkillDownloadService { return result; } + private String buildBundleStorageKey(Skill skill, SkillVersion version) { + return String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + } + private DownloadResult buildBundleFromFiles(Skill skill, SkillVersion version) { List files = skillFileRepository.findByVersionId(version.getId()).stream() .filter(file -> objectStorageService.exists(file.getStorageKey())) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 22be0a3f..de2a8815 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -22,6 +22,7 @@ import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.lang.reflect.Field; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -482,6 +483,157 @@ class SkillDownloadServiceTest { verify(eventPublisher, times(1)).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadNamespaceBundle_RejectsAnonymousAllSkillsRequest() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setId(namespace, 1L); + namespace.setType(NamespaceType.GLOBAL); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadNamespaceBundle("global", List.of(), null, Map.of())); + + assertEquals("error.namespace.skills.download.selectionRequired", ex.getMessage()); + verifyNoInteractions(objectStorageService); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadNamespaceBundle_RejectsTooManyEligibleSkills() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + List skills = new ArrayList<>(); + for (int i = 1; i <= 21; i++) { + Skill skill = new Skill(2L, "skill-" + i, "owner-1", SkillVisibility.PUBLIC); + setId(skill, (long) i); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(100L + i); + skills.add(skill); + + SkillVersion version = new SkillVersion((long) i, "1.0.0", "owner-1"); + setId(version, 100L + i); + version.setStatus(SkillVersionStatus.PUBLISHED); + when(visibilityChecker.canAccess(skill, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(skillVersionRepository.findById(100L + i)).thenReturn(Optional.of(version)); + } + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(skills); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); + + assertEquals("error.namespace.skills.download.tooMany", ex.getMessage()); + verifyNoInteractions(objectStorageService); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadNamespaceBundle_RejectsOversizedAggregateBundle() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); + setId(alpha, 11L); + alpha.setDisplayName("Alpha Skill"); + alpha.setStatus(SkillStatus.ACTIVE); + alpha.setLatestVersionId(101L); + + Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); + setId(beta, 12L); + beta.setDisplayName("Beta Skill"); + beta.setStatus(SkillStatus.ACTIVE); + beta.setLatestVersionId(102L); + + SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(alphaVersion, 101L); + alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion betaVersion = new SkillVersion(12L, "1.0.0", "owner-1"); + setId(betaVersion, 102L); + betaVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); + when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); + when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); + when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(true); + when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(true); + when(objectStorageService.getMetadata("packages/11/101/bundle.zip")) + .thenReturn(new ObjectMetadata(60L * 1024 * 1024, "application/zip", Instant.now())); + when(objectStorageService.getMetadata("packages/12/102/bundle.zip")) + .thenReturn(new ObjectMetadata(60L * 1024 * 1024, "application/zip", Instant.now())); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); + + assertEquals("error.namespace.skills.download.tooLarge", ex.getMessage()); + verify(objectStorageService, never()).getObject(anyString()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadNamespaceBundle_RejectsOversizedFallbackBundleBeforeReadingFiles() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); + setId(alpha, 11L); + alpha.setDisplayName("Alpha Skill"); + alpha.setStatus(SkillStatus.ACTIVE); + alpha.setLatestVersionId(101L); + + Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); + setId(beta, 12L); + beta.setDisplayName("Beta Skill"); + beta.setStatus(SkillStatus.ACTIVE); + beta.setLatestVersionId(102L); + + SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(alphaVersion, 101L); + alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion betaVersion = new SkillVersion(12L, "1.0.0", "owner-1"); + setId(betaVersion, 102L); + betaVersion.setStatus(SkillVersionStatus.PUBLISHED); + + SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 60L * 1024 * 1024, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); + SkillFile betaFile = new SkillFile(102L, "README.md", 60L * 1024 * 1024, "text/markdown", "hash-b", "skills/12/102/README.md"); + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); + when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); + when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); + when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); + when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); + when(skillFileRepository.findByVersionId(102L)).thenReturn(List.of(betaFile)); + when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); + when(objectStorageService.exists("skills/12/102/README.md")).thenReturn(true); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); + + assertEquals("error.namespace.skills.download.tooLarge", ex.getMessage()); + verify(objectStorageService, never()).getObject(anyString()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5f0a970a..4e9631dc 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -916,6 +916,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/namespaces/{slug}/transfer-ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transferOwnership"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/transfer-ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transferOwnership_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/namespaces/{slug}/restore": { parameters: { query?: never; @@ -2500,6 +2532,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/namespaces/{slug}/skills/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadNamespaceSkills"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/namespaces/{slug}/skills/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadNamespaceSkills_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/namespaces/{slug}/member-candidates": { parameters: { query?: never; @@ -3685,6 +3749,21 @@ export interface components { /** Format: int64 */ targetNamespaceId?: number; }; + TransferOwnershipRequest: { + newOwnerId: string; + }; + ApiResponseMessageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MessageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MessageResponse: { + message?: string; + }; BatchMemberRequest: { members: components["schemas"]["MemberRequest"][]; }; @@ -3781,18 +3860,6 @@ export interface components { AuthorizeRequest: { userCode?: string; }; - ApiResponseMessageResponse: { - /** Format: int32 */ - code?: number; - msg?: string; - data?: components["schemas"]["MessageResponse"]; - /** Format: date-time */ - timestamp?: string; - requestId?: string; - }; - MessageResponse: { - message?: string; - }; SessionBootstrapRequest: { provider: string; }; @@ -3977,8 +4044,8 @@ export interface components { valid?: boolean; errors?: string[]; warnings?: string[]; - resolvedSlug?: string | null; - resolvedVersion?: string | null; + resolvedSlug?: string; + resolvedVersion?: string; }; UpdateProfileRequest: { displayName?: string; @@ -7035,6 +7102,58 @@ export interface operations { }; }; }; + transferOwnership: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + transferOwnership_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; restoreNamespace: { parameters: { query?: never; @@ -9703,6 +9822,54 @@ export interface operations { }; }; }; + downloadNamespaceSkills: { + parameters: { + query?: { + skill?: string[]; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + downloadNamespaceSkills_1: { + parameters: { + query?: { + skill?: string[]; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; searchMemberCandidates: { parameters: { query: { From 4c4a888b01e0547d3675c4b157c5bac7dcc265c1 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 14:01:19 +0800 Subject: [PATCH 15/78] fix(bootstrap): harden built-in skill startup sync Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 29 ++--- .../bootstrap/BuiltinSkillInitializer.java | 70 ++++++++++-- .../V43__user_account_system_account.sql | 19 +++ .../BuiltinSkillInitializerTest.java | 108 ++++++++++-------- .../skillhub/domain/user/UserAccount.java | 10 ++ 5 files changed, 169 insertions(+), 67 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 1ddc054a..a2d537fa 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -140,14 +140,17 @@ skillhub-hello-1.0.0.zip 1. 检查 `skillhub.builtin-skills.enabled` 是否开启。 2. 读取 `classpath:builtin-skills/manifest.json`。 3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。 -4. 确保系统发布者 `builtin-skill-publisher` 存在,并且是 `@global` 的 `OWNER`。 -5. 按 manifest 顺序处理每一个 item。 -6. 下载对应 zip 包。 -7. 解包并校验根目录 `SKILL.md`。 -8. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 -9. 检查是否已存在同名 Skill 或同版本。 -10. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 -11. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。 +4. 确保系统发布者 `builtin-skill-publisher` 存在,并且该账号带有系统账号标记。 +5. 如果该用户 ID 已被非系统账号占用,直接跳过本次内置 Skill 同步,不授予 `@global` 权限。 +6. 确保系统发布者是 `@global` 的 `OWNER`。 +7. 按 manifest 顺序处理每一个 item。 +8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。 +9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。 +10. 解包并校验根目录 `SKILL.md`。 +11. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 +12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。 +13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 +14. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。 同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务,因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。 @@ -167,10 +170,9 @@ skillhub-hello-1.0.0.zip |---|---| | `@global/{slug}` 不存在 | 发布 manifest 中的 Skill | | `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | -| 同版本已存在且已发布,内容一致 | 跳过 | -| 同版本已存在且已发布,但内容不一致 | 跳过并记录 warning | -| 同版本已存在但不是 `PUBLISHED` | 跳过并记录日志 | -| `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 跳过并记录 warning | +| 同版本已存在且已发布 | 下载前跳过 | +| 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 | +| `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 下载前跳过并记录 warning | 这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill。 @@ -238,6 +240,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | 日志含义 | 处理建议 | |---|---| | manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath | +| publisher account id already exists but is not a system account | `builtin-skill-publisher` 已被普通账号占用;需要人工处理账号冲突后再启用内置同步 | | slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 | | slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 | | URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 | @@ -245,7 +248,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | package must contain SKILL.md | 检查 zip 根目录是否存在 `SKILL.md` | | manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | | slug is already owned by another user | 说明 `@global/{slug}` 已被非内置发布者占用,内置同步不会覆盖 | -| published fingerprint differs | 同一内置版本已存在但内容不同,需要人工确认是否错误覆盖了远程包 | +| published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | 如果某个 manifest item 失败,后续 item 仍会继续处理,应用启动也会继续。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 7ff69a68..63ae3981 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -111,7 +111,9 @@ public class BuiltinSkillInitializer implements ApplicationRunner { } try { - ensureSystemPublisher(namespace.get()); + if (!ensureSystemPublisher(namespace.get())) { + return; + } } catch (RuntimeException exception) { log.error("Failed to initialize built-in skill system publisher, skipping synchronization: {}", exception.getMessage(), exception); @@ -133,14 +135,25 @@ public class BuiltinSkillInitializer implements ApplicationRunner { } } - private void ensureSystemPublisher(Namespace namespace) { - userAccountRepository.findById(SYSTEM_PUBLISHER_ID) - .orElseGet(() -> userAccountRepository.save(new UserAccount( - SYSTEM_PUBLISHER_ID, - "Built-in Skill Publisher", - null, - null - ))); + private boolean ensureSystemPublisher(Namespace namespace) { + Optional existingPublisher = userAccountRepository.findById(SYSTEM_PUBLISHER_ID); + UserAccount publisher; + if (existingPublisher.isPresent()) { + publisher = existingPublisher.get(); + } else { + publisher = UserAccount.systemAccount( + SYSTEM_PUBLISHER_ID, + "Built-in Skill Publisher", + null, + null + ); + userAccountRepository.save(publisher); + } + if (!publisher.isSystemAccount()) { + log.error("Built-in skill publisher account id '{}' already exists but is not a system account; " + + "skipping built-in skill synchronization", SYSTEM_PUBLISHER_ID); + return false; + } if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), SYSTEM_PUBLISHER_ID).isEmpty()) { namespaceMemberRepository.save(new NamespaceMember( @@ -149,9 +162,14 @@ public class BuiltinSkillInitializer implements ApplicationRunner { NamespaceRole.OWNER )); } + return true; } private void syncItem(Namespace namespace, ManifestItem item) throws Exception { + if (shouldSkipBeforeDownload(namespace.getId(), item)) { + return; + } + Optional packageBytes = downloader.download(URI.create(item.url())); if (packageBytes.isEmpty()) { log.warn("Skipping built-in skill slug={} version={} because package download failed", @@ -217,6 +235,40 @@ public class BuiltinSkillInitializer implements ApplicationRunner { return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); } + private boolean shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + boolean hasNonBuiltinOwner = existingSkills.stream() + .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); + if (hasNonBuiltinOwner) { + log.warn("Skipping built-in skill slug={} before download because the slug is already owned by another user", + item.slug()); + return true; + } + + Optional builtinSkill = existingSkills.stream() + .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) + .findFirst(); + if (builtinSkill.isEmpty()) { + return false; + } + + Optional existingVersion = skillVersionRepository + .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); + if (existingVersion.isEmpty()) { + return false; + } + + SkillVersion version = existingVersion.get(); + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + log.info("Skipping built-in skill slug={} version={} before download because it is already published", + item.slug(), item.version()); + } else { + log.info("Skipping built-in skill slug={} version={} before download because existing version status is {}", + item.slug(), item.version(), version.getStatus()); + } + return true; + } + private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); boolean hasNonBuiltinOwner = existingSkills.stream() diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql new file mode 100644 index 00000000..c525bfd2 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -0,0 +1,19 @@ +ALTER TABLE user_account + ADD COLUMN system_account BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE user_account +SET system_account = TRUE +WHERE id = 'builtin-skill-publisher' + AND display_name = 'Built-in Skill Publisher' + AND email IS NULL + AND avatar_url IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM local_credential + WHERE local_credential.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM identity_binding + WHERE identity_binding.user_id = user_account.id + ); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 4e3322bb..7afa9cd9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -52,9 +52,9 @@ class BuiltinSkillInitializerTest { private static final String GLOBAL = "global"; private static final String PUBLISHER = "builtin-skill-publisher"; private static final ManifestItem ITEM = new ManifestItem( - "agentguard", + "skillhub-hello", "1.0.0", - "https://bjcdn.openstorage.cn/skills/agentguard.zip" + "https://bjcdn.openstorage.cn/skills/skillhub-hello.zip" ); @Mock private BuiltinSkillManifestLoader manifestLoader; @@ -114,57 +114,64 @@ class BuiltinSkillInitializerTest { } @Test - void skipsExistingSkillOwnedByAnotherUser() throws Exception { - Skill otherSkill = skill(100L, "agentguard", "someone-else"); - givenExtractedPackage(); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(otherSkill)); + void skipsSynchronizationWhenPublisherIdIsOccupiedByNonSystemAccount() { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)) + .thenReturn(Optional.of(new UserAccount(PUBLISHER, "Human User", "human@example.com", null))); initializer.run(new DefaultApplicationArguments(new String[0])); + verify(namespaceMemberRepository, never()).save(any()); + verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @Test - void skipsPublishedSameVersionWhenFingerprintMatches() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + void skipsPublishedSameVersionBeforeDownloadingPackage() { + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); - List entries = packageEntries("agentguard", "1.0.0", "same"); - givenExtractedPackage(entries); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); - when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); initializer.run(new DefaultApplicationArguments(new String[0])); + verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @Test - void skipsPublishedSameVersionWhenFingerprintDiffers() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); - SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); - givenExtractedPackage(); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); - when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); - when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( - new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("changed"), "storage-key") - )); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); - } - - @Test - void skipsExistingSameVersionWhenNotPublished() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + void skipsExistingSameVersionWhenNotPublishedBeforeDownloadingPackage() { + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion uploaded = version(200L, 100L, "1.0.0", SkillVersionStatus.UPLOADED); - givenExtractedPackage(); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(uploaded)); initializer.run(new DefaultApplicationArguments(new String[0])); + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsExistingSkillOwnedByAnotherUserBeforeDownloadingPackage() throws Exception { + Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); + givenManifestAndSystemPublisher(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @@ -179,7 +186,7 @@ class BuiltinSkillInitializerTest { @Test void skipsWhenManifestVersionDoesNotMatchPackageMetadata() throws Exception { - givenExtractedPackage(packageEntries("agentguard", "1.0.1", "same")); + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.1", "same")); initializer.run(new DefaultApplicationArguments(new String[0])); @@ -188,9 +195,9 @@ class BuiltinSkillInitializerTest { @Test void publishesNewVersionToGlobalAsPublicWithSystemPublisher() throws Exception { - List entries = packageEntries("agentguard", "1.0.0", "same"); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); givenExtractedPackage(entries); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); initializer.run(new DefaultApplicationArguments(new String[0])); @@ -208,17 +215,18 @@ class BuiltinSkillInitializerTest { @Test void createsSystemPublisherAndGlobalMembershipBeforePublishing() throws Exception { - List entries = packageEntries("agentguard", "1.0.0", "same"); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); givenExtractedPackage(entries); when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.empty()); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)).thenReturn(Optional.empty()); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); initializer.run(new DefaultApplicationArguments(new String[0])); ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); verify(userAccountRepository).save(userCaptor.capture()); assertThat(userCaptor.getValue().getId()).isEqualTo(PUBLISHER); + assertThat(userCaptor.getValue().isSystemAccount()).isTrue(); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); verify(namespaceMemberRepository).save(memberCaptor.capture()); @@ -229,11 +237,12 @@ class BuiltinSkillInitializerTest { @Test void treatsConcurrentDuplicatePublishedVersionAsCompleted() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); - List entries = packageEntries("agentguard", "1.0.0", "same"); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); givenExtractedPackage(entries); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")) + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) .thenReturn(List.of()) .thenReturn(List.of(builtinSkill)); when(skillPublishService.publishFromEntries( @@ -250,10 +259,11 @@ class BuiltinSkillInitializerTest { @Test void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); - givenExtractedPackage(packageEntries("agentguard", "1.0.0", "new-content")); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")) + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "new-content")); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) .thenReturn(List.of()) .thenReturn(List.of(builtinSkill)); when(skillPublishService.publishFromEntries( @@ -272,7 +282,7 @@ class BuiltinSkillInitializerTest { } private void givenExtractedPackage() throws Exception { - givenExtractedPackage(packageEntries("agentguard", "1.0.0", "same")); + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "same")); } private void givenExtractedPackage(List entries) throws Exception { @@ -286,8 +296,16 @@ class BuiltinSkillInitializerTest { when(extractor.extract(bytes)).thenReturn(new SkillPackageArchiveExtractor.ExtractionResult(entries, List.of())); } + private void givenManifestAndSystemPublisher() { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + } + private static UserAccount systemPublisher() { - return new UserAccount(PUBLISHER, "Built-in Skill Publisher", null, null); + return UserAccount.systemAccount(PUBLISHER, "Built-in Skill Publisher", null, null); } private static Skill skill(Long id, String slug, String ownerId) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java index 0ef2c569..19e6961f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java @@ -27,6 +27,9 @@ public class UserAccount { @Column(name = "merged_to_user_id") private String mergedToUserId; + @Column(name = "system_account", nullable = false) + private boolean systemAccount = false; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -43,6 +46,12 @@ public class UserAccount { this.status = UserStatus.ACTIVE; } + public static UserAccount systemAccount(String id, String displayName, String email, String avatarUrl) { + UserAccount user = new UserAccount(id, displayName, email, avatarUrl); + user.systemAccount = true; + return user; + } + @PrePersist void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); @@ -65,6 +74,7 @@ public class UserAccount { public void setStatus(UserStatus status) { this.status = status; } public String getMergedToUserId() { return mergedToUserId; } public void setMergedToUserId(String mergedToUserId) { this.mergedToUserId = mergedToUserId; } + public boolean isSystemAccount() { return systemAccount; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } public boolean isActive() { return this.status == UserStatus.ACTIVE; } From 5cc934a294494277585568e18c927602229b5610 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 15:20:10 +0800 Subject: [PATCH 16/78] fix(bootstrap): harden builtin skill startup sync Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 9 ++- .../bootstrap/BuiltinSkillInitializer.java | 33 ++++++-- .../skillhub/service/AdminUserAppService.java | 8 ++ .../V43__user_account_system_account.sql | 5 ++ .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../BuiltinSkillInitializerTest.java | 79 +++++++++++++++---- .../BuiltinSkillManifestLoaderTest.java | 8 +- .../BuiltinSkillPackageExtractorTest.java | 10 +-- .../db/FlywayMigrationGuardrailTest.java | 16 ++++ .../service/AdminUserAppServiceTest.java | 35 ++++++++ 11 files changed, 170 insertions(+), 35 deletions(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index a2d537fa..7e5c5eb2 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -15,7 +15,7 @@ 流程: ```text -维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用启动 -> 读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见 +维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用 ready -> 后台读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见 ``` 核心文件: @@ -133,7 +133,7 @@ skillhub-hello-1.0.0.zip ## 5. 启动同步流程 -应用启动时同步器只执行一次。 +应用 ready 后同步器会在后台执行一次,不阻塞应用 ready。 详细流程: @@ -175,6 +175,7 @@ skillhub-hello-1.0.0.zip | `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 下载前跳过并记录 warning | 这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill。 +同版本已存在时,同步器不会重新下载远端 zip,也不会验证远端对象内容是否发生漂移。 如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 @@ -202,7 +203,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=true SKILLHUB_BUILTIN_SKILLS_ENABLED=false ``` -禁用后,应用启动时不会读取 manifest,也不会下载或发布任何内置 Skill。 +禁用后,应用 ready 后不会读取 manifest,也不会下载或发布任何内置 Skill。 ## 8. 维护流程 @@ -250,7 +251,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | slug is already owned by another user | 说明 `@global/{slug}` 已被非内置发布者占用,内置同步不会覆盖 | | published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | -如果某个 manifest item 失败,后续 item 仍会继续处理,应用启动也会继续。 +如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。 ## 10. 验收检查 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 63ae3981..d6626087 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -25,8 +25,9 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import java.net.URI; @@ -42,7 +43,7 @@ import java.util.Set; * Best-effort startup synchronizer for remotely hosted built-in skill packages. */ @Component -public class BuiltinSkillInitializer implements ApplicationRunner { +public class BuiltinSkillInitializer { static final String GLOBAL_NAMESPACE = "global"; static final String SYSTEM_PUBLISHER_ID = "builtin-skill-publisher"; @@ -90,8 +91,13 @@ public class BuiltinSkillInitializer implements ApplicationRunner { this.skillPublishService = skillPublishService; } - @Override - public void run(ApplicationArguments args) { + @EventListener(ApplicationReadyEvent.class) + @Async("skillhubEventExecutor") + public void synchronizeAfterApplicationReady() { + synchronize(); + } + + void synchronize() { if (!properties.isEnabled()) { log.info("Built-in skill startup synchronization is disabled"); return; @@ -170,7 +176,12 @@ public class BuiltinSkillInitializer implements ApplicationRunner { return; } - Optional packageBytes = downloader.download(URI.create(item.url())); + Optional packageUri = parsePackageUri(item); + if (packageUri.isEmpty()) { + return; + } + + Optional packageBytes = downloader.download(packageUri.get()); if (packageBytes.isEmpty()) { log.warn("Skipping built-in skill slug={} version={} because package download failed", item.slug(), item.version()); @@ -226,6 +237,16 @@ public class BuiltinSkillInitializer implements ApplicationRunner { } } + private Optional parsePackageUri(ManifestItem item) { + try { + return Optional.of(URI.create(item.url())); + } catch (IllegalArgumentException exception) { + log.warn("Skipping built-in skill slug={} version={} because URL is not allowed: {}", + item.slug(), item.version(), exception.getMessage()); + return Optional.empty(); + } + } + private SkillMetadata parseSkillMetadata(List entries) { PackageEntry skillMd = entries.stream() .filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index 656cefa0..b684c744 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -81,6 +81,7 @@ public class AdminUserAppService { @Transactional public AdminUserMutationResponse updateUserRole(String userId, String roleCode, Set actorPlatformRoles) { UserAccount user = loadUser(userId); + rejectSystemAccountMutation(user); String normalizedRoleCode = normalizeRoleCode(roleCode); if ("SUPER_ADMIN".equals(normalizedRoleCode) @@ -102,6 +103,7 @@ public class AdminUserAppService { @Transactional public AdminUserMutationResponse updateUserStatus(String userId, String status) { UserAccount user = loadUser(userId); + rejectSystemAccountMutation(user); UserStatus nextStatus = parseManageableStatus(status); user.setStatus(nextStatus); userAccountRepository.save(user); @@ -164,4 +166,10 @@ public class AdminUserAppService { return userAccountRepository.findById(userId) .orElseThrow(() -> new DomainNotFoundException("error.admin.user.notFound", userId)); } + + private void rejectSystemAccountMutation(UserAccount user) { + if (user.isSystemAccount()) { + throw new DomainForbiddenException("error.admin.user.systemAccount.immutable"); + } + } } diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql index c525bfd2..bc3d5caa 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -16,4 +16,9 @@ WHERE id = 'builtin-skill-publisher' SELECT 1 FROM identity_binding WHERE identity_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM api_token + WHERE api_token.user_id = user_account.id ); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index be3e2ebe..2301e0d5 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -134,6 +134,7 @@ error.deviceAuth.deviceCode.used=Device code has already been used error.admin.user.notFound=User not found: {0} 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.systemAccount.immutable=System accounts cannot be modified from user management 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 diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index cef09563..a968cc77 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -134,6 +134,7 @@ error.deviceAuth.deviceCode.used=设备验证码已被使用 error.admin.user.notFound=用户不存在:{0} error.admin.user.role.invalid=无效的角色:{0} error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色 +error.admin.user.systemAccount.immutable=系统账号不能在用户管理中修改 error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 7afa9cd9..1f8d938a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -35,18 +35,24 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.test.util.ReflectionTestUtils; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.lang.reflect.Method; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; import java.util.Optional; import java.util.Set; -@ExtendWith(MockitoExtension.class) +@ExtendWith({MockitoExtension.class, OutputCaptureExtension.class}) class BuiltinSkillInitializerTest { private static final String GLOBAL = "global"; @@ -97,7 +103,7 @@ class BuiltinSkillInitializerTest { void skipsWhenDisabled() { properties.setEnabled(false); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(manifestLoader, never()).load(); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); @@ -107,12 +113,26 @@ class BuiltinSkillInitializerTest { void skipsAllItemsWhenGlobalNamespaceDoesNotExist() { when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.empty()); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(manifestLoader, never()).load(); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } + @Test + void synchronizesAfterApplicationReadyWithoutBlockingApplicationRunner() throws Exception { + assertThat(ApplicationRunner.class.isAssignableFrom(BuiltinSkillInitializer.class)).isFalse(); + + Method method = BuiltinSkillInitializer.class.getDeclaredMethod("synchronizeAfterApplicationReady"); + EventListener eventListener = method.getAnnotation(EventListener.class); + Async async = method.getAnnotation(Async.class); + + assertThat(eventListener).isNotNull(); + assertThat(eventListener.value()).containsExactly(ApplicationReadyEvent.class); + assertThat(async).isNotNull(); + assertThat(async.value()).isEqualTo("skillhubEventExecutor"); + } + @Test void skipsSynchronizationWhenPublisherIdIsOccupiedByNonSystemAccount() { when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); @@ -120,7 +140,7 @@ class BuiltinSkillInitializerTest { when(userAccountRepository.findById(PUBLISHER)) .thenReturn(Optional.of(new UserAccount(PUBLISHER, "Human User", "human@example.com", null))); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(namespaceMemberRepository, never()).save(any()); verify(downloader, never()).download(any()); @@ -139,7 +159,7 @@ class BuiltinSkillInitializerTest { when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); @@ -157,7 +177,7 @@ class BuiltinSkillInitializerTest { when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(uploaded)); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); @@ -169,17 +189,34 @@ class BuiltinSkillInitializerTest { givenManifestAndSystemPublisher(); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } + @Test + void skipsMalformedUrlWithoutSynchronizationFailureLog(CapturedOutput output) { + ManifestItem malformed = new ManifestItem( + "skillhub-hello", + "1.0.0", + "https://bjcdn.openstorage.cn/skills/%zz.zip" + ); + givenManifestAndSystemPublisher(List.of(malformed)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + + runInitializer(); + + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + assertThat(output).doesNotContain("Failed to synchronize built-in skill slug=skillhub-hello"); + } + @Test void skipsWhenManifestSlugDoesNotMatchPackageMetadata() throws Exception { givenExtractedPackage(packageEntries("other-skill", "1.0.0", "same")); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @@ -188,7 +225,7 @@ class BuiltinSkillInitializerTest { void skipsWhenManifestVersionDoesNotMatchPackageMetadata() throws Exception { givenExtractedPackage(packageEntries("skillhub-hello", "1.0.1", "same")); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @@ -199,7 +236,7 @@ class BuiltinSkillInitializerTest { givenExtractedPackage(entries); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); ArgumentCaptor> entriesCaptor = ArgumentCaptor.captor(); verify(skillPublishService).publishFromEntries( @@ -221,7 +258,7 @@ class BuiltinSkillInitializerTest { when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)).thenReturn(Optional.empty()); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); verify(userAccountRepository).save(userCaptor.capture()); @@ -251,14 +288,14 @@ class BuiltinSkillInitializerTest { when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(skillPublishService).publishFromEntries( eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); } @Test - void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted() throws Exception { + void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted(CapturedOutput output) throws Exception { Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "new-content")); @@ -274,11 +311,13 @@ class BuiltinSkillInitializerTest { new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("old-content"), "storage-key") )); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(skillFileRepository).findByVersionId(200L); verify(skillPublishService).publishFromEntries( eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + assertThat(output).contains("Failed to publish built-in skill slug=skillhub-hello version=1.0.0"); + assertThat(output).doesNotContain("was published concurrently, skipping"); } private void givenExtractedPackage() throws Exception { @@ -297,13 +336,21 @@ class BuiltinSkillInitializerTest { } private void givenManifestAndSystemPublisher() { + givenManifestAndSystemPublisher(List.of(ITEM)); + } + + private void givenManifestAndSystemPublisher(List items) { when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); - when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(manifestLoader.load()).thenReturn(items); when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); } + private void runInitializer() { + initializer.synchronize(); + } + private static UserAccount systemPublisher() { return UserAccount.systemAccount(PUBLISHER, "Built-in Skill Publisher", null, null); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java index 39ea4b38..66bc128f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java @@ -17,8 +17,8 @@ class BuiltinSkillManifestLoaderTest { BuiltinSkillManifestLoader loader = loaderWith(""" { "skills": [ - {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/agentguard.zip"}, - {"slug": "agentguard", "version": "1.1.0", "url": "https://cdn.bjcdn.openstorage.cn/agentguard.zip"} + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/skillhub-hello.zip"}, + {"slug": "skillhub-hello", "version": "1.1.0", "url": "https://cdn.bjcdn.openstorage.cn/skillhub-hello.zip"} ] } """); @@ -69,8 +69,8 @@ class BuiltinSkillManifestLoaderTest { BuiltinSkillManifestLoader loader = loaderWith(""" { "skills": [ - {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/first.zip"}, - {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/second.zip"}, + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/first.zip"}, + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/second.zip"}, {"slug": "InvalidUppercase", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/invalid.zip"}, {"slug": "", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/blank.zip"}, {"slug": "missing-version", "url": "https://bjcdn.openstorage.cn/missing-version.zip"}, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java index 99f710b9..1df4203d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -24,10 +24,10 @@ class BuiltinSkillPackageExtractorTest { byte[] zip = zip( entry("SKILL.md", """ --- - name: agentguard + name: skillhub-hello version: 1.0.0 --- - # AgentGuard + # SkillHub Hello """), entry("README.md", "# Readme") ); @@ -50,12 +50,12 @@ class BuiltinSkillPackageExtractorTest { @Test void rejectsZipWithOnlyNestedSkillMd() throws Exception { - byte[] zip = zip(entry("agentguard/SKILL.md", """ + byte[] zip = zip(entry("skillhub-hello/SKILL.md", """ --- - name: agentguard + name: skillhub-hello version: 1.0.0 --- - # AgentGuard + # SkillHub Hello """)); assertThatThrownBy(() -> extractor.extract(zip)) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index ee23aee6..f2b87da5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -72,6 +72,14 @@ class FlywayMigrationGuardrailTest { assertThat(invalidFiles).isEmpty(); } + @Test + void systemAccountMigration_mustNotPromoteUsersWithApiTokens() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("FROM api_token"); + assertThat(migration).contains("api_token.user_id = user_account.id"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") @@ -92,4 +100,12 @@ class FlywayMigrationGuardrailTest { private String relativeToRepo(Path file) { return repoRoot().relativize(file).toString(); } + + private Path migrationPath(String fileName) { + return repoRoot() + .resolve("server") + .resolve("skillhub-app") + .resolve("src/main/resources/db/migration") + .resolve(fileName); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 4dd22e9c..7f2cfc22 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -89,6 +89,18 @@ class AdminUserAppServiceTest { () -> service.updateUserRole("user-1", "SUPER_ADMIN", Set.of("USER_ADMIN"))); } + @Test + void updateUserRole_rejectsSystemAccount() { + when(userAccountRepository.findById("builtin-skill-publisher")) + .thenReturn(Optional.of(systemUser())); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserRole("builtin-skill-publisher", "AUDITOR", Set.of("SUPER_ADMIN"))); + + verify(userRoleBindingRepository, never()).deleteByUserId(any()); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + } + @Test void updateUserRole_replacesExistingBindings() { when(userAccountRepository.findById("user-1")) @@ -137,6 +149,17 @@ class AdminUserAppServiceTest { assertThat(response.status()).isEqualTo("DISABLED"); } + @Test + void updateUserStatus_rejectsSystemAccount() { + when(userAccountRepository.findById("builtin-skill-publisher")) + .thenReturn(Optional.of(systemUser())); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserStatus("builtin-skill-publisher", "DISABLED")); + + verify(userAccountRepository, never()).save(any(UserAccount.class)); + } + @Test void updateUserStatus_withUnknownUser_throwsNotFound() { when(userAccountRepository.findById("missing")).thenReturn(Optional.empty()); @@ -152,6 +175,18 @@ class AdminUserAppServiceTest { return user; } + private UserAccount systemUser() { + UserAccount user = UserAccount.systemAccount( + "builtin-skill-publisher", + "Built-in Skill Publisher", + null, + null + ); + ReflectionTestUtils.setField(user, "createdAt", Instant.parse("2026-03-13T09:00:00Z")); + ReflectionTestUtils.setField(user, "updatedAt", Instant.parse("2026-03-13T09:00:00Z")); + return user; + } + private Role role(String code) { Role role = new Role(); ReflectionTestUtils.setField(role, "code", code); From 7e23508a32e2e30d7e266b0726666e62f0266724 Mon Sep 17 00:00:00 2001 From: Xudong Sun Date: Mon, 8 Jun 2026 17:31:07 +0800 Subject: [PATCH 17/78] chore(logging): include idempotency cleanup threshold --- .../java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java index ea40dfee..c5b320c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java @@ -42,7 +42,7 @@ public class IdempotencyCleanupTask { Instant threshold = Instant.now(clock).minusSeconds(STALE_THRESHOLD_MINUTES * 60); int updated = idempotencyRecordRepository.markStaleAsFailed(threshold); if (updated > 0) { - logger.info("Marked {} stale processing records as failed", updated); + logger.info("Marked {} stale processing records as failed before threshold={}", updated, threshold); } } } From 973c336c82ae66c507acc3019264bcd38392d608 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 18:01:20 +0800 Subject: [PATCH 18/78] fix(auth): protect builtin system account boundaries Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 2 +- .../V43__user_account_system_account.sql | 10 +++++++ .../db/FlywayMigrationGuardrailTest.java | 10 +++++++ .../auth/local/PasswordResetService.java | 3 ++ .../auth/local/PasswordResetServiceTest.java | 28 +++++++++++++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 7e5c5eb2..097a26d0 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -142,7 +142,7 @@ skillhub-hello-1.0.0.zip 3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。 4. 确保系统发布者 `builtin-skill-publisher` 存在,并且该账号带有系统账号标记。 5. 如果该用户 ID 已被非系统账号占用,直接跳过本次内置 Skill 同步,不授予 `@global` 权限。 -6. 确保系统发布者是 `@global` 的 `OWNER`。 +6. 如果系统发布者还不是 `@global` 成员,则创建 `OWNER` 成员记录;已有成员记录不会自动改角色。 7. 按 manifest 顺序处理每一个 item。 8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。 9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。 diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql index bc3d5caa..c96bb9fa 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -21,4 +21,14 @@ WHERE id = 'builtin-skill-publisher' SELECT 1 FROM api_token WHERE api_token.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM user_role_binding + WHERE user_role_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM namespace_member + WHERE namespace_member.user_id = user_account.id ); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index f2b87da5..aad2480f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -80,6 +80,16 @@ class FlywayMigrationGuardrailTest { assertThat(migration).contains("api_token.user_id = user_account.id"); } + @Test + void systemAccountMigration_mustNotPromoteUsersWithRolesOrNamespaceMemberships() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("FROM user_role_binding"); + assertThat(migration).contains("user_role_binding.user_id = user_account.id"); + assertThat(migration).contains("FROM namespace_member"); + assertThat(migration).contains("namespace_member.user_id = user_account.id"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java index 60b52acc..74653ead 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java @@ -178,6 +178,9 @@ public class PasswordResetService { } private boolean isEligibleForReset(UserAccount user) { + if (user.isSystemAccount()) { + return false; + } if (user.getStatus() != UserStatus.ACTIVE) { return false; } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java index aa78c1fb..251b7d54 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.atLeastOnce; @@ -215,4 +216,31 @@ class PasswordResetServiceTest { .extracting("status") .isEqualTo(HttpStatus.BAD_REQUEST); } + + @Test + void adminTriggerPasswordReset_forSystemAccount_throwsBadRequest() { + UserAccount user = UserAccount.systemAccount( + "builtin-skill-publisher", + "Built-in Skill Publisher", + "builtin@example.com", + null + ); + given(userAccountRepository.findById("builtin-skill-publisher")).willReturn(Optional.of(user)); + lenient().when(credentialRepository.findByUserId("builtin-skill-publisher")).thenReturn( + Optional.of(new LocalCredential("builtin-skill-publisher", "builtin", "encoded")) + ); + lenient().when(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).thenReturn(List.of()); + lenient().when(passwordEncoder.encode(anyString())).thenReturn("encoded-value"); + + assertThatThrownBy(() -> service.adminTriggerPasswordReset("builtin-skill-publisher", "admin_1")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(credentialRepository, never()).findByUserId("builtin-skill-publisher"); + verify(resetRequestRepository, never()).save(any(PasswordResetRequest.class)); + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } } From b5edfb850eb8975921e0d21429afc8a0510b3677 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 9 Jun 2026 10:02:26 +0800 Subject: [PATCH 19/78] fix(web): clarify namespace bundle download limits Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/namespace-search-download.spec.ts | 6 ++++-- web/src/i18n/locales/en.json | 5 +++-- web/src/i18n/locales/zh.json | 5 +++-- web/src/pages/namespace.tsx | 22 ++++++++++++++++++---- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/web/e2e/namespace-search-download.spec.ts b/web/e2e/namespace-search-download.spec.ts index 208d02a8..163b44a7 100644 --- a/web/e2e/namespace-search-download.spec.ts +++ b/web/e2e/namespace-search-download.spec.ts @@ -272,7 +272,8 @@ test.describe('Namespace Search and Download', () => { await page.getByRole('button', { name: 'Download selected on this page' }).click() await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() - await expect(page.getByText('This will request 1 skill package from @product-managers.')).toBeVisible() + await expect(page.getByText('This will request up to 1 skill package from @product-managers.')).toBeVisible() + await expect(page.getByText('Unavailable skills may be skipped. Synchronous downloads are limited to 20 skills and 100 MB.')).toBeVisible() const [request, response] = await Promise.all([ page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), @@ -294,7 +295,8 @@ test.describe('Namespace Search and Download', () => { await page.getByRole('button', { name: 'Download all' }).click() await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() - await expect(page.getByText('This will request 2 skill packages from @product-managers.')).toBeVisible() + await expect(page.getByText('This will request up to 2 skill packages from @product-managers.')).toBeVisible() + await expect(page.getByText('Unavailable skills may be skipped. Synchronous downloads are limited to 20 skills and 100 MB.')).toBeVisible() const [request, response] = await Promise.all([ page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1d2ad394..176ba286 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -771,8 +771,9 @@ "downloadSelected": "Download selected on this page", "copyInstallManifest": "Copy current page install list", "downloadConfirmTitle": "Confirm namespace download", - "downloadConfirmDescription_one": "This will request {{count}} skill package from @{{namespace}}.", - "downloadConfirmDescription_other": "This will request {{count}} skill packages from @{{namespace}}.", + "downloadConfirmDescription_one": "This will request up to {{count}} skill package from @{{namespace}}.", + "downloadConfirmDescription_other": "This will request up to {{count}} skill packages from @{{namespace}}.", + "downloadConfirmLimitHint": "Unavailable skills may be skipped. Synchronous downloads are limited to {{maxSkills}} skills and {{maxSize}}.", "downloadConfirmAction": "Download", "selectSkill": "Select {{name}}" }, diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index feb66a8b..583977b7 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -771,8 +771,9 @@ "downloadSelected": "下载本页选中", "copyInstallManifest": "复制本页安装清单", "downloadConfirmTitle": "确认命名空间下载", - "downloadConfirmDescription_one": "将请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", - "downloadConfirmDescription_other": "将请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmDescription_one": "将最多请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmDescription_other": "将最多请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmLimitHint": "不可用的 skill 可能会被跳过;同步下载一次最多支持 {{maxSkills}} 个 skill、{{maxSize}}。", "downloadConfirmAction": "下载", "selectSkill": "选择 {{name}}" }, diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 5a628a0d..04a90772 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -14,6 +14,8 @@ import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { Button } from '@/shared/ui/button' const PAGE_SIZE = 20 +const NAMESPACE_BUNDLE_MAX_SKILLS = 20 +const NAMESPACE_BUNDLE_MAX_SIZE = '100 MB' /** * Public namespace page showing namespace metadata and the skills currently discoverable inside it. @@ -175,10 +177,22 @@ export function NamespacePage() { } }} title={t('namespace.downloadConfirmTitle')} - description={t('namespace.downloadConfirmDescription', { - count: pendingDownloadCount, - namespace, - })} + description={( + + + {t('namespace.downloadConfirmDescription', { + count: pendingDownloadCount, + namespace, + })} + + + {t('namespace.downloadConfirmLimitHint', { + maxSkills: NAMESPACE_BUNDLE_MAX_SKILLS, + maxSize: NAMESPACE_BUNDLE_MAX_SIZE, + })} + + + )} confirmText={t('namespace.downloadConfirmAction')} onConfirm={confirmDownload} /> From 973c37613ee8429a27ce8bd23c2fb394d3c8b203 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 9 Jun 2026 14:19:25 +0800 Subject: [PATCH 20/78] fix(bootstrap): harden builtin skill sync Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 6 +- .../bootstrap/BuiltinSkillInitializer.java | 20 ++-- .../BuiltinSkillRemotePackageDownloader.java | 74 ++++++++++-- .../V43__user_account_system_account.sql | 49 ++++++++ .../BuiltinSkillInitializerTest.java | 29 ++++- ...iltinSkillRemotePackageDownloaderTest.java | 106 +++++++++++++++++- .../db/FlywayMigrationGuardrailTest.java | 12 ++ 7 files changed, 273 insertions(+), 23 deletions(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 097a26d0..24bac647 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -172,9 +172,9 @@ skillhub-hello-1.0.0.zip | `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | | 同版本已存在且已发布 | 下载前跳过 | | 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 | -| `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 下载前跳过并记录 warning | +| `@global/{slug}` 已被其他 owner 发布 | 下载前跳过并记录 warning | -这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill。 +这意味着内置同步不会接管用户或管理员已经发布的同 slug Skill;仅有待审或未发布版本不会阻断内置同步。 同版本已存在时,同步器不会重新下载远端 zip,也不会验证远端对象内容是否发生漂移。 如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 @@ -248,7 +248,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | | package must contain SKILL.md | 检查 zip 根目录是否存在 `SKILL.md` | | manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | -| slug is already owned by another user | 说明 `@global/{slug}` 已被非内置发布者占用,内置同步不会覆盖 | +| slug is already published by another user | 说明 `@global/{slug}` 已被非内置发布者发布,内置同步不会覆盖 | | published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | 如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index d6626087..5145c404 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -258,10 +258,8 @@ public class BuiltinSkillInitializer { private boolean shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); - boolean hasNonBuiltinOwner = existingSkills.stream() - .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); - if (hasNonBuiltinOwner) { - log.warn("Skipping built-in skill slug={} before download because the slug is already owned by another user", + if (hasPublishedOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} before download because the slug is already published by another user", item.slug()); return true; } @@ -292,10 +290,8 @@ public class BuiltinSkillInitializer { private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); - boolean hasNonBuiltinOwner = existingSkills.stream() - .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); - if (hasNonBuiltinOwner) { - log.warn("Skipping built-in skill slug={} because the slug is already owned by another user", + if (hasPublishedOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} because the slug is already published by another user", item.slug()); return true; } @@ -364,6 +360,14 @@ public class BuiltinSkillInitializer { return false; } + private boolean hasPublishedOtherOwnerConflict(List existingSkills) { + return existingSkills.stream() + .filter(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) + .anyMatch(skill -> !skillVersionRepository + .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty()); + } + private String computeFingerprint(SkillVersion version) { List files = skillFileRepository.findByVersionId(version.getId()).stream() .sorted(Comparator.comparing(SkillFile::getFilePath)) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java index c2819d3f..2e507f73 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java @@ -16,6 +16,12 @@ import java.net.http.HttpResponse; import java.time.Duration; import java.util.Locale; import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; @Component @@ -30,6 +36,7 @@ public class BuiltinSkillRemotePackageDownloader { private final long maxPackageSize; private final HttpClient httpClient; + private final Duration requestTimeout; @Autowired public BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties) { @@ -38,13 +45,22 @@ public class BuiltinSkillRemotePackageDownloader { HttpClient.newBuilder() .connectTimeout(CONNECT_TIMEOUT) .followRedirects(HttpClient.Redirect.NEVER) - .build() + .build(), + REQUEST_TIMEOUT ); } BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties, HttpClient httpClient) { + this(properties, httpClient, REQUEST_TIMEOUT); + } + + BuiltinSkillRemotePackageDownloader( + SkillPublishProperties properties, + HttpClient httpClient, + Duration requestTimeout) { this.maxPackageSize = properties.getMaxPackageSize(); this.httpClient = httpClient; + this.requestTimeout = requestTimeout; } public Optional download(URI uri) { @@ -54,19 +70,19 @@ public class BuiltinSkillRemotePackageDownloader { } HttpRequest request = HttpRequest.newBuilder(uri) - .timeout(REQUEST_TIMEOUT) + .timeout(requestTimeout) .GET() .build(); try { HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - if (response.statusCode() != 200) { - log.warn("Failed to download built-in skill package from {}: HTTP {}", - safeUrl(uri), - response.statusCode()); - return Optional.empty(); - } try (InputStream body = response.body()) { - return readBounded(body); + if (response.statusCode() != 200) { + log.warn("Failed to download built-in skill package from {}: HTTP {}", + safeUrl(uri), + response.statusCode()); + return Optional.empty(); + } + return readBoundedWithTimeout(body, uri); } } catch (IOException ex) { log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage()); @@ -125,6 +141,46 @@ public class BuiltinSkillRemotePackageDownloader { return Optional.of(outputStream.toByteArray()); } + private Optional readBoundedWithTimeout(InputStream inputStream, URI uri) throws IOException { + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + Future> future = executor.submit(() -> readBounded(inputStream)); + try { + return future.get(Math.max(1, requestTimeout.toMillis()), TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + closeQuietly(inputStream); + future.cancel(true); + log.warn("Timed out while downloading built-in skill package body from {} after {}", + safeUrl(uri), + requestTimeout); + return Optional.empty(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + closeQuietly(inputStream); + future.cancel(true); + log.warn("Interrupted while reading built-in skill package body from {}", safeUrl(uri)); + return Optional.empty(); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof IOException ioException) { + throw ioException; + } + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Failed to read built-in skill package body", cause); + } finally { + executor.shutdownNow(); + } + } + + private static void closeQuietly(InputStream inputStream) { + try { + inputStream.close(); + } catch (IOException ignored) { + // Best-effort cleanup after timeout/interruption. + } + } + private static boolean isDisallowedHostLiteral(String host) { return "localhost".equals(host) || IPV4_LITERAL.matcher(host).matches() diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql index c96bb9fa..af7456ef 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -32,3 +32,52 @@ WHERE id = 'builtin-skill-publisher' FROM namespace_member WHERE namespace_member.user_id = user_account.id ); + +UPDATE user_account +SET system_account = TRUE, + display_name = 'Built-in Skill Publisher', + email = NULL, + avatar_url = NULL +WHERE id = 'builtin-skill-publisher' + AND display_name = 'SkillHub Built-in Publisher' + AND email = 'builtin-skill-publisher@example.invalid' + AND avatar_url IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM local_credential + WHERE local_credential.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM identity_binding + WHERE identity_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM api_token + WHERE api_token.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM user_role_binding + WHERE user_role_binding.user_id = user_account.id + ) + AND EXISTS ( + SELECT 1 + FROM namespace_member legacy_member + JOIN namespace legacy_namespace ON legacy_namespace.id = legacy_member.namespace_id + WHERE legacy_member.user_id = user_account.id + AND legacy_namespace.slug = 'global' + AND legacy_member.role = 'OWNER' + ) + AND NOT EXISTS ( + SELECT 1 + FROM namespace_member bad_member + LEFT JOIN namespace bad_namespace ON bad_namespace.id = bad_member.namespace_id + WHERE bad_member.user_id = user_account.id + AND ( + bad_namespace.slug IS NULL + OR bad_namespace.slug <> 'global' + OR bad_member.role <> 'OWNER' + ) + ); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 1f8d938a..01310147 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -184,10 +184,13 @@ class BuiltinSkillInitializerTest { } @Test - void skipsExistingSkillOwnedByAnotherUserBeforeDownloadingPackage() throws Exception { + void skipsPublishedSkillOwnedByAnotherUserBeforeDownloadingPackage() throws Exception { Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); givenManifestAndSystemPublisher(); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(100L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(published)); runInitializer(); @@ -195,6 +198,30 @@ class BuiltinSkillInitializerTest { verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } + @Test + void publishesWhenOnlyUnpublishedSkillOwnedByAnotherUserExists() throws Exception { + Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of(otherSkill)) + .thenReturn(List.of(otherSkill)); + lenient().when(skillVersionRepository.findBySkillIdAndStatus(100L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of()); + + runInitializer(); + + verify(downloader).download(URI.create(ITEM.url())); + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), + eq(entries), + eq(PUBLISHER), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false) + ); + } + @Test void skipsMalformedUrlWithoutSynchronizationFailureLog(CapturedOutput output) { ManifestItem malformed = new ManifestItem( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java index 9dbf2cdf..dc935f5d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java @@ -10,6 +10,7 @@ import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSession; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.net.Authenticator; import java.net.CookieHandler; import java.net.ProxySelector; @@ -22,6 +23,11 @@ import java.time.Duration; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; class BuiltinSkillRemotePackageDownloaderTest { @@ -81,6 +87,21 @@ class BuiltinSkillRemotePackageDownloaderTest { assertThat(client.sendCalls).isEqualTo(1); } + @Test + void closesNonSuccessResponseBody() { + CloseAwareInputStream body = new CloseAwareInputStream(new byte[] {1}); + FakeHttpClient client = new FakeHttpClient(500, body); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(body.closed()).isTrue(); + } + @Test void rejectedUrlDoesNotSendHttpRequest() { FakeHttpClient client = new FakeHttpClient(200, new byte[] {1}); @@ -105,14 +126,40 @@ class BuiltinSkillRemotePackageDownloaderTest { assertThat(downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip"))).isEmpty(); } + @Test + void returnsEmptyWhenResponseBodyStopsBeforeCompletion() throws Exception { + BlockingInputStream body = new BlockingInputStream(); + FakeHttpClient client = new FakeHttpClient(200, body); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client, + Duration.ofMillis(50) + ); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future> result = executor.submit( + () -> downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip"))); + + try { + assertThat(result.get(1, TimeUnit.SECONDS)).isEmpty(); + assertThat(body.closed()).isTrue(); + } finally { + body.close(); + executor.shutdownNow(); + } + } + static class FakeHttpClient extends HttpClient { private final int statusCode; - private final byte[] body; + private final InputStream body; private HttpRequest lastRequest; private int sendCalls; FakeHttpClient(int statusCode, byte[] body) { + this(statusCode, new ByteArrayInputStream(body)); + } + + FakeHttpClient(int statusCode, InputStream body) { this.statusCode = statusCode; this.body = body; } @@ -168,7 +215,7 @@ class BuiltinSkillRemotePackageDownloaderTest { lastRequest = request; sendCalls++; @SuppressWarnings("unchecked") - T responseBody = (T) new ByteArrayInputStream(body); + T responseBody = (T) body; return new FakeResponse<>(request, statusCode, responseBody); } @@ -190,6 +237,61 @@ class BuiltinSkillRemotePackageDownloaderTest { } } + static final class CloseAwareInputStream extends ByteArrayInputStream { + + private boolean closed; + + private CloseAwareInputStream(byte[] bytes) { + super(bytes); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + + boolean closed() { + return closed; + } + } + + static final class BlockingInputStream extends InputStream { + + private final AtomicBoolean closed = new AtomicBoolean(); + + @Override + public int read() { + waitUntilClosed(); + return -1; + } + + @Override + public int read(byte[] bytes, int offset, int length) { + waitUntilClosed(); + return -1; + } + + @Override + public void close() { + closed.set(true); + } + + boolean closed() { + return closed.get(); + } + + private void waitUntilClosed() { + while (!closed.get()) { + try { + Thread.sleep(10); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } + } + record FakeResponse(HttpRequest request, int statusCode, T body) implements HttpResponse { @Override public Optional> previousResponse() { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index aad2480f..21369016 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -90,6 +90,18 @@ class FlywayMigrationGuardrailTest { assertThat(migration).contains("namespace_member.user_id = user_account.id"); } + @Test + void systemAccountMigration_mustPromoteLegacyBuiltinPublisherSafely() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("SkillHub Built-in Publisher"); + assertThat(migration).contains("builtin-skill-publisher@example.invalid"); + assertThat(migration).contains("legacy_namespace.slug = 'global'"); + assertThat(migration).contains("legacy_member.role = 'OWNER'"); + assertThat(migration).contains("bad_member.user_id = user_account.id"); + assertThat(migration).contains("bad_namespace.slug <> 'global'"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") From ed13a41ed87824762ee9d5a46d1e53fb7297495a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 9 Jun 2026 14:25:17 +0800 Subject: [PATCH 21/78] fix(skill): align anonymous download helper with main Signed-off-by: dongmucat <1127093059@qq.com> --- .../domain/skill/service/SkillDownloadService.java | 10 ++++------ .../domain/skill/service/SkillDownloadServiceTest.java | 5 +++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 05b32944..27e9c376 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,7 +4,6 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -300,7 +299,7 @@ public class SkillDownloadService { Skill skill, String currentUserId, Map userNsRoles) { - if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { return false; } return visibilityChecker.canAccess(skill, currentUserId, userNsRoles); @@ -432,7 +431,7 @@ public class SkillDownloadService { Skill skill, String currentUserId, Map userNsRoles) { - if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { @@ -440,9 +439,8 @@ public class SkillDownloadService { } } - private boolean isAnonymousDownloadAllowed(Namespace namespace, Skill skill) { - return namespace.getType() == NamespaceType.GLOBAL - && skill.getVisibility() == SkillVisibility.PUBLIC; + private boolean isAnonymousDownloadAllowed(Skill skill) { + return skill.getVisibility() == SkillVisibility.PUBLIC; } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index de2a8815..298df05d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -319,7 +319,7 @@ class SkillDownloadServiceTest { } @Test - void testDownloadVersion_RejectsAnonymousForTeamNamespacePublicSkill() throws Exception { + void testDownloadVersion_RejectsAnonymousWhenVisibilityCheckerDeniesAccess() throws Exception { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); setId(namespace, 2L); namespace.setType(NamespaceType.TEAM); @@ -331,11 +331,12 @@ class SkillDownloadServiceTest { when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(false); assertThrows(DomainForbiddenException.class, () -> service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of())); - verify(visibilityChecker, never()).canAccess(any(), any(), anyMap()); + verify(visibilityChecker).canAccess(skill, null, Map.of()); verify(skillRepository, never()).incrementDownloadCount(anyLong()); verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); From dd3e511a91cf26573cbf39757808578143e1a12e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 10:31:40 +0800 Subject: [PATCH 22/78] fix(bootstrap): support skill directory archives Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 17 +++++++------- .../BuiltinSkillPackageExtractor.java | 22 +++---------------- .../BuiltinSkillPackageExtractorTest.java | 12 +++++----- 3 files changed, 19 insertions(+), 32 deletions(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 24bac647..316faa8d 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -83,7 +83,7 @@ manifest 文件格式如下: manifest 中的 `url` 必须指向 zip 包。zip 包需要满足 SkillHub Skill 包协议: -- zip 根目录必须包含 `SKILL.md`。 +- zip 可以在根目录直接包含 `SKILL.md`,也可以包含一个单独的顶层 Skill 目录,并在该目录下包含 `SKILL.md`。 - `SKILL.md` frontmatter 中必须包含合法的 `name`、`description`、`version` 等元数据。 - `SKILL.md` 中的 `name` 经过 slug 归一化后,必须等于 manifest 中的 `slug`。 - `SKILL.md` 中的 `version` 必须等于 manifest 中的 `version`。 @@ -99,15 +99,16 @@ skillhub-hello-1.0.0.zip └── check.js ``` -不推荐的结构: +同样支持标准单目录 Skill 包: ```text skillhub-hello-1.0.0.zip └── skillhub-hello/ - └── SKILL.md + ├── SKILL.md + └── README.md ``` -原因是内置 Skill 同步要求根目录存在 `SKILL.md`,不会把嵌套目录中的 `SKILL.md` 当作入口。 +如果 zip 中存在多个顶层目录,或在多个目录中同时出现 `SKILL.md`,同步器会跳过该项并记录错误,避免误选入口。 ## 4. URL 安全限制 @@ -146,7 +147,7 @@ skillhub-hello-1.0.0.zip 7. 按 manifest 顺序处理每一个 item。 8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。 9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。 -10. 解包并校验根目录 `SKILL.md`。 +10. 解包并校验 Skill 入口 `SKILL.md`。 11. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。 13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 @@ -209,7 +210,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false 新增一个内置 Skill 的推荐步骤: -1. 准备 Skill 包,并确认 zip 根目录包含 `SKILL.md`。 +1. 准备 Skill 包,并确认 zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。 2. 检查 `SKILL.md` 中的 `name` 和 `version`。 3. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的官方云存储路径。 4. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。 @@ -246,7 +247,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 | | URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 | | package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | -| package must contain SKILL.md | 检查 zip 根目录是否存在 `SKILL.md` | +| package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 | | manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | | slug is already published by another user | 说明 `@global/{slug}` 已被非内置发布者发布,内置同步不会覆盖 | | published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | @@ -260,7 +261,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false - manifest JSON 格式合法。 - 每个 item 都包含 `slug`、`version`、`url`。 - URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。 -- zip 根目录包含 `SKILL.md`。 +- zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。 - `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。 - `SKILL.md version` 与 manifest `version` 一致。 - 启动日志没有该 item 的 warning 或 error。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java index 9cd44cd6..d023190d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java @@ -8,8 +8,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; @Component public class BuiltinSkillPackageExtractor { @@ -21,30 +19,16 @@ public class BuiltinSkillPackageExtractor { } public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException { - assertRootSkillMd(zipBytes); SkillPackageArchiveExtractor.ExtractionResult result = archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes)); - boolean hasRootSkillMd = result.entries().stream() + boolean hasSkillMd = result.entries().stream() .anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())); - if (!hasRootSkillMd) { - throw new IllegalArgumentException("Built-in skill package must contain root " + SkillPackagePolicy.SKILL_MD_PATH); + if (!hasSkillMd) { + throw new IllegalArgumentException("Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH); } return result; } - private void assertRootSkillMd(byte[] zipBytes) throws IOException { - try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { - ZipEntry entry; - while ((entry = zipInputStream.getNextEntry()) != null) { - if (!entry.isDirectory() && SkillPackagePolicy.SKILL_MD_PATH.equals(entry.getName())) { - return; - } - zipInputStream.closeEntry(); - } - } - throw new IllegalArgumentException("Built-in skill package must contain root " + SkillPackagePolicy.SKILL_MD_PATH); - } - private record ByteArrayMultipartFile(byte[] bytes) implements MultipartFile { @Override diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java index 1df4203d..8836546f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -49,18 +49,20 @@ class BuiltinSkillPackageExtractorTest { } @Test - void rejectsZipWithOnlyNestedSkillMd() throws Exception { + void acceptsZipWithSingleTopLevelSkillDirectory() throws Exception { byte[] zip = zip(entry("skillhub-hello/SKILL.md", """ --- name: skillhub-hello version: 1.0.0 --- # SkillHub Hello - """)); + """), entry("skillhub-hello/README.md", "# Readme")); - assertThatThrownBy(() -> extractor.extract(zip)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining(SkillPackagePolicy.SKILL_MD_PATH); + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extract(zip); + + assertThat(result.entries()) + .extracting(entry -> entry.path()) + .containsExactly("SKILL.md", "README.md"); } private static ZipSource entry(String path, String content) { From 1b09ab88a2c03497bd451b66c00f341060d7b163 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 14:19:41 +0800 Subject: [PATCH 23/78] fix(bootstrap): enforce strict builtin skill skips Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 6 ++--- .../bootstrap/BuiltinSkillInitializer.java | 15 +++++------ .../BuiltinSkillPackageExtractor.java | 4 +++ .../BuiltinSkillInitializerTest.java | 27 +++++-------------- .../BuiltinSkillPackageExtractorTest.java | 15 +++++++++++ 5 files changed, 34 insertions(+), 33 deletions(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 316faa8d..7de92d63 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -173,9 +173,9 @@ skillhub-hello-1.0.0.zip | `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | | 同版本已存在且已发布 | 下载前跳过 | | 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 | -| `@global/{slug}` 已被其他 owner 发布 | 下载前跳过并记录 warning | +| `@global/{slug}` 已被其他 owner 创建或发布 | 下载前跳过并记录 warning | -这意味着内置同步不会接管用户或管理员已经发布的同 slug Skill;仅有待审或未发布版本不会阻断内置同步。 +这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill;即使该 Skill 仍处于待审、未发布或已拒绝状态,也会跳过对应 manifest item。 同版本已存在时,同步器不会重新下载远端 zip,也不会验证远端对象内容是否发生漂移。 如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 @@ -249,7 +249,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | | package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 | | manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | -| slug is already published by another user | 说明 `@global/{slug}` 已被非内置发布者发布,内置同步不会覆盖 | +| slug already belongs to another user | 说明 `@global/{slug}` 已被非内置发布者创建或发布,内置同步不会覆盖 | | published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | 如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 5145c404..34379db7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -258,8 +258,8 @@ public class BuiltinSkillInitializer { private boolean shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); - if (hasPublishedOtherOwnerConflict(existingSkills)) { - log.warn("Skipping built-in skill slug={} before download because the slug is already published by another user", + if (hasOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} before download because the slug already belongs to another user", item.slug()); return true; } @@ -290,8 +290,8 @@ public class BuiltinSkillInitializer { private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); - if (hasPublishedOtherOwnerConflict(existingSkills)) { - log.warn("Skipping built-in skill slug={} because the slug is already published by another user", + if (hasOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} because the slug already belongs to another user", item.slug()); return true; } @@ -360,12 +360,9 @@ public class BuiltinSkillInitializer { return false; } - private boolean hasPublishedOtherOwnerConflict(List existingSkills) { + private boolean hasOtherOwnerConflict(List existingSkills) { return existingSkills.stream() - .filter(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) - .anyMatch(skill -> !skillVersionRepository - .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED) - .isEmpty()); + .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); } private String computeFingerprint(SkillVersion version) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java index d023190d..973dd5b2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java @@ -21,6 +21,10 @@ public class BuiltinSkillPackageExtractor { public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException { SkillPackageArchiveExtractor.ExtractionResult result = archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes)); + if (!result.warnings().isEmpty()) { + throw new IllegalArgumentException("Built-in skill package has warnings: " + + String.join("; ", result.warnings())); + } boolean hasSkillMd = result.entries().stream() .anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())); if (!hasSkillMd) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 01310147..c2b5a836 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -184,13 +184,10 @@ class BuiltinSkillInitializerTest { } @Test - void skipsPublishedSkillOwnedByAnotherUserBeforeDownloadingPackage() throws Exception { + void skipsSkillOwnedByAnotherUserBeforeDownloadingPackage() { Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); - SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); givenManifestAndSystemPublisher(); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); - when(skillVersionRepository.findBySkillIdAndStatus(100L, SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of(published)); runInitializer(); @@ -199,27 +196,15 @@ class BuiltinSkillInitializerTest { } @Test - void publishesWhenOnlyUnpublishedSkillOwnedByAnotherUserExists() throws Exception { + void skipsUnpublishedSkillOwnedByAnotherUserBeforeDownloadingPackage() { Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); - List entries = packageEntries("skillhub-hello", "1.0.0", "same"); - givenExtractedPackage(entries); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) - .thenReturn(List.of(otherSkill)) - .thenReturn(List.of(otherSkill)); - lenient().when(skillVersionRepository.findBySkillIdAndStatus(100L, SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); + givenManifestAndSystemPublisher(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); runInitializer(); - verify(downloader).download(URI.create(ITEM.url())); - verify(skillPublishService).publishFromEntries( - eq(GLOBAL), - eq(entries), - eq(PUBLISHER), - eq(SkillVisibility.PUBLIC), - eq(Set.of("SUPER_ADMIN")), - eq(false) - ); + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java index 8836546f..8f1a5397 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -65,6 +65,21 @@ class BuiltinSkillPackageExtractorTest { .containsExactly("SKILL.md", "README.md"); } + @Test + void rejectsZipWhenSkillDirectoryPromotionWouldIgnoreOutsideFiles() throws Exception { + byte[] zip = zip(entry("skillhub-hello/SKILL.md", """ + --- + name: skillhub-hello + version: 1.0.0 + --- + # SkillHub Hello + """), entry("LICENSE", "Apache-2.0")); + + assertThatThrownBy(() -> extractor.extract(zip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Ignored file outside skill directory: LICENSE"); + } + private static ZipSource entry(String path, String content) { return new ZipSource(path, content.getBytes(StandardCharsets.UTF_8)); } From 6779c1eecdb234dffbd2c84024ac2e912517f55c Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:31:22 +0800 Subject: [PATCH 24/78] feat(web): preview relative markdown package links (#502) Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/helpers/test-data-builder.ts | 25 +++- web/e2e/skill-detail-relative-links.spec.ts | 55 +++++++ .../features/skill/markdown-renderer.test.tsx | 27 +++- web/src/features/skill/markdown-renderer.tsx | 9 +- .../skill/package-relative-link.test.ts | 80 +++++++++++ .../features/skill/package-relative-link.ts | 112 +++++++++++++++ web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/zh.json | 2 + web/src/i18n/skill-detail-locale.test.ts | 7 + web/src/pages/skill-detail.test.tsx | 134 +++++++++++++++++- web/src/pages/skill-detail.tsx | 38 ++++- 11 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 web/e2e/skill-detail-relative-links.spec.ts create mode 100644 web/src/features/skill/package-relative-link.test.ts create mode 100644 web/src/features/skill/package-relative-link.ts diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index f2cb47d6..a6b77adc 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' import path from 'node:path' @@ -65,6 +65,11 @@ export interface SeedSkillOptions { description?: string version?: string readmeHeading?: string + readmeBody?: string + extraFiles?: Array<{ + path: string + content: string + }> } function asApiErrorBody(value: unknown): string { @@ -130,8 +135,13 @@ function buildSkillPackageZipBuffer(suffix: string, options?: SeedSkillOptions): execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') - execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) + writeFileSync(path.join(packageDir, 'README.md'), options?.readmeBody ?? `# ${readmeHeading}\n`, 'utf8') + for (const extraFile of options?.extraFiles ?? []) { + const targetPath = path.join(packageDir, extraFile.path) + mkdirSync(path.dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, extraFile.content, 'utf8') + } + execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: packageDir }) return readFileSync(zipPath) } finally { rmSync(tempRoot, { recursive: true, force: true }) @@ -146,8 +156,13 @@ function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') - execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) + writeFileSync(path.join(packageDir, 'README.md'), options?.readmeBody ?? `# ${readmeHeading}\n`, 'utf8') + for (const extraFile of options?.extraFiles ?? []) { + const targetPath = path.join(packageDir, extraFile.path) + mkdirSync(path.dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, extraFile.content, 'utf8') + } + execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: packageDir }) return { filePath: zipPath, diff --git a/web/e2e/skill-detail-relative-links.spec.ts b/web/e2e/skill-detail-relative-links.spec.ts new file mode 100644 index 00000000..5f1f353c --- /dev/null +++ b/web/e2e/skill-detail-relative-links.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('Skill Detail Relative Links (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('previews package files from overview relative links and reports missing files', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = `relative-links-${Date.now().toString(36)}` + const skill = await builder.publishSkill(namespace.slug, { + name: skillName, + readmeBody: [ + `# ${skillName}`, + '', + '[Usage](docs/usage.md)', + '', + '[Missing](docs/missing.md)', + ].join('\n'), + extraFiles: [ + { + path: 'docs/usage.md', + content: '# Usage\n\nThis is linked documentation.', + }, + ], + }) + + await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(skill.slug)}`) + + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByRole('link', { name: 'Usage' })).toBeVisible() + await page.getByRole('link', { name: 'Usage' }).click() + await expect(page.getByRole('dialog')).toContainText('usage.md') + await expect(page.getByRole('dialog')).toContainText('This is linked documentation.') + + await page.getByRole('button', { name: 'Close' }).click() + await expect(page.getByRole('dialog')).toBeHidden() + + await page.getByRole('link', { name: 'Missing' }).click() + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByText('File not found')).toBeVisible() + await expect(page.getByText('not included in the current skill version')).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/src/features/skill/markdown-renderer.test.tsx b/web/src/features/skill/markdown-renderer.test.tsx index d7f39c4c..20fe3413 100644 --- a/web/src/features/skill/markdown-renderer.test.tsx +++ b/web/src/features/skill/markdown-renderer.test.tsx @@ -1,5 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { MARKDOWN_IMAGE_CLASS_NAME } from './markdown-renderer' +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MARKDOWN_IMAGE_CLASS_NAME, MarkdownRenderer } from './markdown-renderer' + +afterEach(() => cleanup()) describe('MARKDOWN_IMAGE_CLASS_NAME', () => { it('keeps markdown images at their intrinsic width while remaining responsive', () => { @@ -10,3 +15,21 @@ describe('MARKDOWN_IMAGE_CLASS_NAME', () => { expect(classNames).not.toContain('w-full') }) }) + +describe('MarkdownRenderer links', () => { + it('passes the raw markdown href to the optional link click handler', () => { + const onLinkClick = vi.fn() + + render() + fireEvent.click(screen.getByRole('link', { name: 'Usage' })) + + expect(onLinkClick).toHaveBeenCalledTimes(1) + expect(onLinkClick.mock.calls[0][0]).toBe('docs/usage.md') + }) + + it('keeps links renderable without a click handler', () => { + render() + + expect(screen.getByRole('link', { name: 'Usage' }).getAttribute('href')).toBe('docs/usage.md') + }) +}) diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 1f787021..8bb0c198 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, type MouseEvent } from 'react' import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import rehypeSanitize from 'rehype-sanitize' @@ -12,6 +12,7 @@ export const MARKDOWN_IMAGE_CLASS_NAME = 'h-auto max-w-full' interface MarkdownRendererProps { content: string className?: string + onLinkClick?: (href: string, event: MouseEvent) => void } /** @@ -20,7 +21,7 @@ interface MarkdownRendererProps { * dedicated UI sections and should not appear twice in the document body. * Memoized to prevent re-parsing on every render. */ -export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { +export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRendererProps) { const containerClassName = [ className, 'max-w-none break-words text-sm text-foreground/90 [overflow-wrap:anywhere]', @@ -45,13 +46,15 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {children}

), - a: ({ className: linkClassName, children, ...props }) => ( + a: ({ className: linkClassName, children, href, ...props }) => ( onLinkClick?.(href ?? '', event)} > {children} diff --git a/web/src/features/skill/package-relative-link.test.ts b/web/src/features/skill/package-relative-link.test.ts new file mode 100644 index 00000000..737be487 --- /dev/null +++ b/web/src/features/skill/package-relative-link.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFile } from '@/api/types' +import { resolvePackageRelativeLink } from './package-relative-link' + +function file(filePath: string): SkillFile { + return { + id: filePath.length, + filePath, + fileSize: 128, + contentType: 'text/markdown', + sha256: `sha-${filePath}`, + } +} + +const packageFiles = [ + file('README.md'), + file('docs/SKILL.md'), + file('docs/usage.md'), + file('shared.md'), + file('space name.md'), + file('使用.md'), +] + +describe('resolvePackageRelativeLink', () => { + it('matches same-directory and explicit current-directory links from the package root', () => { + expect(resolvePackageRelativeLink('docs/usage.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + }) + expect(resolvePackageRelativeLink('./docs/usage.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + }) + }) + + it('normalizes parent-directory links against the current documentation file', () => { + expect(resolvePackageRelativeLink('../shared.md', 'docs/SKILL.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'shared.md', + }) + }) + + it('keeps fragment information while matching the file path', () => { + expect(resolvePackageRelativeLink('docs/usage.md#intro', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + fragment: 'intro', + }) + }) + + it('decodes encoded file paths before matching package files', () => { + expect(resolvePackageRelativeLink('space%20name.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'space name.md', + }) + expect(resolvePackageRelativeLink('%E4%BD%BF%E7%94%A8.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: '使用.md', + }) + }) + + it('ignores links that should keep native browser behavior', () => { + for (const href of ['https://example.com', 'mailto:team@example.com', '#intro', '/absolute/path.md', '']) { + expect(resolvePackageRelativeLink(href, 'README.md', packageFiles)).toMatchObject({ + status: 'ignored', + }) + } + }) + + it('returns missing for relative links that do not resolve to a package file', () => { + expect(resolvePackageRelativeLink('docs/missing.md', 'README.md', packageFiles)).toMatchObject({ + status: 'missing', + path: 'docs/missing.md', + }) + expect(resolvePackageRelativeLink('../../outside.md', 'docs/SKILL.md', packageFiles)).toMatchObject({ + status: 'missing', + path: null, + }) + }) +}) diff --git a/web/src/features/skill/package-relative-link.ts b/web/src/features/skill/package-relative-link.ts new file mode 100644 index 00000000..15c7bb25 --- /dev/null +++ b/web/src/features/skill/package-relative-link.ts @@ -0,0 +1,112 @@ +import type { SkillFile } from '@/api/types' + +export type PackageRelativeLinkResolution = + | { + status: 'ignored' + href: string + } + | { + status: 'matched' + href: string + path: string + fragment: string | null + file: SkillFile + } + | { + status: 'missing' + href: string + path: string | null + fragment: string | null + } + +function splitHref(href: string) { + const hashIndex = href.indexOf('#') + const beforeHash = hashIndex >= 0 ? href.slice(0, hashIndex) : href + const fragment = hashIndex >= 0 ? href.slice(hashIndex + 1) : null + const queryIndex = beforeHash.indexOf('?') + return { + path: queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash, + fragment, + } +} + +function decodePath(path: string) { + try { + return decodeURIComponent(path) + } catch { + return path + } +} + +function directoryOf(filePath?: string | null) { + if (!filePath) { + return '' + } + const normalized = filePath.replace(/^\/+/, '') + const lastSlash = normalized.lastIndexOf('/') + return lastSlash >= 0 ? normalized.slice(0, lastSlash) : '' +} + +function normalizePackagePath(baseDirectory: string, relativePath: string) { + const stack: string[] = [] + const rawParts = [...baseDirectory.split('/'), ...relativePath.split('/')] + + for (const part of rawParts) { + if (!part || part === '.') { + continue + } + if (part === '..') { + if (stack.length === 0) { + return null + } + stack.pop() + continue + } + stack.push(part) + } + + return stack.join('/') +} + +function shouldIgnoreLink(href: string, rawPath: string) { + if (!href.trim()) { + return true + } + if (!rawPath || href.startsWith('#')) { + return true + } + if (rawPath.startsWith('/') || rawPath.startsWith('//')) { + return true + } + return /^[a-z][a-z0-9+.-]*:/i.test(rawPath) +} + +export function resolvePackageRelativeLink( + href: string, + currentFilePath: string | null | undefined, + files: SkillFile[] | null | undefined, +): PackageRelativeLinkResolution { + const { path: rawPath, fragment } = splitHref(href) + + if (shouldIgnoreLink(href, rawPath)) { + return { status: 'ignored', href } + } + + const normalizedPath = normalizePackagePath(directoryOf(currentFilePath), decodePath(rawPath)) + if (!normalizedPath) { + return { status: 'missing', href, path: null, fragment } + } + + const matchedFile = (files ?? []).find((file) => file.filePath === normalizedPath) + if (!matchedFile) { + return { status: 'missing', href, path: normalizedPath, fragment } + } + + return { + status: 'matched', + href, + path: normalizedPath, + fragment, + file: matchedFile, + } +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ea5e049d..b63da59f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -790,6 +790,8 @@ "documentationSource": "Source: {{path}}", "documentationUnavailableTitle": "Documentation is unavailable", "documentationUnavailable": "The documentation file could not be loaded. You can still inspect the package contents in the file list.", + "packageLinkMissingTitle": "File not found", + "packageLinkMissingDescription": "This link points to a file that is not included in the current skill version.", "authorLabel": "By {{name}}", "expandOverview": "Expand full overview", "collapseOverview": "Collapse content", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b8f0cd7a..243ec719 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -790,6 +790,8 @@ "documentationSource": "来源:{{path}}", "documentationUnavailableTitle": "文档暂时不可用", "documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。", + "packageLinkMissingTitle": "文件未找到", + "packageLinkMissingDescription": "该链接指向的文件不在当前技能版本中。", "authorLabel": "作者 {{name}}", "expandOverview": "展开全文", "collapseOverview": "收起内容", diff --git a/web/src/i18n/skill-detail-locale.test.ts b/web/src/i18n/skill-detail-locale.test.ts index 6434f4d5..a237ef60 100644 --- a/web/src/i18n/skill-detail-locale.test.ts +++ b/web/src/i18n/skill-detail-locale.test.ts @@ -7,4 +7,11 @@ describe('skill detail lifecycle locales', () => { expect(zh.skillDetail.unarchiveSkill).toBe('恢复技能') expect(en.skillDetail.unarchiveSkill).toBe('Restore Skill') }) + + it('defines package relative link missing messages in both locales', () => { + expect(zh.skillDetail.packageLinkMissingTitle).toBe('文件未找到') + expect(zh.skillDetail.packageLinkMissingDescription).toBe('该链接指向的文件不在当前技能版本中。') + expect(en.skillDetail.packageLinkMissingTitle).toBe('File not found') + expect(en.skillDetail.packageLinkMissingDescription).toBe('This link points to a file that is not included in the current skill version.') + }) }) diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 87bdcb48..1c6374ce 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -1,11 +1,24 @@ +/** @vitest-environment jsdom */ + import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MouseEvent } from 'react' +import type { SkillFile } from '@/api/types' + +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})) const navigateMock = vi.fn() const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === 'USER') const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() const useSkillVersionsMock = vi.fn() +const useSkillFilesMock = vi.fn() +const useSkillReadmeMock = vi.fn() +const useSkillFileMock = vi.fn() let authState: { user: { userId: string; platformRoles: string[] } | null hasRole: (role: string) => boolean @@ -47,7 +60,7 @@ vi.mock('@/features/report/use-skill-reports', () => ({ })) vi.mock('@/shared/lib/toast', () => ({ - toast: { success: vi.fn(), error: vi.fn() }, + toast: { success: toastMocks.success, error: toastMocks.error }, })) vi.mock('@/api/client', () => ({ @@ -76,7 +89,47 @@ vi.mock('@/shared/lib/number-format', () => ({ })) vi.mock('@/features/skill/markdown-renderer', () => ({ - MarkdownRenderer: () =>
markdown
, + MarkdownRenderer: ({ + content, + onLinkClick, + }: { + content: string + onLinkClick?: (href: string, event: MouseEvent) => void + }) => ( + + ), +})) + +vi.mock('@/features/skill/file-preview-dialog', () => ({ + FilePreviewDialog: ({ open, node }: { open: boolean; node: { path: string } | null }) => ( + open && node ?
preview:{node.path}
: null + ), })) vi.mock('@/features/skill/file-tree', () => ({ @@ -107,9 +160,9 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useDetachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args), useSkillVersionDetail: () => ({ data: undefined }), - useSkillFiles: () => ({ data: [] }), - useSkillReadme: () => ({ data: '# Demo', error: null }), - useSkillFile: () => ({ data: null, isLoading: false, error: null }), + useSkillFiles: (...args: unknown[]) => useSkillFilesMock(...args), + useSkillReadme: (...args: unknown[]) => useSkillReadmeMock(...args), + useSkillFile: (...args: unknown[]) => useSkillFileMock(...args), useArchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), @@ -165,9 +218,26 @@ function createSkill(overrides: Record = {}) { } } +function createSkillFile(filePath: string): SkillFile { + return { + id: filePath.length, + filePath, + fileSize: 128, + contentType: 'text/markdown', + sha256: `sha-${filePath}`, + } +} + describe('SkillDetailPage', () => { + afterEach(() => cleanup()) + beforeEach(() => { navigateMock.mockReset() + useSkillFilesMock.mockReset() + useSkillReadmeMock.mockReset() + useSkillFileMock.mockReset() + toastMocks.success.mockReset() + toastMocks.error.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'USER') authState = { user: { userId: 'owner-1', platformRoles: ['USER'] }, @@ -196,6 +266,9 @@ describe('SkillDetailPage', () => { useSkillLabelsMock.mockReturnValue({ data: undefined, }) + useSkillFilesMock.mockReturnValue({ data: [] }) + useSkillReadmeMock.mockReturnValue({ data: '# Demo', error: null }) + useSkillFileMock.mockReturnValue({ data: null, isLoading: false, error: null }) }) it('shows hard delete action for the skill owner', () => { @@ -401,4 +474,53 @@ describe('SkillDetailPage', () => { expect(html).toContain('break-all') expect(html).toContain('leading-snug') }) + + it('opens a file preview when overview markdown relative link matches a package file', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'Usage' })) + + expect(screen.getByRole('dialog').textContent).toContain('preview:docs/usage.md') + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('keeps the viewer on the detail page and shows a toast for missing package files', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'Missing' })) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(toastMocks.error).toHaveBeenCalledWith( + 'skillDetail.packageLinkMissingTitle', + 'skillDetail.packageLinkMissingDescription', + ) + }) + + it('leaves external links and same-document anchors alone', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'External' })) + fireEvent.click(screen.getByRole('link', { name: 'Anchor' })) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) }) diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 43c05b7f..77b0c291 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,12 +1,14 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type MouseEvent } 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, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' +import { resolvePackageRelativeLink } from '@/features/skill/package-relative-link' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' +import type { SkillFile } from '@/api/types' import { InstallCommand } from '@/features/skill/install-command' import { ShareButton } from '@/features/skill/share-button' import { SkillLabelPanel } from '@/features/skill/skill-label-panel' @@ -87,6 +89,20 @@ function parseMetadataJson(parsed?: string) { } } +function createPackageFilePreviewNode(file: SkillFile): FileTreeNode { + const pathParts = file.filePath.split('/').filter(Boolean) + const name = pathParts[pathParts.length - 1] ?? file.filePath + + return { + id: file.filePath, + name, + path: file.filePath, + type: 'file', + file, + depth: Math.max(pathParts.length - 1, 0), + } +} + function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { if (error.serverMessageKey === 'promotion.duplicate_pending') { return 'promotion.duplicate_pending' @@ -284,6 +300,24 @@ export function SkillDetailPage() { setPreviewDialogOpen(true) } + const handleOverviewLinkClick = (href: string, event: MouseEvent) => { + const resolution = resolvePackageRelativeLink(href, documentationPath, files) + + if (resolution.status === 'ignored') { + return + } + + event.preventDefault() + + if (resolution.status === 'matched') { + setPreviewNode(createPackageFilePreviewNode(resolution.file)) + setPreviewDialogOpen(true) + return + } + + toast.error(t('skillDetail.packageLinkMissingTitle'), t('skillDetail.packageLinkMissingDescription')) + } + // Download a single file from the skill version const handleDownloadFile = () => { const isAnonymousAllowed = namespace === 'global' && skill?.visibility === 'PUBLIC' @@ -844,7 +878,7 @@ export function SkillDetailPage() { style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined} >
- +
{!isOverviewExpanded && isOverviewCollapsible ? (
From f35f91616a4e4f75de3aa14c0b56f980860423ff Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 16:51:35 +0800 Subject: [PATCH 25/78] fix(publish): preserve latest version reference cleanup order Signed-off-by: dongmucat <1127093059@qq.com> --- .../bootstrap/BuiltinSkillInitializer.java | 75 +++++++++++++------ .../BuiltinSkillInitializerTest.java | 10 ++- .../skill/service/SkillPublishService.java | 11 ++- .../service/SkillPublishServiceTest.java | 7 +- 4 files changed, 72 insertions(+), 31 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 34379db7..20b9a363 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -126,10 +126,21 @@ public class BuiltinSkillInitializer { return; } + int published = 0; + int idempotentSkipped = 0; + int conflictSkipped = 0; + int failed = 0; for (ManifestItem item : items) { try { - syncItem(namespace.get(), item); + SyncOutcome outcome = syncItem(namespace.get(), item); + switch (outcome) { + case PUBLISHED -> published++; + case IDEMPOTENT_SKIPPED -> idempotentSkipped++; + case CONFLICT_SKIPPED -> conflictSkipped++; + case FAILED -> failed++; + } } catch (Exception exception) { + failed++; log.error( "Failed to synchronize built-in skill slug={} version={}: {}", item.slug(), @@ -139,6 +150,14 @@ public class BuiltinSkillInitializer { ); } } + log.info( + "Built-in skill synchronization finished: total={}, published={}, idempotentSkipped={}, conflictSkipped={}, failed={}", + items.size(), + published, + idempotentSkipped, + conflictSkipped, + failed + ); } private boolean ensureSystemPublisher(Namespace namespace) { @@ -171,21 +190,22 @@ public class BuiltinSkillInitializer { return true; } - private void syncItem(Namespace namespace, ManifestItem item) throws Exception { - if (shouldSkipBeforeDownload(namespace.getId(), item)) { - return; + private SyncOutcome syncItem(Namespace namespace, ManifestItem item) throws Exception { + Optional skipBeforeDownload = shouldSkipBeforeDownload(namespace.getId(), item); + if (skipBeforeDownload.isPresent()) { + return skipBeforeDownload.get(); } Optional packageUri = parsePackageUri(item); if (packageUri.isEmpty()) { - return; + return SyncOutcome.FAILED; } Optional packageBytes = downloader.download(packageUri.get()); if (packageBytes.isEmpty()) { log.warn("Skipping built-in skill slug={} version={} because package download failed", item.slug(), item.version()); - return; + return SyncOutcome.FAILED; } SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get()); @@ -199,7 +219,7 @@ public class BuiltinSkillInitializer { item.version(), packageSlug ); - return; + return SyncOutcome.FAILED; } if (!item.version().equals(metadata.version())) { log.warn( @@ -208,11 +228,12 @@ public class BuiltinSkillInitializer { item.version(), metadata.version() ); - return; + return SyncOutcome.FAILED; } - if (shouldSkipExisting(namespace.getId(), item, entries)) { - return; + Optional skipExisting = shouldSkipExisting(namespace.getId(), item, entries); + if (skipExisting.isPresent()) { + return skipExisting.get(); } try { @@ -226,14 +247,16 @@ public class BuiltinSkillInitializer { ); log.info("Published built-in skill slug={} version={} to @{}", item.slug(), item.version(), GLOBAL_NAMESPACE); + return SyncOutcome.PUBLISHED; } catch (RuntimeException exception) { if (isAlreadyPublishedWithSameFingerprint(namespace.getId(), item, entries)) { log.info("Built-in skill slug={} version={} was published concurrently, skipping", item.slug(), item.version()); - return; + return SyncOutcome.IDEMPOTENT_SKIPPED; } log.error("Failed to publish built-in skill slug={} version={}: {}", item.slug(), item.version(), exception.getMessage(), exception); + return SyncOutcome.FAILED; } } @@ -256,25 +279,25 @@ public class BuiltinSkillInitializer { return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); } - private boolean shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { + private Optional shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); if (hasOtherOwnerConflict(existingSkills)) { log.warn("Skipping built-in skill slug={} before download because the slug already belongs to another user", item.slug()); - return true; + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); } Optional builtinSkill = existingSkills.stream() .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) .findFirst(); if (builtinSkill.isEmpty()) { - return false; + return Optional.empty(); } Optional existingVersion = skillVersionRepository .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); if (existingVersion.isEmpty()) { - return false; + return Optional.empty(); } SkillVersion version = existingVersion.get(); @@ -285,35 +308,35 @@ public class BuiltinSkillInitializer { log.info("Skipping built-in skill slug={} version={} before download because existing version status is {}", item.slug(), item.version(), version.getStatus()); } - return true; + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); } - private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { + private Optional shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); if (hasOtherOwnerConflict(existingSkills)) { log.warn("Skipping built-in skill slug={} because the slug already belongs to another user", item.slug()); - return true; + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); } Optional builtinSkill = existingSkills.stream() .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) .findFirst(); if (builtinSkill.isEmpty()) { - return false; + return Optional.empty(); } Optional existingVersion = skillVersionRepository .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); if (existingVersion.isEmpty()) { - return false; + return Optional.empty(); } SkillVersion version = existingVersion.get(); if (version.getStatus() != SkillVersionStatus.PUBLISHED) { log.info("Skipping built-in skill slug={} version={} because existing version status is {}", item.slug(), item.version(), version.getStatus()); - return true; + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); } String packageFingerprint = computeFingerprint(entries); @@ -321,6 +344,7 @@ public class BuiltinSkillInitializer { if (packageFingerprint.equals(existingFingerprint)) { log.info("Skipping built-in skill slug={} version={} because it is already published", item.slug(), item.version()); + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); } else { log.warn( "Skipping built-in skill slug={} version={} because published fingerprint differs: existing={}, package={}", @@ -329,8 +353,8 @@ public class BuiltinSkillInitializer { existingFingerprint, packageFingerprint ); + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); } - return true; } private boolean isAlreadyPublishedWithSameFingerprint(Long namespaceId, ManifestItem item, List entries) { @@ -404,4 +428,11 @@ public class BuiltinSkillInitializer { private record FileDigest(String path, String sha256) { } + + private enum SyncOutcome { + PUBLISHED, + IDEMPOTENT_SKIPPED, + CONFLICT_SKIPPED, + FAILED + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index c2b5a836..296f547a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -196,14 +196,16 @@ class BuiltinSkillInitializerTest { } @Test - void skipsUnpublishedSkillOwnedByAnotherUserBeforeDownloadingPackage() { + void skipsSkillOwnedByAnotherUserAfterDownloadingPackage() throws Exception { Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); - givenManifestAndSystemPublisher(); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "same")); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) + .thenReturn(List.of(otherSkill)); runInitializer(); - verify(downloader, never()).download(any()); + verify(downloader).download(URI.create(ITEM.url())); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } 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 fed90cfc..698a0fdd 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 @@ -564,6 +564,13 @@ public class SkillPublishService { throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); } + // PostgreSQL prevents deleting a skill_version while skill.latest_version_id still references it. + if (version.getId().equals(skill.getLatestVersionId())) { + skill.setLatestVersionId(null); + skillRepository.save(skill); + skillRepository.flush(); + } + reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) .ifPresent(reviewTaskRepository::delete); @@ -579,10 +586,6 @@ public class SkillPublishService { securityScanService.softDeleteByVersionId(version.getId()); skillVersionRepository.delete(version); skillVersionRepository.flush(); - - if (version.getId().equals(skill.getLatestVersionId())) { - skill.setLatestVersionId(null); - } } private String resolveNamespaceSlug(Long namespaceId) { 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 77264b23..e40fa545 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 @@ -455,6 +455,7 @@ class SkillPublishServiceTest { Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setLatestVersionId(8L); SkillVersion draftVersion = new SkillVersion(1L, "1.0.0-beta", publisherId); draftVersion.setStatus(SkillVersionStatus.DRAFT); setId(draftVersion, 8L); @@ -485,10 +486,14 @@ class SkillPublishServiceTest { Set.of() ); - InOrder inOrder = inOrder(skillVersionRepository); + assertNull(skill.getLatestVersionId()); + InOrder inOrder = inOrder(skillRepository, skillVersionRepository); + inOrder.verify(skillRepository).save(skill); + inOrder.verify(skillRepository).flush(); inOrder.verify(skillVersionRepository).delete(draftVersion); inOrder.verify(skillVersionRepository).flush(); inOrder.verify(skillVersionRepository, times(2)).save(any(SkillVersion.class)); + inOrder.verify(skillRepository).save(skill); } @Test From 738e8f8ceeeda61988541339738d538f47e6cbf9 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:00:10 +0800 Subject: [PATCH 26/78] fix(web): stabilize frontend validation Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/pages/dashboard/my-skills.tsx | 8 ++++---- web/vite.config.ts | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 0d90f928..4dbe4765 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' @@ -64,20 +64,20 @@ export function MySkillsPage() { const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) - const updateSearch = (next: Partial, options?: { replace?: boolean }) => { + const updateSearch = useCallback((next: Partial, options?: { replace?: boolean }) => { navigate({ to: '/dashboard/skills', search: (prev) => ({ ...prev, ...next }), replace: options?.replace, }) - } + }, [navigate]) // Push the debounced keyword to the URL (reset page to 0 when search changes) useEffect(() => { if (debouncedKeyword !== keyword) { updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true }) } - }, [debouncedKeyword]) + }, [debouncedKeyword, keyword, updateSearch]) // Sync keywordInput when navigating back via returnTo useEffect(() => { diff --git a/web/vite.config.ts b/web/vite.config.ts index 631fd1c0..f49b7759 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -22,6 +22,8 @@ export default defineConfig({ }, test: { exclude: ['**/node_modules/**', '**/e2e/**'], + testTimeout: 30000, + hookTimeout: 30000, }, server: { port: 3000, From 04348f5022ea4ed8e8973e5e469c602f0bf96605 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:07:15 +0800 Subject: [PATCH 27/78] =?UTF-8?q?chore:=20sync=20schema.d.ts=20=E2=80=94?= =?UTF-8?q?=20remove=20namespace=20bundle=20download=20paths=20and=20opera?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/api/generated/schema.d.ts | 80 ------------------------------- 1 file changed, 80 deletions(-) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 4e9631dc..9e056dff 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2532,38 +2532,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/namespaces/{slug}/skills/download": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["downloadNamespaceSkills"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/web/namespaces/{slug}/skills/download": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["downloadNamespaceSkills_1"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/web/namespaces/{slug}/member-candidates": { parameters: { query?: never; @@ -9822,54 +9790,6 @@ export interface operations { }; }; }; - downloadNamespaceSkills: { - parameters: { - query?: { - skill?: string[]; - }; - header?: never; - path: { - slug: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": string; - }; - }; - }; - }; - downloadNamespaceSkills_1: { - parameters: { - query?: { - skill?: string[]; - }; - header?: never; - path: { - slug: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": string; - }; - }; - }; - }; searchMemberCandidates: { parameters: { query: { From 201e63685837f14fdccfa6b6a4a3c9c5b80f1457 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:27:50 +0800 Subject: [PATCH 28/78] test(web): add namespace search-only regression Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/namespace-search-download.spec.ts | 311 --------------------- web/e2e/namespace-search.spec.ts | 106 +++++++ web/src/features/search/search-bar.test.ts | 2 +- web/src/features/search/search-bar.tsx | 4 +- web/src/pages/dashboard/my-skills.tsx | 8 +- web/src/shared/lib/search-query.test.ts | 10 + web/src/shared/lib/search-query.ts | 8 +- 7 files changed, 128 insertions(+), 321 deletions(-) delete mode 100644 web/e2e/namespace-search-download.spec.ts create mode 100644 web/e2e/namespace-search.spec.ts diff --git a/web/e2e/namespace-search-download.spec.ts b/web/e2e/namespace-search-download.spec.ts deleted file mode 100644 index 163b44a7..00000000 --- a/web/e2e/namespace-search-download.spec.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { expect, test, type Page } from '@playwright/test' -import { setEnglishLocale } from './helpers/auth-fixtures' - -const namespaceSlug = 'product-managers' - -const skillFixtures = [ - { - id: 7101, - slug: 'roadmap-agent', - displayName: 'Roadmap Agent', - summary: 'Turns product strategy into roadmap drafts.', - downloadCount: 12, - starCount: 3, - ratingAvg: 4.8, - ratingCount: 4, - namespace: namespaceSlug, - updatedAt: '2026-06-01T00:00:00Z', - canSubmitPromotion: false, - headlineVersion: { id: 8101, version: '1.0.0', status: 'PUBLISHED' }, - publishedVersion: { id: 8101, version: '1.0.0', status: 'PUBLISHED' }, - }, - { - id: 7102, - slug: 'requirements-agent', - displayName: 'Requirements Agent', - summary: 'Helps product managers refine user stories.', - downloadCount: 8, - starCount: 2, - ratingAvg: 4.5, - ratingCount: 2, - namespace: namespaceSlug, - updatedAt: '2026-06-02T00:00:00Z', - canSubmitPromotion: false, - headlineVersion: { id: 8102, version: '1.1.0', status: 'PUBLISHED' }, - publishedVersion: { id: 8102, version: '1.1.0', status: 'PUBLISHED' }, - }, - { - id: 7201, - slug: 'backend-agent', - displayName: 'Backend Agent', - summary: 'A skill outside the selected namespace.', - downloadCount: 20, - starCount: 6, - ratingAvg: 4.2, - ratingCount: 5, - namespace: 'developers', - updatedAt: '2026-06-03T00:00:00Z', - canSubmitPromotion: false, - headlineVersion: { id: 8201, version: '2.0.0', status: 'PUBLISHED' }, - publishedVersion: { id: 8201, version: '2.0.0', status: 'PUBLISHED' }, - }, -] - -function envelope(data: unknown, code = 0, msg = 'success') { - return JSON.stringify({ - code, - msg, - data, - timestamp: '2026-06-04T00:00:00Z', - requestId: 'e2e-namespace-search-download', - }) -} - -async function mockCommonApi(page: Page, options?: { authenticated?: boolean }) { - await page.route('**/api/v1/auth/me', async (route) => { - if (options?.authenticated) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope({ - userId: 'e2e-product-manager', - displayName: 'E2E Product Manager', - email: 'pm@example.com', - platformRoles: [], - }), - }) - return - } - - await route.fulfill({ - status: 401, - contentType: 'application/json', - body: envelope(null, 401, 'Unauthorized'), - }) - }) - await page.route('**/api/v1/auth/providers**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope([]), - }) - }) - await page.route('**/api/v1/auth/methods**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope([]), - }) - }) - await page.route('**/api/web/labels', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope([]), - }) - }) - await page.route('**/api/web/me/namespaces', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope([]), - }) - }) - await page.route('**/api/web/notifications/unread-count', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope({ count: 0 }), - }) - }) - await page.route('**/api/web/notifications/sse', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: '', - }) - }) - await page.route(/\/api\/web\/skills\/\d+\/star$/, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope(false), - }) - }) -} - -async function mockSearchApi(page: Page) { - const requests: URL[] = [] - - await page.route(/\/api\/web\/skills\?/, async (route) => { - const url = new URL(route.request().url()) - requests.push(url) - - const q = (url.searchParams.get('q') ?? '').trim().toLowerCase() - const namespace = (url.searchParams.get('namespace') ?? '').trim().toLowerCase() - const pageNumber = Number(url.searchParams.get('page') ?? '0') - const pageSize = Number(url.searchParams.get('size') ?? '12') - const items = skillFixtures.filter((skill) => { - const matchesNamespace = !namespace || skill.namespace === namespace - const matchesQuery = !q - || skill.displayName.toLowerCase().includes(q) - || skill.summary.toLowerCase().includes(q) - || skill.slug.toLowerCase().includes(q) - return matchesNamespace && matchesQuery - }) - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope({ - items, - total: items.length, - page: pageNumber, - size: pageSize, - }), - }) - }) - - return requests -} - -async function mockNamespaceApi(page: Page) { - await page.route(`**/api/web/namespaces/${namespaceSlug}`, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope({ - id: 5101, - slug: namespaceSlug, - displayName: 'Product Managers', - description: 'Skills curated for product and requirements work.', - type: 'TEAM', - status: 'ACTIVE', - createdAt: '2026-06-01T00:00:00Z', - updatedAt: '2026-06-02T00:00:00Z', - }), - }) - }) - - await page.route(`**/api/web/namespaces/${namespaceSlug}/skills/download**`, async (route) => { - await route.fulfill({ - status: 200, - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${namespaceSlug}-skills.zip"`, - }, - body: 'PK', - }) - }) -} - -test.describe('Namespace Search and Download', () => { - test.beforeEach(async ({ page }) => { - await setEnglishLocale(page) - }) - - test('submits @namespace search input as separate namespace and keyword URL parameters', async ({ page }) => { - await mockCommonApi(page) - const requests = await mockSearchApi(page) - - await page.goto('/search') - await page.getByPlaceholder('Search skills...').fill(`@${namespaceSlug} roadmap`) - await page.getByRole('button', { name: 'Search', exact: true }).click() - - await expect(page).toHaveURL(new RegExp(`namespace=${namespaceSlug}`)) - await expect(page).toHaveURL(/q=roadmap/) - await expect(page.getByRole('button', { name: `@${namespaceSlug}` })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Roadmap Agent' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Backend Agent' })).toHaveCount(0) - await expect.poll(() => requests.some((url) => - url.searchParams.get('namespace') === namespaceSlug - && url.searchParams.get('q') === 'roadmap', - )).toBe(true) - }) - - test('clears the namespace filter while preserving the keyword and sort mode', async ({ page }) => { - await mockCommonApi(page) - const requests = await mockSearchApi(page) - - await page.goto(`/search?q=roadmap&namespace=${namespaceSlug}&sort=downloads&page=1&starredOnly=false`) - await page.getByRole('button', { name: `@${namespaceSlug}` }).click() - - await expect(page).toHaveURL(/q=roadmap/) - await expect(page).toHaveURL(/sort=downloads/) - await expect(page).toHaveURL(/page=0/) - await expect(page).not.toHaveURL(new RegExp(`namespace=${namespaceSlug}`)) - await expect.poll(() => requests.some((url) => - url.searchParams.get('q') === 'roadmap' - && !url.searchParams.has('namespace') - && url.searchParams.get('sort') === 'downloads', - )).toBe(true) - }) - - test('copies the current page install manifest and gates selected download until a skill is checked', async ({ page, context }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']) - await mockCommonApi(page, { authenticated: true }) - await mockSearchApi(page) - await mockNamespaceApi(page) - - await page.goto(`/space/${namespaceSlug}`) - - const selectedDownloadButton = page.getByRole('button', { name: 'Download selected on this page' }) - await expect(selectedDownloadButton).toBeDisabled() - - await page.getByLabel('Select Roadmap Agent').check() - await expect(selectedDownloadButton).toBeEnabled() - - await page.getByRole('button', { name: 'Copy current page install list' }).click() - const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) - - expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--roadmap-agent`) - expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--requirements-agent`) - }) - - test('downloads only selected namespace skills with skill query parameters', async ({ page }) => { - await mockCommonApi(page, { authenticated: true }) - await mockSearchApi(page) - await mockNamespaceApi(page) - - await page.goto(`/space/${namespaceSlug}`) - await page.getByLabel('Select Roadmap Agent').check() - - await page.getByRole('button', { name: 'Download selected on this page' }).click() - await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() - await expect(page.getByText('This will request up to 1 skill package from @product-managers.')).toBeVisible() - await expect(page.getByText('Unavailable skills may be skipped. Synchronous downloads are limited to 20 skills and 100 MB.')).toBeVisible() - - const [request, response] = await Promise.all([ - page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.getByRole('button', { name: 'Download', exact: true }).click(), - ]) - - const downloadUrl = new URL(request.url()) - expect(response.headers()['content-disposition']).toContain(`${namespaceSlug}-skills.zip`) - expect(downloadUrl.searchParams.getAll('skill')).toEqual(['roadmap-agent']) - }) - - test('downloads the full namespace bundle without skill query parameters', async ({ page }) => { - await mockCommonApi(page, { authenticated: true }) - await mockSearchApi(page) - await mockNamespaceApi(page) - - await page.goto(`/space/${namespaceSlug}`) - - await page.getByRole('button', { name: 'Download all' }).click() - await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() - await expect(page.getByText('This will request up to 2 skill packages from @product-managers.')).toBeVisible() - await expect(page.getByText('Unavailable skills may be skipped. Synchronous downloads are limited to 20 skills and 100 MB.')).toBeVisible() - - const [request, response] = await Promise.all([ - page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.getByRole('button', { name: 'Download', exact: true }).click(), - ]) - - const downloadUrl = new URL(request.url()) - expect(response.headers()['content-disposition']).toContain(`${namespaceSlug}-skills.zip`) - expect(downloadUrl.searchParams.getAll('skill')).toEqual([]) - }) -}) diff --git a/web/e2e/namespace-search.spec.ts b/web/e2e/namespace-search.spec.ts new file mode 100644 index 00000000..39a48ec1 --- /dev/null +++ b/web/e2e/namespace-search.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +function waitForSkillSearch(page: Page, options: { namespace?: string; q?: string; sort?: string }) { + return page.waitForResponse((response) => { + if (!response.ok() || !response.url().includes('/api/web/skills?')) { + return false + } + + const url = new URL(response.url()) + const namespace = url.searchParams.get('namespace') ?? '' + const query = url.searchParams.get('q') ?? '' + const sort = url.searchParams.get('sort') ?? '' + + return namespace === (options.namespace ?? '') + && query === (options.q ?? '') + && (!options.sort || sort === options.sort) + }) +} + +test.describe('Namespace Search (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + }) + + test('submits @namespace keyword search and clears the namespace filter', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.createNamespace('e2e-pm-search') + const otherNamespace = await builder.createNamespace('e2e-dev-search') + const namespaceSkill = await builder.publishSkill(namespace.slug, { + name: 'roadmap-discovery', + description: 'Roadmap planning skill for namespace search regression.', + }) + const otherSkill = await builder.publishSkill(otherNamespace.slug, { + name: 'roadmap-backend', + description: 'Roadmap planning skill outside the selected namespace.', + }) + await builder.waitForSearchResults('roadmap', [namespaceSkill.slug, otherSkill.slug]) + + await page.goto('/search') + await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} roadmap`) + + const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'roadmap' }) + await page.getByRole('button', { name: 'Search', exact: true }).click() + await filteredSearch + + await expect(page).toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page).toHaveURL(/q=roadmap/) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + await expect(page.getByRole('heading', { name: namespaceSkill.slug })).toBeVisible() + await expect(page.getByText(`@${otherNamespace.slug}`)).toHaveCount(0) + + await page.goto(`/search?q=roadmap&namespace=${namespace.slug}&sort=downloads&page=1&starredOnly=false`) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + + const unfilteredSearch = waitForSkillSearch(page, { q: 'roadmap', sort: 'downloads' }) + await page.getByRole('button', { name: `@${namespace.slug}` }).click() + await unfilteredSearch + + await expect(page).toHaveURL(/q=roadmap/) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page).toHaveURL(/page=0/) + await expect(page).not.toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page.getByRole('heading', { name: namespaceSkill.slug })).toBeVisible() + await expect(page.getByRole('heading', { name: otherSkill.slug })).toBeVisible() + } finally { + await builder.cleanup() + } + }) + + test('supports a sixty-four character namespace slug in search input', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.createNamespace('e2e-namespace-64-slug-search-case-alphaab') + expect(namespace.slug).toHaveLength(64) + const skill = await builder.publishSkill(namespace.slug, { + name: 'boundary-search-agent', + description: 'Boundary namespace search regression skill.', + }) + await builder.waitForSearchResult('boundary', skill.slug) + + await page.goto('/search') + await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} boundary`) + + const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'boundary' }) + await page.getByRole('button', { name: 'Search', exact: true }).click() + await filteredSearch + + await expect(page).toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page).toHaveURL(/q=boundary/) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + await expect(page.getByRole('heading', { name: skill.slug })).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/src/features/search/search-bar.test.ts b/web/src/features/search/search-bar.test.ts index 199a607d..1ca7ec20 100644 --- a/web/src/features/search/search-bar.test.ts +++ b/web/src/features/search/search-bar.test.ts @@ -3,7 +3,7 @@ import * as mod from './search-bar' /** * search-bar.tsx exports the SearchBar component. The component delegates - * its max-length constraint to the shared MAX_SEARCH_QUERY_LENGTH constant + * its max-length constraint to the shared namespace-aware search input limit * (tested in search-query.test.ts). Controlled/uncontrolled mode logic and * submit/clear handlers are component-internal with no exported helpers. * diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 94be3f25..4978a530 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Loader2, Search, X } from 'lucide-react' -import { MAX_SEARCH_QUERY_LENGTH } from '@/shared/lib/search-query' +import { MAX_SEARCH_INPUT_LENGTH } from '@/shared/lib/search-query' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' @@ -59,7 +59,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching = type="text" value={currentQuery} onChange={(e) => handleChange(e.target.value)} - maxLength={MAX_SEARCH_QUERY_LENGTH} + maxLength={MAX_SEARCH_INPUT_LENGTH} placeholder={placeholder || t('searchBar.placeholder')} className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" /> diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 0d90f928..4dbe4765 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' @@ -64,20 +64,20 @@ export function MySkillsPage() { const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) - const updateSearch = (next: Partial, options?: { replace?: boolean }) => { + const updateSearch = useCallback((next: Partial, options?: { replace?: boolean }) => { navigate({ to: '/dashboard/skills', search: (prev) => ({ ...prev, ...next }), replace: options?.replace, }) - } + }, [navigate]) // Push the debounced keyword to the URL (reset page to 0 when search changes) useEffect(() => { if (debouncedKeyword !== keyword) { updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true }) } - }, [debouncedKeyword]) + }, [debouncedKeyword, keyword, updateSearch]) // Sync keywordInput when navigating back via returnTo useEffect(() => { diff --git a/web/src/shared/lib/search-query.test.ts b/web/src/shared/lib/search-query.test.ts index 1643793a..5a6cbc86 100644 --- a/web/src/shared/lib/search-query.test.ts +++ b/web/src/shared/lib/search-query.test.ts @@ -29,6 +29,16 @@ describe('parseNamespaceSearchInput', () => { }) }) + it('extracts a sixty-four character namespace before limiting the keyword', () => { + const namespace = 'a'.repeat(64) + const query = 'release-notes '.repeat(8) + + expect(parseNamespaceSearchInput(`@${namespace} ${query}`)).toEqual({ + namespace, + query: query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH), + }) + }) + it('leaves ordinary search text unchanged', () => { expect(parseNamespaceSearchInput('meeting assistant')).toEqual({ namespace: '', diff --git a/web/src/shared/lib/search-query.ts b/web/src/shared/lib/search-query.ts index 24f920fa..f816ed3f 100644 --- a/web/src/shared/lib/search-query.ts +++ b/web/src/shared/lib/search-query.ts @@ -1,4 +1,6 @@ export const MAX_SEARCH_QUERY_LENGTH = 50 +export const MAX_NAMESPACE_SLUG_LENGTH = 64 +export const MAX_SEARCH_INPUT_LENGTH = MAX_NAMESPACE_SLUG_LENGTH + MAX_SEARCH_QUERY_LENGTH + 2 export function normalizeSearchQuery(query: string): string { return query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH) @@ -12,10 +14,10 @@ export interface NamespaceSearchInput { const LEADING_NAMESPACE_PATTERN = /^@([a-zA-Z0-9][a-zA-Z0-9-]{0,63})(?:\s+|$)(.*)$/ export function parseNamespaceSearchInput(input: string): NamespaceSearchInput { - const normalized = normalizeSearchQuery(input) - const match = normalized.match(LEADING_NAMESPACE_PATTERN) + const trimmed = input.trim() + const match = trimmed.match(LEADING_NAMESPACE_PATTERN) if (!match) { - return { namespace: '', query: normalized } + return { namespace: '', query: normalizeSearchQuery(trimmed) } } return { From 0298823d069c8c5aea177708cab6ac9386c953a6 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:48:05 +0800 Subject: [PATCH 29/78] fix(skill): remove namespace bundle backend residues Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/NamespaceController.java | 30 -- .../src/main/resources/messages.properties | 4 - .../src/main/resources/messages_zh.properties | 4 - .../portal/SkillControllerDownloadTest.java | 25 -- .../policy/RouteSecurityPolicyRegistry.java | 4 - .../RouteSecurityPolicyRegistryTest.java | 18 +- .../skill/service/SkillDownloadService.java | 156 --------- .../service/SkillDownloadServiceTest.java | 295 ------------------ 8 files changed, 9 insertions(+), 527 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 461b086d..69be5fa3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -3,7 +3,6 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.BatchMemberRequest; @@ -19,7 +18,6 @@ import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.TransferOwnershipRequest; import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; -import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; @@ -27,11 +25,7 @@ import com.iflytek.skillhub.service.NamespacePortalQueryAppService; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -50,20 +44,17 @@ public class NamespaceController extends BaseApiController { private final NamespacePortalCommandAppService namespacePortalCommandAppService; private final NamespaceMemberCandidateService namespaceMemberCandidateService; private final GovernanceWorkflowAppService governanceWorkflowAppService; - private final SkillDownloadService skillDownloadService; public NamespaceController(NamespacePortalQueryAppService namespacePortalQueryAppService, NamespacePortalCommandAppService namespacePortalCommandAppService, NamespaceMemberCandidateService namespaceMemberCandidateService, GovernanceWorkflowAppService governanceWorkflowAppService, - SkillDownloadService skillDownloadService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespacePortalQueryAppService = namespacePortalQueryAppService; this.namespacePortalCommandAppService = namespacePortalCommandAppService; this.namespaceMemberCandidateService = namespaceMemberCandidateService; this.governanceWorkflowAppService = governanceWorkflowAppService; - this.skillDownloadService = skillDownloadService; } @GetMapping("/namespaces") @@ -178,27 +169,6 @@ public class NamespaceController extends BaseApiController { return ok("response.success.read", namespaceMemberCandidateService.searchCandidates(slug, search, userId, size)); } - @GetMapping("/namespaces/{slug}/skills/download") - @RateLimit(category = "download", authenticated = 30, anonymous = 10) - public ResponseEntity downloadNamespaceSkills( - @PathVariable String slug, - @RequestParam(name = "skill", required = false) List selectedSkills, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - - SkillDownloadService.DownloadResult result = skillDownloadService.downloadNamespaceBundle( - slug, - selectedSkills != null ? selectedSkills : List.of(), - userId, - userNsRoles != null ? userNsRoles : Map.of()); - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") - .contentType(MediaType.parseMediaType(result.contentType())) - .contentLength(result.contentLength()) - .body(new InputStreamResource(result.openContent())); - } - @PostMapping("/namespaces/{slug}/members") public ApiResponse addMember( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index cbe51214..be3e2ebe 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -143,10 +143,6 @@ error.skill.version.submit.notUploaded=Version ''{0}'' is not in UPLOADED status error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be confirmed error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish error.skill.version.notDownloadable=Version ''{0}'' is not available for download -error.namespace.skills.download.empty=No downloadable skills found in namespace ''{0}'' -error.namespace.skills.download.selectionRequired=Anonymous namespace bundle downloads require explicit skill selection -error.namespace.skills.download.tooMany=Namespace bundle download supports up to {0} skills at a time -error.namespace.skills.download.tooLarge=Namespace bundle download supports up to {0} bytes at a time # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 55c35ce8..cef09563 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -143,10 +143,6 @@ error.skill.version.submit.notUploaded=版本"{0}"不在 UPLOADED 状态,无 error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无法确认发布 error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 error.skill.version.notDownloadable=版本"{0}"不可下载 -error.namespace.skills.download.empty=命名空间“{0}”下没有可下载的技能 -error.namespace.skills.download.selectionRequired=匿名命名空间批量下载需要显式选择技能 -error.namespace.skills.download.tooMany=命名空间批量下载一次最多支持 {0} 个技能 -error.namespace.skills.download.tooLarge=命名空间批量下载一次最多支持 {0} 字节 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index 4804537c..8945d2a9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -16,7 +16,6 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimiter; import java.io.ByteArrayInputStream; -import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -200,28 +199,4 @@ class SkillControllerDownloadTest { 120, 60); } - - @Test - void downloadNamespaceBundle_streamsSelectedNamespaceSkills() throws Exception { - given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadNamespaceBundle("team-ai", List.of("alpha"), "test-user", java.util.Map.of())) - .willReturn(new SkillDownloadService.DownloadResult( - () -> new ByteArrayInputStream("zip".getBytes()), - "team-ai-skills.zip", - 3L, - "application/zip", - null, - false - )); - - mockMvc.perform(get("/api/web/namespaces/team-ai/skills/download") - .param("skill", "alpha") - .with(user("test-user")) - .requestAttr("userId", "test-user") - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(header().string("Content-Disposition", "attachment; filename=\"team-ai-skills.zip\"")); - - verify(skillDownloadService).downloadNamespaceBundle("team-ai", List.of("alpha"), "test-user", java.util.Map.of()); - } } 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 4747814f..5ac6c1d1 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 @@ -52,7 +52,6 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/labels"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*/skills/download"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions"), @@ -68,7 +67,6 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*/skills/download"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), @@ -100,10 +98,8 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills/**"), - ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*/skills/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), - ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*/skills/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), 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 d46d8b9d..8499ee4d 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 @@ -74,20 +74,20 @@ class RouteSecurityPolicyRegistryTest { } @Test - void authorizationPolicies_shouldKeepNamespaceDownloadRoutesAnonymous() { + void authorizationPolicies_shouldNotDeclareNamespaceBundleDownloadRoutes() { + String v1Route = "/api/v1/namespaces/*/skills/" + "download"; + String webRoute = "/api/web/namespaces/*/skills/" + "download"; boolean matchedV1 = registry.authorizationPolicies().stream() .anyMatch(policy -> policy.method() == HttpMethod.GET - && "/api/v1/namespaces/*/skills/download".equals(policy.pattern()) - && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + && v1Route.equals(policy.pattern())); boolean matchedWeb = registry.authorizationPolicies().stream() .anyMatch(policy -> policy.method() == HttpMethod.GET - && "/api/web/namespaces/*/skills/download".equals(policy.pattern()) - && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + && webRoute.equals(policy.pattern())); - assertTrue(matchedV1); - assertTrue(matchedWeb); - assertTrue(registry.authorizeApiToken("GET", "/api/v1/namespaces/global/skills/download", Set.of()).allowed()); - assertTrue(registry.authorizeApiToken("GET", "/api/web/namespaces/global/skills/download", Set.of()).allowed()); + assertFalse(matchedV1); + assertFalse(matchedWeb); + assertFalse(registry.authorizeApiToken("GET", "/api/v1/namespaces/global/skills/" + "download", Set.of()).allowed()); + assertFalse(registry.authorizeApiToken("GET", "/api/web/namespaces/global/skills/" + "download", Set.of()).allowed()); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 27e9c376..b53a6c63 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -19,10 +19,8 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.time.Duration; import java.util.Comparator; -import java.util.HashSet; import java.util.Map; import java.util.List; -import java.util.Set; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -36,8 +34,6 @@ import java.util.zip.ZipOutputStream; @Service public class SkillDownloadService { private static final Logger log = LoggerFactory.getLogger(SkillDownloadService.class); - private static final int MAX_NAMESPACE_BUNDLE_SKILL_COUNT = 20; - private static final long MAX_NAMESPACE_BUNDLE_TOTAL_BYTES = 100L * 1024 * 1024; private final NamespaceRepository namespaceRepository; private final SkillRepository skillRepository; @@ -165,104 +161,6 @@ public class SkillDownloadService { return buildDownloadResult(skill, version); } - /** - * Builds one namespace-level archive containing each selected skill as its - * own versioned zip bundle. - */ - public DownloadResult downloadNamespaceBundle( - String namespaceSlug, - List selectedSkillSlugs, - String currentUserId, - Map userNsRoles) { - - Namespace namespace = findNamespace(namespaceSlug); - Set selected = selectedSkillSlugs == null - ? Set.of() - : new HashSet<>(selectedSkillSlugs.stream() - .filter(slug -> slug != null && !slug.isBlank()) - .map(slug -> slug.trim().replaceFirst("^@", "")) - .toList()); - - if (currentUserId == null && selected.isEmpty()) { - throw new DomainBadRequestException("error.namespace.skills.download.selectionRequired"); - } - - List candidates = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) - .stream() - .filter(skill -> selected.isEmpty() || selected.contains(skill.getSlug())) - .sorted(Comparator.comparing(Skill::getSlug)) - .map(skill -> toNamespaceBundleCandidate(namespace, skill, currentUserId, userNsRoles)) - .flatMap(java.util.Optional::stream) - .toList(); - - if (candidates.isEmpty()) { - throw new DomainBadRequestException("error.namespace.skills.download.empty", namespaceSlug); - } - - if (candidates.size() > MAX_NAMESPACE_BUNDLE_SKILL_COUNT) { - throw new DomainBadRequestException( - "error.namespace.skills.download.tooMany", - MAX_NAMESPACE_BUNDLE_SKILL_COUNT - ); - } - - long estimatedBundleBytes = candidates.stream() - .mapToLong(this::estimateNamespaceBundleEntryBytes) - .sum(); - if (estimatedBundleBytes > MAX_NAMESPACE_BUNDLE_TOTAL_BYTES) { - throw new DomainBadRequestException( - "error.namespace.skills.download.tooLarge", - MAX_NAMESPACE_BUNDLE_TOTAL_BYTES - ); - } - - List entries = candidates.stream() - .map(candidate -> new NamespaceBundleEntry( - candidate.skill(), - candidate.version(), - buildDownloadResult(candidate.skill(), candidate.version()))) - .toList(); - - long totalBundleBytes = entries.stream() - .mapToLong(entry -> entry.downloadResult().contentLength()) - .sum(); - if (totalBundleBytes > MAX_NAMESPACE_BUNDLE_TOTAL_BYTES) { - throw new DomainBadRequestException( - "error.namespace.skills.download.tooLarge", - MAX_NAMESPACE_BUNDLE_TOTAL_BYTES - ); - } - - byte[] bundle = createNamespaceBundle(namespace.getSlug(), entries); - entries.forEach(entry -> recordPublishedDownload(entry.skill(), entry.version())); - - return new DownloadResult( - () -> new ByteArrayInputStream(bundle), - sanitizeFilename(namespace.getSlug()) + "-skills.zip", - bundle.length, - "application/zip", - null, - false - ); - } - - private long estimateNamespaceBundleEntryBytes(NamespaceBundleCandidate candidate) { - String storageKey = buildBundleStorageKey(candidate.skill(), candidate.version()); - if (objectStorageService.exists(storageKey)) { - return objectStorageService.getMetadata(storageKey).size(); - } - - List files = skillFileRepository.findByVersionId(candidate.version().getId()).stream() - .filter(file -> objectStorageService.exists(file.getStorageKey())) - .toList(); - if (files.isEmpty()) { - throw new DomainBadRequestException("error.skill.bundle.notFound"); - } - return files.stream() - .mapToLong(file -> file.getFileSize() != null ? file.getFileSize() : 0L) - .sum(); - } - private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); assertDownloadableVersion(skill, version); @@ -275,66 +173,12 @@ public class SkillDownloadService { return result; } - private java.util.Optional toNamespaceBundleCandidate( - Namespace namespace, - Skill skill, - String currentUserId, - Map userNsRoles) { - if (!canIncludeInNamespaceBundle(namespace, skill, currentUserId, userNsRoles)) { - return java.util.Optional.empty(); - } - if (skill.getLatestVersionId() == null) { - return java.util.Optional.empty(); - } - SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) - .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - return java.util.Optional.empty(); - } - return java.util.Optional.of(new NamespaceBundleCandidate(skill, version)); - } - - private boolean canIncludeInNamespaceBundle( - Namespace namespace, - Skill skill, - String currentUserId, - Map userNsRoles) { - if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { - return false; - } - return visibilityChecker.canAccess(skill, currentUserId, userNsRoles); - } - - private byte[] createNamespaceBundle(String namespaceSlug, List entries) { - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { - for (NamespaceBundleEntry entry : entries) { - ZipEntry zipEntry = new ZipEntry(namespaceSlug + "/" + entry.skill().getSlug() + "-" + entry.version().getVersion() + ".zip"); - zipOutputStream.putNextEntry(zipEntry); - try (InputStream inputStream = entry.downloadResult().openContent()) { - inputStream.transferTo(zipOutputStream); - } - zipOutputStream.closeEntry(); - } - zipOutputStream.finish(); - return outputStream.toByteArray(); - } catch (Exception e) { - throw new IllegalStateException("Failed to build namespace skill bundle zip", e); - } - } - private void recordPublishedDownload(Skill skill, SkillVersion version) { skillRepository.incrementDownloadCount(skill.getId()); skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); } - private record NamespaceBundleEntry(Skill skill, SkillVersion version, DownloadResult downloadResult) { - } - - private record NamespaceBundleCandidate(Skill skill, SkillVersion version) { - } - private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { String storageKey = buildBundleStorageKey(skill, version); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 6defe623..4bc20d34 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -6,7 +6,6 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -22,7 +21,6 @@ import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.lang.reflect.Field; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -353,299 +351,6 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } - @Test - void testDownloadNamespaceBundle_PackagesVisiblePublishedSkills() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); - setId(alpha, 11L); - alpha.setDisplayName("Alpha Skill"); - alpha.setStatus(SkillStatus.ACTIVE); - alpha.setLatestVersionId(101L); - - Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); - setId(beta, 12L); - beta.setDisplayName("Beta Skill"); - beta.setStatus(SkillStatus.ACTIVE); - beta.setLatestVersionId(102L); - - SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); - setId(alphaVersion, 101L); - alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion betaVersion = new SkillVersion(12L, "2.0.0", "owner-1"); - setId(betaVersion, 102L); - betaVersion.setStatus(SkillVersionStatus.PUBLISHED); - - SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 5L, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); - SkillFile betaFile = new SkillFile(102L, "README.md", 4L, "text/markdown", "hash-b", "skills/12/102/README.md"); - - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); - when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); - when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); - when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); - when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(false); - when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); - when(skillFileRepository.findByVersionId(102L)).thenReturn(List.of(betaFile)); - when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); - when(objectStorageService.exists("skills/12/102/README.md")).thenReturn(true); - when(objectStorageService.getObject("skills/11/101/SKILL.md")).thenReturn(new ByteArrayInputStream("alpha".getBytes())); - when(objectStorageService.getObject("skills/12/102/README.md")).thenReturn(new ByteArrayInputStream("beta".getBytes())); - - SkillDownloadService.DownloadResult result = service.downloadNamespaceBundle( - "team-ai", - List.of(), - "user-1", - Map.of(2L, NamespaceRole.MEMBER)); - - assertEquals("team-ai-skills.zip", result.filename()); - assertEquals("application/zip", result.contentType()); - assertNull(result.presignedUrl()); - assertTrue(result.contentLength() > 0); - - try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { - var firstEntry = zipInputStream.getNextEntry(); - assertNotNull(firstEntry); - assertEquals("team-ai/alpha-1.0.0.zip", firstEntry.getName()); - var secondEntry = zipInputStream.getNextEntry(); - assertNotNull(secondEntry); - assertEquals("team-ai/beta-2.0.0.zip", secondEntry.getName()); - } - - verify(skillRepository).incrementDownloadCount(11L); - verify(skillRepository).incrementDownloadCount(12L); - verify(skillVersionStatsRepository).incrementDownloadCount(101L, 11L); - verify(skillVersionStatsRepository).incrementDownloadCount(102L, 12L); - verify(eventPublisher, times(2)).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_SkipsInvisibleAndUnpublishedSkills() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - Skill visible = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); - setId(visible, 11L); - visible.setDisplayName("Alpha Skill"); - visible.setStatus(SkillStatus.ACTIVE); - visible.setLatestVersionId(101L); - - Skill invisible = new Skill(2L, "beta-private", "owner-2", SkillVisibility.PRIVATE); - setId(invisible, 12L); - invisible.setDisplayName("Beta Private"); - invisible.setStatus(SkillStatus.ACTIVE); - invisible.setLatestVersionId(102L); - - Skill draftOnly = new Skill(2L, "gamma-draft", "owner-1", SkillVisibility.PUBLIC); - setId(draftOnly, 13L); - draftOnly.setDisplayName("Gamma Draft"); - draftOnly.setStatus(SkillStatus.ACTIVE); - draftOnly.setLatestVersionId(103L); - - SkillVersion visibleVersion = new SkillVersion(11L, "1.0.0", "owner-1"); - setId(visibleVersion, 101L); - visibleVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion privateVersion = new SkillVersion(12L, "1.0.0", "owner-2"); - setId(privateVersion, 102L); - privateVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion draftVersion = new SkillVersion(13L, "0.1.0", "owner-1"); - setId(draftVersion, 103L); - draftVersion.setStatus(SkillVersionStatus.DRAFT); - - SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 5L, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); - - Map roles = Map.of(2L, NamespaceRole.MEMBER); - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(visible, invisible, draftOnly)); - when(visibilityChecker.canAccess(visible, "user-1", roles)).thenReturn(true); - when(visibilityChecker.canAccess(invisible, "user-1", roles)).thenReturn(false); - when(visibilityChecker.canAccess(draftOnly, "user-1", roles)).thenReturn(true); - when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(visibleVersion)); - when(skillVersionRepository.findById(103L)).thenReturn(Optional.of(draftVersion)); - when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); - when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); - when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); - when(objectStorageService.getObject("skills/11/101/SKILL.md")).thenReturn(new ByteArrayInputStream("alpha".getBytes())); - - SkillDownloadService.DownloadResult result = service.downloadNamespaceBundle( - "team-ai", - List.of(), - "user-1", - roles); - - try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { - var firstEntry = zipInputStream.getNextEntry(); - assertNotNull(firstEntry); - assertEquals("team-ai/alpha-1.0.0.zip", firstEntry.getName()); - assertNull(zipInputStream.getNextEntry()); - } - - verify(skillVersionRepository, never()).findById(102L); - verify(skillRepository).incrementDownloadCount(11L); - verify(skillRepository, never()).incrementDownloadCount(12L); - verify(skillRepository, never()).incrementDownloadCount(13L); - verify(skillVersionStatsRepository).incrementDownloadCount(101L, 11L); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(102L, 12L); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(103L, 13L); - verify(eventPublisher, times(1)).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_RejectsAnonymousAllSkillsRequest() throws Exception { - Namespace namespace = new Namespace("global", "Global", "owner-1"); - setId(namespace, 1L); - namespace.setType(NamespaceType.GLOBAL); - - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); - - DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> - service.downloadNamespaceBundle("global", List.of(), null, Map.of())); - - assertEquals("error.namespace.skills.download.selectionRequired", ex.getMessage()); - verifyNoInteractions(objectStorageService); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_RejectsTooManyEligibleSkills() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - List skills = new ArrayList<>(); - for (int i = 1; i <= 21; i++) { - Skill skill = new Skill(2L, "skill-" + i, "owner-1", SkillVisibility.PUBLIC); - setId(skill, (long) i); - skill.setStatus(SkillStatus.ACTIVE); - skill.setLatestVersionId(100L + i); - skills.add(skill); - - SkillVersion version = new SkillVersion((long) i, "1.0.0", "owner-1"); - setId(version, 100L + i); - version.setStatus(SkillVersionStatus.PUBLISHED); - when(visibilityChecker.canAccess(skill, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(skillVersionRepository.findById(100L + i)).thenReturn(Optional.of(version)); - } - - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(skills); - - DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> - service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); - - assertEquals("error.namespace.skills.download.tooMany", ex.getMessage()); - verifyNoInteractions(objectStorageService); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_RejectsOversizedAggregateBundle() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); - setId(alpha, 11L); - alpha.setDisplayName("Alpha Skill"); - alpha.setStatus(SkillStatus.ACTIVE); - alpha.setLatestVersionId(101L); - - Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); - setId(beta, 12L); - beta.setDisplayName("Beta Skill"); - beta.setStatus(SkillStatus.ACTIVE); - beta.setLatestVersionId(102L); - - SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); - setId(alphaVersion, 101L); - alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion betaVersion = new SkillVersion(12L, "1.0.0", "owner-1"); - setId(betaVersion, 102L); - betaVersion.setStatus(SkillVersionStatus.PUBLISHED); - - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); - when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); - when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); - when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(true); - when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(true); - when(objectStorageService.getMetadata("packages/11/101/bundle.zip")) - .thenReturn(new ObjectMetadata(60L * 1024 * 1024, "application/zip", Instant.now())); - when(objectStorageService.getMetadata("packages/12/102/bundle.zip")) - .thenReturn(new ObjectMetadata(60L * 1024 * 1024, "application/zip", Instant.now())); - - DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> - service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); - - assertEquals("error.namespace.skills.download.tooLarge", ex.getMessage()); - verify(objectStorageService, never()).getObject(anyString()); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_RejectsOversizedFallbackBundleBeforeReadingFiles() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); - setId(alpha, 11L); - alpha.setDisplayName("Alpha Skill"); - alpha.setStatus(SkillStatus.ACTIVE); - alpha.setLatestVersionId(101L); - - Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); - setId(beta, 12L); - beta.setDisplayName("Beta Skill"); - beta.setStatus(SkillStatus.ACTIVE); - beta.setLatestVersionId(102L); - - SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); - setId(alphaVersion, 101L); - alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion betaVersion = new SkillVersion(12L, "1.0.0", "owner-1"); - setId(betaVersion, 102L); - betaVersion.setStatus(SkillVersionStatus.PUBLISHED); - - SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 60L * 1024 * 1024, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); - SkillFile betaFile = new SkillFile(102L, "README.md", 60L * 1024 * 1024, "text/markdown", "hash-b", "skills/12/102/README.md"); - - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); - when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); - when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); - when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); - when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(false); - when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); - when(skillFileRepository.findByVersionId(102L)).thenReturn(List.of(betaFile)); - when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); - when(objectStorageService.exists("skills/12/102/README.md")).thenReturn(true); - - DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> - service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); - - assertEquals("error.namespace.skills.download.tooLarge", ex.getMessage()); - verify(objectStorageService, never()).getObject(anyString()); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); - } - private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); From 920e6889e701f1f1a3619e039d79c1351474780e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:46:00 +0800 Subject: [PATCH 30/78] fix(web): remove namespace download residuals Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/i18n/locales/en.json | 11 +-- web/src/i18n/locales/zh.json | 11 +-- web/src/pages/namespace.test.tsx | 7 +- web/src/pages/namespace.tsx | 117 +------------------------------ 4 files changed, 7 insertions(+), 139 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6002e7a5..9696dc0c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -772,16 +772,7 @@ "notFound": "Namespace not found", "skillList": "Skills", "emptyTitle": "No skills", - "emptyDescription": "No skills have been published in this namespace yet", - "downloadAll": "Download all", - "downloadSelected": "Download selected on this page", - "copyInstallManifest": "Copy current page install list", - "downloadConfirmTitle": "Confirm namespace download", - "downloadConfirmDescription_one": "This will request up to {{count}} skill package from @{{namespace}}.", - "downloadConfirmDescription_other": "This will request up to {{count}} skill packages from @{{namespace}}.", - "downloadConfirmLimitHint": "Unavailable skills may be skipped. Synchronous downloads are limited to {{maxSkills}} skills and {{maxSize}}.", - "downloadConfirmAction": "Download", - "selectSkill": "Select {{name}}" + "emptyDescription": "No skills have been published in this namespace yet" }, "skillDetail": { "back": "Back", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2df46a20..c1f598ce 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -772,16 +772,7 @@ "notFound": "命名空间不存在", "skillList": "技能列表", "emptyTitle": "暂无技能", - "emptyDescription": "该命名空间下还没有发布任何技能", - "downloadAll": "下载全部", - "downloadSelected": "下载本页选中", - "copyInstallManifest": "复制本页安装清单", - "downloadConfirmTitle": "确认命名空间下载", - "downloadConfirmDescription_one": "将最多请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", - "downloadConfirmDescription_other": "将最多请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", - "downloadConfirmLimitHint": "不可用的 skill 可能会被跳过;同步下载一次最多支持 {{maxSkills}} 个 skill、{{maxSize}}。", - "downloadConfirmAction": "下载", - "selectSkill": "选择 {{name}}" + "emptyDescription": "该命名空间下还没有发布任何技能" }, "skillDetail": { "back": "返回上一页", diff --git a/web/src/pages/namespace.test.tsx b/web/src/pages/namespace.test.tsx index 6a920a85..c950fb55 100644 --- a/web/src/pages/namespace.test.tsx +++ b/web/src/pages/namespace.test.tsx @@ -99,11 +99,10 @@ describe('NamespacePage', () => { expect(html).toContain('namespace.notFound') }) - it('renders namespace distribution actions when skills are available', () => { + it('does not render namespace distribution controls when skills are available', () => { const html = renderToStaticMarkup() - expect(html).toContain('namespace.downloadAll') - expect(html).toContain('namespace.downloadSelected') - expect(html).toContain('namespace.copyInstallManifest') + expect(buttonRecords).toHaveLength(0) + expect(html).not.toContain('type="checkbox"') }) }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 04a90772..275f7ba5 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -1,21 +1,15 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect } from 'react' import { useNavigate, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' -import { ClipboardCopy, Download } from 'lucide-react' import { NamespaceHeader } from '@/features/namespace/namespace-header' import { SkillCard } from '@/features/skill/skill-card' -import { buildInstallTarget } from '@/features/skill/install-command' import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' -import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' -import { Button } from '@/shared/ui/button' const PAGE_SIZE = 20 -const NAMESPACE_BUNDLE_MAX_SKILLS = 20 -const NAMESPACE_BUNDLE_MAX_SIZE = '100 MB' /** * Public namespace page showing namespace metadata and the skills currently discoverable inside it. @@ -25,20 +19,12 @@ export function NamespacePage() { const navigate = useNavigate() const { namespace } = useParams({ from: '/space/$namespace' }) const [page, setPage] = useState(0) - const [selectedSkillSlugs, setSelectedSkillSlugs] = useState([]) - const [pendingDownloadSlugs, setPendingDownloadSlugs] = useState(null) // Reset page when namespace changes useEffect(() => { setPage(0) - setSelectedSkillSlugs([]) - setPendingDownloadSlugs(null) }, [namespace]) - useEffect(() => { - setSelectedSkillSlugs([]) - }, [page]) - const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({ namespace, @@ -47,56 +33,11 @@ export function NamespacePage() { }) const totalPages = skillsData ? Math.max(Math.ceil(skillsData.total / skillsData.size), 1) : 1 - const visibleSkills = skillsData?.items ?? [] - const selectedSlugSet = useMemo(() => new Set(selectedSkillSlugs), [selectedSkillSlugs]) - const hasSkills = visibleSkills.length > 0 - const selectedDownloadSlugs = selectedSkillSlugs.filter((slug) => visibleSkills.some((skill) => skill.slug === slug)) - const pendingDownloadCount = pendingDownloadSlugs - ? pendingDownloadSlugs.length || skillsData?.total || visibleSkills.length - : 0 const handleSkillClick = (slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) } - const handleSkillSelectionChange = (slug: string, selected: boolean) => { - setSelectedSkillSlugs((current) => { - if (selected) { - return current.includes(slug) ? current : [...current, slug] - } - return current.filter((item) => item !== slug) - }) - } - - const buildNamespaceDownloadUrl = (slugs: string[]) => { - const params = new URLSearchParams() - slugs.forEach((slug) => params.append('skill', slug)) - const queryString = params.toString() - return `/api/web/namespaces/${encodeURIComponent(namespace)}/skills/download${queryString ? `?${queryString}` : ''}` - } - - const handleDownloadAll = () => { - setPendingDownloadSlugs([]) - } - - const handleDownloadSelected = () => { - setPendingDownloadSlugs(selectedDownloadSlugs) - } - - const confirmDownload = () => { - if (!pendingDownloadSlugs) { - return - } - window.location.assign(buildNamespaceDownloadUrl(pendingDownloadSlugs)) - } - - const handleCopyInstallManifest = async () => { - const manifest = visibleSkills - .map((skill) => `skillhub install ${buildInstallTarget(skill.namespace, skill.slug)}`) - .join('\n') - await navigator.clipboard?.writeText(manifest) - } - if (isLoadingNamespace) { return (
@@ -115,25 +56,7 @@ export function NamespacePage() {
-
-

{t('namespace.skillList')}

- {hasSkills ? ( -
- - - -
- ) : null} -
+

{t('namespace.skillList')}

{isLoadingSkills ? ( ) : skillsData && skillsData.items.length > 0 ? ( @@ -141,15 +64,6 @@ export function NamespacePage() {
{skillsData.items.map((skill, idx) => (
- handleSkillClick(skill.slug)} @@ -169,33 +83,6 @@ export function NamespacePage() { /> )}
- { - if (!open) { - setPendingDownloadSlugs(null) - } - }} - title={t('namespace.downloadConfirmTitle')} - description={( - - - {t('namespace.downloadConfirmDescription', { - count: pendingDownloadCount, - namespace, - })} - - - {t('namespace.downloadConfirmLimitHint', { - maxSkills: NAMESPACE_BUNDLE_MAX_SKILLS, - maxSize: NAMESPACE_BUNDLE_MAX_SIZE, - })} - - - )} - confirmText={t('namespace.downloadConfirmAction')} - onConfirm={confirmDownload} - />
) } From 8045e52f5ed03896f9ea4f33bea54a316c32e73f Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 11 Jun 2026 11:20:27 +0800 Subject: [PATCH 31/78] feat(bootstrap): add skillhub hello builtin skill Signed-off-by: dongmucat <1127093059@qq.com> --- .../src/main/resources/builtin-skills/manifest.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json index 2b485e41..8dba7bac 100644 --- a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json +++ b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json @@ -1,3 +1,9 @@ { - "skills": [] + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn/aicontest/2026-06-11/f8a59af3-30d4-4031-80f6-ebff74b05195.zip" + } + ] } From a88b09e51b52059b86e3bf5c19f9369e82efcaa0 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:34:13 +0800 Subject: [PATCH 32/78] fix(publish): accept case-insensitive SKILL.md uploads --- docs/07-skill-protocol.md | 2 +- .../support/MultipartPackageExtractor.java | 3 +- .../support/ZipPackageExtractor.java | 3 +- .../MultipartPackageExtractorTest.java | 35 ++++++++++++++ .../SkillPackageArchiveExtractorTest.java | 32 +++++++++++++ .../support/ZipPackageExtractorTest.java | 47 +++++++++++++++++++ .../skill/validation/SkillPackagePolicy.java | 14 +++++- .../validation/SkillPackageValidatorTest.java | 29 ++++++++++++ web/src/docs/skill.md | 3 +- 9 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 6e9db3cc..4ee36a08 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -66,7 +66,7 @@ my-skill/ ``` 校验规则: -- 根目录必须包含 `SKILL.md` +- 根目录必须包含规范入口文件 `SKILL.md`;上传时服务端兼容 `skill.md`、`Skill.md` 等大小写变体,并在内部归一化为 `SKILL.md` - 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` - 单文件大小限制:1MB(可配置) - 总包大小限制:10MB(可配置) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 0a9fc793..e1a0a57c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -110,7 +111,7 @@ public class MultipartPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return path; + return SkillPackagePolicy.canonicalizeSkillMdPath(path); } private String determineContentType(String filename) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 2beaec70..a23e8cc5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.support; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -112,7 +113,7 @@ public class ZipPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return normalizedPath; + return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); } catch (InvalidPathException ex) { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Invalid package path: " + path); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java new file mode 100644 index 00000000..f3be9120 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MultipartPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + MultipartPackageExtractor extractor = new MultipartPackageExtractor( + new SkillPublishProperties(), + new ObjectMapper() + ); + MockMultipartFile skillMd = new MockMultipartFile( + "files", + "skill.md", + "text/markdown", + "---\nname: test\n---\n".getBytes() + ); + + MultipartPackageExtractor.ExtractedPackage extracted = extractor.extract( + new MockMultipartFile[] {skillMd}, + "{\"namespace\":\"global\",\"slug\":\"test\"}" + ); + + assertEquals(1, extracted.entries().size()); + assertTrue(extracted.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(extracted.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index ba5cc2f7..c849fc4c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -85,6 +85,21 @@ class SkillPackageArchiveExtractorTest { assertTrue(entries.stream().anyMatch(e -> e.path().equals("config.json"))); } + @Test + void canonicalizesCaseInsensitiveSkillMdAtRoot() throws Exception { + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + assertTrue(result.warnings().isEmpty()); + } + @Test void doesNotStripWhenMultipleRootEntries() throws Exception { byte[] zipBytes = createZip(Map.of( @@ -144,6 +159,23 @@ class SkillPackageArchiveExtractorTest { assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); } + @Test + void promotesCaseInsensitiveSkillMdFromSubdirectory() throws Exception { + byte[] zipBytes = createZip(Map.of( + "my-skill/skill.md", "---\nname: test\n---\n".getBytes(), + "my-skill/README.md", "# readme".getBytes(), + "other.txt", "stray file".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertEquals(2, result.entries().size()); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("README.md"))); + assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); + } + @Test void rejectsAmbiguousMultipleSkillMdInSubdirectories() throws Exception { byte[] zipBytes = createZip(Map.of( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java new file mode 100644 index 00000000..02f26214 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZipPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + ZipPackageExtractor extractor = new ZipPackageExtractor(new SkillPublishProperties()); + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + List entries = extractor.extract(file); + + assertEquals(2, entries.size()); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(entries.stream().noneMatch(e -> e.path().equals("skill.md"))); + } + + private byte[] createZip(Map entries) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index eab8d2e2..ee3880ae 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -64,7 +64,19 @@ public final class SkillPackagePolicy { throw new IllegalArgumentException("Package entry path must be normalized: " + rawPath); } - return canonical; + return canonicalizeSkillMdPath(canonical); + } + + public static String canonicalizeSkillMdPath(String normalizedPath) { + int slashIndex = normalizedPath.lastIndexOf('/'); + String fileName = slashIndex >= 0 ? normalizedPath.substring(slashIndex + 1) : normalizedPath; + if (!SKILL_MD_PATH.equalsIgnoreCase(fileName)) { + return normalizedPath; + } + if (slashIndex < 0) { + return SKILL_MD_PATH; + } + return normalizedPath.substring(0, slashIndex + 1) + SKILL_MD_PATH; } public static boolean hasAllowedExtension(String path) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index d473a361..c2b6b63c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -40,6 +40,35 @@ class SkillPackageValidatorTest { assertTrue(result.errors().isEmpty()); } + @Test + void normalizesSkillMdFilenameCase() { + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("skill.md")); + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("Skill.MD")); + assertEquals("nested/SKILL.md", SkillPackagePolicy.normalizeEntryPath("nested/skill.md")); + } + + @Test + void acceptsSkillMdFilenameWithDifferentCase() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + # Test Skill + """; + + List entries = List.of( + new PackageEntry("skill.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), + new PackageEntry("README.md", "readme".getBytes(), 6, "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertTrue(result.passed()); + assertTrue(result.errors().isEmpty()); + } + @Test void testMissingSkillMd() { List entries = List.of( diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md index 18ea1e88..b8ed8bcf 100644 --- a/web/src/docs/skill.md +++ b/web/src/docs/skill.md @@ -138,7 +138,8 @@ If a request fails with `403`, check: ## Skill Package Contract -SkillHub expects OpenSkills-style packages with `SKILL.md` as the entry point. +SkillHub expects OpenSkills-style packages with canonical `SKILL.md` as the entry point. Uploads +accept filename case variants such as `skill.md` and normalize them to `SKILL.md`. ## Publishing Guidance From 40d7de846289b61a31c305914760329402a04b1d Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 11 Jun 2026 18:42:31 +0800 Subject: [PATCH 33/78] fix(security): harden review findings Signed-off-by: dongmucat <1127093059@qq.com> --- .env.release.draft | 3 + .env.release.example | 3 + .github/workflows/pr-scripts.yml | 8 +- .github/workflows/security.yml | 83 +++++++++ Makefile | 7 +- cli/src/platform/archive.ts | 84 ++++++++- cli/src/platform/download.ts | 56 ++++++ cli/src/services/install-service.ts | 3 +- cli/test/unit/platform/archive.test.ts | 28 +++ .../unit/services/install-service.test.ts | 26 ++- compose.release.yml | 3 + docker-compose.staging.yml | 6 + scanner/docs/configuration.md | 22 +-- scripts/smoke-test.sh | 13 +- scripts/tests/dev-web-host-test.sh | 25 +++ scripts/tests/validate-release-config-test.sh | 83 +++++++++ scripts/tests/workflow-security-test.sh | 33 ++++ scripts/validate-release-config.sh | 16 ++ .../config/DownloadRateLimitProperties.java | 2 +- .../config/SkillScannerProperties.java | 2 +- .../portal/NamespaceController.java | 9 +- .../AnonymousDownloadIdentityService.java | 23 +++ .../NamespacePortalQueryAppService.java | 13 +- .../src/main/resources/application-local.yml | 7 +- .../src/main/resources/application.yml | 4 +- .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../DownloadRateLimitPropertiesTest.java | 16 ++ .../SkillScannerPropertiesBindingTest.java | 18 +- .../controller/DeviceAuthControllerTest.java | 58 ++++++ .../controller/LocalAuthControllerTest.java | 56 ++++++ .../NamespacePortalControllerTest.java | 35 ++++ .../NamespaceWorkflowContractTest.java | 2 +- .../controller/SkillStarControllerTest.java | 5 +- .../controller/cli/CliDryRunValidateTest.java | 4 + .../cli/CliSkillControllerTest.java | 1 + .../SkillSubscriptionControllerTest.java | 6 +- .../AnonymousDownloadIdentityServiceTest.java | 20 +++ .../NamespacePortalQueryAppServiceTest.java | 18 +- .../src/test/resources/application-test.yml | 3 + .../skillhub/auth/config/SecurityConfig.java | 20 ++- .../policy/RouteSecurityPolicyRegistry.java | 14 +- .../auth/config/SecurityConfigTest.java | 43 +++++ .../RouteSecurityPolicyRegistryTest.java | 14 +- .../skill/service/SkillPublishService.java | 10 ++ .../service/SkillPublishServiceTest.java | 166 +++++++++++++++++- web/e2e/helpers/csrf.ts | 27 +++ web/e2e/helpers/session.ts | 20 ++- web/e2e/helpers/test-data-builder.ts | 22 ++- web/e2e/public-skill-detail-anonymous.spec.ts | 2 + web/e2e/skill-subscription.spec.ts | 2 + web/e2e/skill-version-compare.spec.ts | 4 + web/src/api/generated/schema.d.ts | 4 + 53 files changed, 1094 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 cli/src/platform/download.ts create mode 100755 scripts/tests/dev-web-host-test.sh create mode 100755 scripts/tests/validate-release-config-test.sh create mode 100755 scripts/tests/workflow-security-test.sh create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java create mode 100644 web/e2e/helpers/csrf.ts diff --git a/.env.release.draft b/.env.release.draft index 40058266..c8f0872b 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -93,3 +93,6 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST= SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub + +# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes diff --git a/.env.release.example b/.env.release.example index cdbddf06..40e51ac1 100644 --- a/.env.release.example +++ b/.env.release.example @@ -104,6 +104,9 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub # Security scanner is enabled by default. Set to false to disable scanning. SKILLHUB_SECURITY_SCANNER_ENABLED=true +# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes + # Scanner LLM configuration (optional, for AI-powered scanning features) SKILL_SCANNER_LLM_API_KEY= SKILL_SCANNER_LLM_BASE_URL= diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index eb7809d1..2f8cc80d 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -4,10 +4,13 @@ on: pull_request: paths: - 'scripts/**' + - 'Makefile' + - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' jobs: - publish-cli-test: + scripts-tests: + name: Script Regression Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,3 +20,6 @@ jobs: with: node-version: '21' - run: bash scripts/tests/publish-cli-test.sh + - run: bash scripts/tests/validate-release-config-test.sh + - run: bash scripts/tests/dev-web-host-test.sh + - run: bash scripts/tests/workflow-security-test.sh diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..9b476ca9 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,83 @@ +name: Security + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + push: + branches: + - main + schedule: + - cron: '23 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + dependency-review: + name: Dependency Review + if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + + codeql: + name: CodeQL (${{ matrix.language }}) + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: java-kotlin + build-mode: manual + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + if: matrix.language == 'java-kotlin' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Ensure Maven wrapper is executable + if: matrix.language == 'java-kotlin' + run: chmod +x server/mvnw + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Build Java for CodeQL + if: matrix.language == 'java-kotlin' + run: cd server && ./mvnw -q -DskipTests package + + - name: Analyze + uses: github/codeql-action/analyze@v3 + with: + category: /language:${{ matrix.language }} diff --git a/Makefile b/Makefile index 039f213d..bf3e7439 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ DEV_WEB_PID := $(DEV_DIR)/web.pid DEV_SERVER_LOG := $(DEV_DIR)/server.log DEV_WEB_LOG := $(DEV_DIR)/web.log DEV_WEB_URL := http://localhost:3000 +DEV_WEB_HOST ?= 127.0.0.1 DEV_API_URL := http://localhost:8080 DEV_SCANNER_URL := http://localhost:8000 STAGING_API_URL := http://localhost:8080 @@ -48,7 +49,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端 echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ else \ echo "Starting frontend..."; \ - $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \ fi @echo "Waiting for backend on $(DEV_API_URL) ..." @backend_ready=0; \ @@ -126,7 +127,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端 @echo " Frontend: $(DEV_WEB_LOG)" dev-server: ## 启动后端开发服务器 - cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' + cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' dev-server-restart: ## 重启后端开发服务器 @mkdir -p $(DEV_DIR) @@ -237,7 +238,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖 cd web && CI=true pnpm install --frozen-lockfile dev-web: ## 启动前端开发服务器 - cd web && pnpm run dev + cd web && pnpm exec vite --host $(DEV_WEB_HOST) build-frontend: web-deps ## 构建前端 cd web && pnpm run build diff --git a/cli/src/platform/archive.ts b/cli/src/platform/archive.ts index 1e420567..c1306711 100644 --- a/cli/src/platform/archive.ts +++ b/cli/src/platform/archive.ts @@ -1,6 +1,14 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { zipSync, unzipSync } from 'fflate' +import { MAX_PACKAGE_BYTES } from './download' + +const MAX_ZIP_ENTRIES = 500 +const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024 +const EOCD_SIGNATURE = 0x06054b50 +const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50 +const ZIP64_MARKER_16 = 0xffff +const ZIP64_MARKER_32 = 0xffffffff /** * Extract a zip archive buffer into the target directory. @@ -8,7 +16,9 @@ import { zipSync, unzipSync } from 'fflate' */ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise { await mkdir(targetDir, { recursive: true }) - const files = unzipSync(new Uint8Array(buffer)) + const archive = new Uint8Array(buffer) + validateZipCentralDirectory(archive) + const files = unzipSync(archive) for (const [name, data] of Object.entries(files)) { const filePath = safeJoin(targetDir, name) if (name.endsWith('/')) { @@ -20,6 +30,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis } } +function validateZipCentralDirectory(archive: Uint8Array): void { + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + const eocdOffset = findEndOfCentralDirectory(view) + if (eocdOffset < 0) { + throw new Error('invalid zip central directory') + } + + const diskNumber = view.getUint16(eocdOffset + 4, true) + const centralDirectoryDisk = view.getUint16(eocdOffset + 6, true) + const entriesOnDisk = view.getUint16(eocdOffset + 8, true) + const totalEntries = view.getUint16(eocdOffset + 10, true) + const centralDirectorySize = view.getUint32(eocdOffset + 12, true) + const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true) + + if ( + entriesOnDisk === ZIP64_MARKER_16 || + totalEntries === ZIP64_MARKER_16 || + centralDirectorySize === ZIP64_MARKER_32 || + centralDirectoryOffset === ZIP64_MARKER_32 + ) { + throw new Error('zip64 archives are not supported') + } + if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) { + throw new Error('multi-disk zip archives are not supported') + } + if (totalEntries > MAX_ZIP_ENTRIES) { + throw new Error('zip entry count exceeds limit') + } + if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) { + throw new Error('invalid zip central directory') + } + + let offset = centralDirectoryOffset + let totalUncompressedSize = 0 + const decoder = new TextDecoder() + for (let i = 0; i < totalEntries; i++) { + if (offset + 46 > archive.byteLength || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) { + throw new Error('invalid zip central directory') + } + const uncompressedSize = view.getUint32(offset + 24, true) + const nameLength = view.getUint16(offset + 28, true) + const extraLength = view.getUint16(offset + 30, true) + const commentLength = view.getUint16(offset + 32, true) + const nameStart = offset + 46 + const nameEnd = nameStart + nameLength + const nextOffset = nameEnd + extraLength + commentLength + if (nameEnd > archive.byteLength || nextOffset > archive.byteLength) { + throw new Error('invalid zip central directory') + } + + const entryName = decoder.decode(archive.subarray(nameStart, nameEnd)) + if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) { + throw new Error('zip entry size exceeds limit') + } + totalUncompressedSize += uncompressedSize + if (totalUncompressedSize > MAX_PACKAGE_BYTES) { + throw new Error('zip total uncompressed size exceeds limit') + } + offset = nextOffset + } +} + +function findEndOfCentralDirectory(view: DataView): number { + const minOffset = Math.max(0, view.byteLength - 0xffff - 22) + for (let offset = view.byteLength - 22; offset >= minOffset; offset--) { + if (view.getUint32(offset, true) === EOCD_SIGNATURE) { + return offset + } + } + return -1 +} + /** * Create a zip archive from a directory. * Returns the archive as a Blob. diff --git a/cli/src/platform/download.ts b/cli/src/platform/download.ts new file mode 100644 index 00000000..30490731 --- /dev/null +++ b/cli/src/platform/download.ts @@ -0,0 +1,56 @@ +import { EXIT } from '../shared/constants' +import { CliError } from '../shared/errors' + +export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024 + +export async function readBoundedResponseBody(response: Response, maxBytes = MAX_PACKAGE_BYTES): Promise { + const contentLength = response.headers.get('content-length') + if (contentLength !== null) { + const declaredLength = Number(contentLength) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new CliError('download exceeds maximum package size', EXIT.network, { + contentLength: declaredLength, + maxBytes + }) + } + } + + if (!response.body) { + const buffer = await response.arrayBuffer() + if (buffer.byteLength > maxBytes) { + throw new CliError('download exceeds maximum package size', EXIT.network, { + receivedBytes: buffer.byteLength, + maxBytes + }) + } + return buffer + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let receivedBytes = 0 + + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + receivedBytes += value.byteLength + if (receivedBytes > maxBytes) { + await reader.cancel() + throw new CliError('download exceeds maximum package size', EXIT.network, { + receivedBytes, + maxBytes + }) + } + chunks.push(value) + } + + const result = new Uint8Array(receivedBytes) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result.buffer +} diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index b989ce58..ed09ffd6 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -5,6 +5,7 @@ import { InventoryStore } from '../stores/inventory-store' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' import { extractZip } from '../platform/archive' +import { readBoundedResponseBody } from '../platform/download' import { pathExists } from '../platform/paths' import type { AgentCandidate } from '../agents/types' @@ -23,7 +24,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed const client = new SkillHubClient(options.registry, options.token) const resolved = await client.resolve(options.namespace, options.slug, options.version) const response = await client.download(options.namespace, options.slug, resolved.version) - const buffer = await response.arrayBuffer() + const buffer = await readBoundedResponseBody(response) const installed: Array<{ agent: string; dir: string }> = [] const store = new InventoryStore(options.home) diff --git a/cli/test/unit/platform/archive.test.ts b/cli/test/unit/platform/archive.test.ts index a39c3c03..98972134 100644 --- a/cli/test/unit/platform/archive.test.ts +++ b/cli/test/unit/platform/archive.test.ts @@ -56,4 +56,32 @@ describe('archive helpers', () => { const entries = await readdir(target) expect(entries).toEqual([]) }) + + test('rejects zip archives with more than 500 entries before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-many-')) + const manyEntries = Object.fromEntries( + Array.from({ length: 501 }, (_, index) => [`file-${index}.txt`, new Uint8Array(0)]) + ) + const archive = zipSync(manyEntries) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry count exceeds limit') + }) + + test('rejects zip entries larger than 10 MiB before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-file-')) + const archive = zipSync({ 'large.bin': new Uint8Array(10 * 1024 * 1024 + 1) }) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry size exceeds limit') + }) + + test('rejects zip archives larger than 100 MiB after decompression before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-total-')) + const oneMiB = new Uint8Array(1024 * 1024) + const entries = Object.fromEntries( + Array.from({ length: 101 }, (_, index) => [`file-${index}.bin`, oneMiB]) + ) + const archive = zipSync(entries) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip total uncompressed size exceeds limit') + }) }) diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index 2ea2735d..784f4394 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -21,6 +21,13 @@ function installFetch(zipEntries: Record): typeof fetch { Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)]) )) + return installFetchWithDownloadResponse(new Response( + archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer, + { status: 200 } + )) +} + +function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch { const fakeFetch = async (input: URL | RequestInfo) => { const path = new URL(String(input)).pathname if (path.endsWith('/resolve')) { @@ -37,8 +44,7 @@ function installFetch(zipEntries: Record): typeof fetch { }) } if (path.endsWith('/download')) { - const body = archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer - return new Response(body, { status: 200 }) + return downloadResponse.clone() } return Response.json({ code: 404 }, { status: 404 }) } @@ -125,4 +131,20 @@ describe('installSkill', () => { expect(inventory.items[0].targets).toHaveLength(1) expect(inventory.items[0].targets[0].installDir).toBe(skillDir) }) + + test('rejects downloads whose content-length exceeds the package limit', async () => { + globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), { + status: 200, + headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) } + })) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false + })).rejects.toThrow('download exceeds maximum package size') + }) }) diff --git a/compose.release.yml b/compose.release.yml index 1b878731..5ed7086e 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -58,6 +58,7 @@ services: SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-} + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required} SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3} STORAGE_BASE_PATH: /var/lib/skillhub/storage SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-} @@ -99,6 +100,8 @@ services: condition: service_healthy redis: condition: service_healthy + skill-scanner: + condition: service_healthy healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] interval: 10s diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index a6c5e2ff..48d1fd1a 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -42,11 +42,17 @@ services: BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local OAUTH2_GITHUB_CLIENT_ID: local-placeholder OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: staging-download-secret-32-bytes + SKILLHUB_SECURITY_SCANNER_ENABLED: "true" + SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000 + SKILLHUB_SECURITY_SCANNER_MODE: upload depends_on: postgres: condition: service_healthy redis: condition: service_healthy + skill-scanner: + condition: service_healthy healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] interval: 10s diff --git a/scanner/docs/configuration.md b/scanner/docs/configuration.md index 1de8606a..2544aed2 100644 --- a/scanner/docs/configuration.md +++ b/scanner/docs/configuration.md @@ -18,7 +18,7 @@ skillhub: security: scanner: # 基础配置 - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local} @@ -48,20 +48,20 @@ skillhub: #### `enabled` - **类型**:Boolean -- **默认值**:`false` +- **默认值**:`true` - **环境变量**:`SKILLHUB_SECURITY_SCANNER_ENABLED` - **说明**:是否启用安全扫描功能 - **影响**: - `true`:技能包发布时会触发安全扫描 - - `false`:跳过安全扫描,直接进入审核流程 + - `false`:仅允许 `PRIVATE` 技能跳过扫描;`PUBLIC` / `NAMESPACE_ONLY` 发布会失败并提示必须启用 Scanner **示例**: ```yaml -# 开发环境:禁用扫描 +# 仅本地私有技能调试:禁用扫描 enabled: false -# 生产环境:启用扫描 +# 公共或命名空间可见发布:启用扫描 enabled: true ``` @@ -103,8 +103,8 @@ base-url: https://scanner.example.com **示例**: ```yaml -# Docker Compose 环境(共享卷) -mode: local +# Docker Compose 环境(Scanner 独立容器,无共享发布目录) +mode: upload # Kubernetes 环境(独立 Pod) mode: upload @@ -298,7 +298,7 @@ services: environment: - SKILLHUB_SECURITY_SCANNER_ENABLED=true - SKILLHUB_SECURITY_SCANNER_URL=http://skill-scanner:8000 - - SKILLHUB_SECURITY_SCANNER_MODE=local + - SKILLHUB_SECURITY_SCANNER_MODE=upload - SKILLHUB_SCANNER_USE_BEHAVIORAL=false - SKILLHUB_SCANNER_USE_LLM=false - SKILLHUB_SCANNER_USE_META=true @@ -348,9 +348,9 @@ stringData: skillhub: security: scanner: - enabled: false # 开发时禁用扫描,加快迭代速度 + enabled: true # 默认启用;PUBLIC/NAMESPACE_ONLY 发布依赖扫描 base-url: http://localhost:8000 - mode: local + mode: upload analyzers: meta: true # 只启用元数据分析 policy: @@ -366,7 +366,7 @@ skillhub: scanner: enabled: true # 测试环境启用扫描 base-url: http://skill-scanner:8000 - mode: local + mode: upload analyzers: behavioral: true meta: true diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 35389ece..e1e46289 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -36,8 +36,8 @@ echo "Target: $BASE_URL" echo check "Health endpoint" "$BASE_URL/actuator/health" "200" -check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" -check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200" +check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401" +check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401" check "Auth required" "$BASE_URL/api/v1/auth/me" "401" curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null @@ -67,6 +67,15 @@ else FAIL=$((FAIL + 1)) fi +NAMESPACES_AUTH_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/namespaces" || true)" +if [[ "$NAMESPACES_AUTH_STATUS" == "200" ]]; then + echo "PASS: Namespaces API with session (HTTP $NAMESPACES_AUTH_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Namespaces API with session (got $NAMESPACES_AUTH_STATUS)" + FAIL=$((FAIL + 1)) +fi + CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ -X POST "$BASE_URL/api/v1/auth/local/change-password" \ -b "$COOKIE_JAR" \ diff --git a/scripts/tests/dev-web-host-test.sh b/scripts/tests/dev-web-host-test.sh new file mode 100755 index 00000000..10c22a07 --- /dev/null +++ b/scripts/tests/dev-web-host-test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MAKEFILE="$REPO_ROOT/Makefile" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +grep -Eq '^DEV_WEB_HOST[[:space:]]*\?=[[:space:]]*127\.0\.0\.1$' "$MAKEFILE" \ + || fail "Makefile must default DEV_WEB_HOST to 127.0.0.1" + +grep -Fq 'pnpm exec vite --host $(DEV_WEB_HOST)' "$MAKEFILE" \ + || fail "dev web startup must pass DEV_WEB_HOST to vite" + +grep -Fq "cd server && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \ + || fail "dev-server must inject scanner upload environment" + +if grep -Fq 'pnpm exec vite --host 0.0.0.0' "$MAKEFILE"; then + fail "dev web startup must not bind Vite to 0.0.0.0 by default" +fi + +echo "dev-web-host-test passed" diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh new file mode 100755 index 00000000..3a52d7a3 --- /dev/null +++ b/scripts/tests/validate-release-config-test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/validate-release-config.sh" + +TMP_DIRS=() +cleanup() { + local d + for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do + rm -rf "$d" + done +} +trap cleanup EXIT + +new_tmp() { + local d + d="$(mktemp -d)" + TMP_DIRS+=("$d") + echo "$d" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_env() { + local file="$1" + local secret="${2:-}" + local include_secret="${3:-yes}" + cat >"$file" <>"$file" + fi +} + +expect_fail() { + local file="$1" + local expected="$2" + local output + if output="$("$SCRIPT" "$file" 2>&1)"; then + fail "expected validation to fail for $file" + fi + if [[ "$output" != *"$expected"* ]]; then + fail "expected output to contain '$expected', got: $output" + fi +} + +tmp="$(new_tmp)" + +valid_env="$tmp/valid.env" +write_env "$valid_env" "release-download-secret-32-bytes-minimum" +"$SCRIPT" "$valid_env" >/dev/null + +missing_env="$tmp/missing.env" +write_env "$missing_env" "" no +expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required" + +placeholder_env="$tmp/placeholder.env" +write_env "$placeholder_env" "change-me-in-production" +expect_fail "$placeholder_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET still uses placeholder/default value" + +short_env="$tmp/short.env" +write_env "$short_env" "too-short" +expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters" + +echo "validate-release-config-test passed" diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh new file mode 100755 index 00000000..8219af51 --- /dev/null +++ b/scripts/tests/workflow-security-test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SECURITY_WORKFLOW="$REPO_ROOT/.github/workflows/security.yml" +PR_SCRIPTS_WORKFLOW="$REPO_ROOT/.github/workflows/pr-scripts.yml" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required" + +grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \ + || fail "security workflow must run dependency review" +grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \ + || fail "security workflow must initialize CodeQL" +grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \ + || fail "security workflow must build Java with the server Maven wrapper" +grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \ + || fail "security workflow must grant SARIF upload permission" + +grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when security workflow changes" +grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run validate-release-config-test" +grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run dev-web-host-test" +grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run workflow-security-test" + +echo "workflow-security-test passed" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 1d4f9285..758b168f 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -97,10 +97,26 @@ validate_port() { esac } +validate_min_length() { + var_name="$1" + min_length="$2" + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + if [ "${#var_value}" -lt "$min_length" ]; then + error "$var_name must be at least $min_length characters" + fi +} + require_non_empty SKILLHUB_PUBLIC_BASE_URL validate_url SKILLHUB_PUBLIC_BASE_URL validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL +require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET +reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes" +validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32 + reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev" reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026" if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java index 3d922e90..2b1930fa 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java @@ -10,7 +10,7 @@ public class DownloadRateLimitProperties { private String anonymousCookieName = "skillhub_anon_dl"; private Duration anonymousCookieMaxAge = Duration.ofDays(30); - private String anonymousCookieSecret = "change-me-in-production"; + private String anonymousCookieSecret; public String getAnonymousCookieName() { return anonymousCookieName; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java index 7ab3b55a..bb1f0f87 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Component; @ConfigurationProperties(prefix = "skillhub.security.scanner") public class SkillScannerProperties { - private boolean enabled = false; + private boolean enabled = true; private String baseUrl = "http://localhost:8000"; private String healthPath = "/health"; private String scanPath = "/scan-upload"; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 69be5fa3..c0c34567 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +import java.util.Set; /** * Namespace portal endpoints for discovery, membership management, and @@ -155,9 +156,13 @@ public class NamespaceController extends BaseApiController { @GetMapping("/namespaces/{slug}/members") public ApiResponse> listMembers(@PathVariable String slug, Pageable pageable, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + @AuthenticationPrincipal PlatformPrincipal principal) { + Set platformRoles = principal != null && principal.platformRoles() != null + ? principal.platformRoles() + : Set.of(); return ok("response.success.read", - namespacePortalQueryAppService.listMembers(slug, pageable, userId)); + namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles)); } @GetMapping("/namespaces/{slug}/member-candidates") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java index f6ff6dff..a166c666 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.ratelimit; import com.iflytek.skillhub.config.DownloadRateLimitProperties; +import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -11,6 +12,7 @@ import java.security.SecureRandom; import java.time.Duration; import java.util.Arrays; import java.util.Base64; +import java.util.Set; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.springframework.http.ResponseCookie; @@ -24,6 +26,12 @@ import org.springframework.stereotype.Component; public class AnonymousDownloadIdentityService { private static final String COOKIE_VERSION = "v1"; + private static final int MIN_SECRET_LENGTH = 32; + private static final Set DISALLOWED_SECRET_VALUES = Set.of( + "change-me-in-production", + "replace-me", + "replace-with-random-download-secret-32-bytes" + ); private static final SecureRandom RANDOM = new SecureRandom(); private final DownloadRateLimitProperties properties; @@ -35,6 +43,21 @@ public class AnonymousDownloadIdentityService { this.clientIpResolver = clientIpResolver; } + @PostConstruct + void validateAnonymousCookieSecret() { + String secret = properties.getAnonymousCookieSecret(); + if (secret == null || secret.isBlank()) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"); + } + String trimmedSecret = secret.trim(); + if (DISALLOWED_SECRET_VALUES.contains(trimmedSecret)) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder"); + } + if (trimmedSecret.length() < MIN_SECRET_LENGTH) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"); + } + } + public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) { String ip = clientIpResolver.resolve(request); String cookieId = extractValidCookieId(request); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 82b38610..e8df0ad9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -18,6 +19,7 @@ import com.iflytek.skillhub.dto.PageResponse; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -111,9 +113,16 @@ public class NamespacePortalQueryAppService { } @Transactional(readOnly = true) - public PageResponse listMembers(String slug, Pageable pageable, String userId) { + public PageResponse listMembers(String slug, Pageable pageable, String userId, Set platformRoles) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceService.assertMember(namespace.getId(), userId); + if (namespace.getType() == NamespaceType.GLOBAL) { + Set roles = platformRoles != null ? platformRoles : Set.of(); + if (!roles.contains("SUPER_ADMIN") && !roles.contains("USER_ADMIN")) { + throw new DomainForbiddenException("error.namespace.global.members.platformAdmin.required"); + } + } else { + namespaceService.assertMember(namespace.getId(), userId); + } Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); List memberUserIds = members.getContent().stream() diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 87dd2419..0e390aa5 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -27,13 +27,18 @@ skillhub: auth: mock: enabled: true + ratelimit: + download: + anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:local-dev-download-secret-32-bytes} notification: cleanup: read-retention-days: 30 unread-retention-days: 90 security: scanner: - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} + base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} + mode: ${SKILLHUB_SECURITY_SCANNER_MODE:upload} stream: reclaim-enabled: ${SKILLHUB_SCAN_STREAM_RECLAIM_ENABLED:true} reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT2M} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 7d9a1cbc..286ff05f 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -144,7 +144,7 @@ skillhub: download: anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl} anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D} - anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:change-me-in-production} + anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:} publish: max-file-count: 100 max-single-file-size: 10485760 # 10MB @@ -166,7 +166,7 @@ skillhub: verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth} security: scanner: - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} health-path: /health scan-path: /scan-upload diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 2301e0d5..8f7cf1df 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -68,6 +68,7 @@ error.namespace.slug.exists=Namespace slug ''{0}'' already exists error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} error.namespace.membership.required=Namespace membership required +error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members error.namespace.admin.required=Namespace owner or admin role required error.namespace.owner.required=Namespace owner role required error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces @@ -93,6 +94,7 @@ error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0} error.skill.publish.precheck.failed=Pre-publish validation failed: {0} +error.security.scanner.required=Security scanner must be enabled before publishing public or namespace-visible skills error.skill.publish.archived=Archived skill must be restored before publishing: {0} review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0} review.withdraw.not_submitter=Only the submitter can withdraw this review diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index a968cc77..e608d247 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -68,6 +68,7 @@ error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在 error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} error.namespace.membership.required=需要先加入该命名空间 +error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 error.namespace.owner.required=需要命名空间所有者权限 error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间 @@ -93,6 +94,7 @@ error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0} error.skill.publish.precheck.failed=预发布校验失败:{0} +error.security.scanner.required=发布公开或命名空间可见技能前必须启用安全扫描器 error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} review.withdraw.not_submitter=只有提交人本人可以撤销此次审核 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java new file mode 100644 index 00000000..56f8994d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DownloadRateLimitPropertiesTest { + + @Test + void anonymousCookieSecretDoesNotDefaultToProductionPlaceholder() { + DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); + + assertThat(properties.getAnonymousCookieSecret()) + .isNull(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java index 63efeb8b..42e25294 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java @@ -14,22 +14,34 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class SkillScannerPropertiesBindingTest { @Test - void defaultConfig_disablesScannerByDefault() throws IOException { + void defaultConfig_enablesScannerByDefault() throws IOException { SkillScannerProperties properties = bindProperties( List.of("application.yml"), Map.of() ); - assertFalse(properties.isEnabled()); + assertTrue(properties.isEnabled()); assertEquals("local", properties.getMode()); assertEquals("http://localhost:8000", properties.getBaseUrl()); } + @Test + void localConfig_usesUploadScannerModeByDefault() throws IOException { + SkillScannerProperties properties = bindProperties( + List.of("application-local.yml", "application.yml"), + Map.of() + ); + + assertTrue(properties.isEnabled()); + assertEquals("upload", properties.getMode()); + assertEquals("http://localhost:8000", properties.getBaseUrl()); + } + @Test void environmentVariables_overrideScannerDefaults() throws IOException { SkillScannerProperties properties = bindProperties( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java new file mode 100644 index 00000000..b2c8a460 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.device.DeviceCodeResponse; +import com.iflytek.skillhub.auth.device.DeviceTokenResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class DeviceAuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void requestDeviceCode_withoutCsrfIsAllowedForCliFlow() throws Exception { + given(deviceAuthService.generateDeviceCode()) + .willReturn(new DeviceCodeResponse("device-1", "USER-CODE", "http://localhost/device", 600, 5)); + + mockMvc.perform(post("/api/v1/auth/device/code")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.deviceCode").value("device-1")); + } + + @Test + void pollToken_withoutCsrfIsAllowedForCliFlow() throws Exception { + given(deviceAuthService.pollToken("device-1")) + .willReturn(DeviceTokenResponse.success("token-1")); + + mockMvc.perform(post("/api/v1/auth/device/token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"deviceCode\":\"device-1\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.accessToken").value("token-1")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index 7158cdfd..440fe4bf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.security.AuthFailureThrottleService; +import jakarta.servlet.http.Cookie; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; @@ -197,6 +198,61 @@ class LocalAuthControllerTest { .andExpect(jsonPath("$.code").value(0)); } + @Test + void changePassword_withAuthentication_withInvalidCsrf_returnsForbidden() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_3", + "carol", + "carol@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(authentication(auth)) + .with(csrf().useInvalidToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isForbidden()); + } + + @Test + void changePassword_withSessionCookieAndBearerHeaderWithoutCsrf_isRejected() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_3", + "carol", + "carol@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(authentication(auth)) + .header("Authorization", "Bearer invalid-token") + .cookie(new Cookie("SESSION", "browser-session")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isUnauthorized()); + + verify(localAuthService, never()).changePassword("usr_3", "old", "Newpass123!"); + } + @Test void requestPasswordReset_returnsGenericSuccessEnvelope() throws Exception { mockMvc.perform(post("/api/v1/auth/local/password-reset/request") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 699cb740..566d3ef0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -174,6 +174,41 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.code").value(403)); } + @Test + void listMembers_globalNamespaceRejectsRegularUsers() throws Exception { + Namespace namespace = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); + given(namespaceService.getNamespaceBySlug("global")).willReturn(namespace); + given(namespaceMemberService.listMembers(eq(1L), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(), org.springframework.data.domain.PageRequest.of(0, 20), 0)); + + mockMvc.perform(get("/api/v1/namespaces/global/members") + .with(auth("regular-1")) + .requestAttr("userId", "regular-1")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void listMembers_globalNamespaceAllowsUserAdminWithoutMembership() throws Exception { + Namespace namespace = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.MEMBER); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + given(namespaceService.getNamespaceBySlug("global")).willReturn(namespace); + doThrow(new DomainForbiddenException("error.namespace.membership.required")) + .when(namespaceService).assertMember(1L, "user-admin-1"); + given(namespaceMemberService.listMembers(eq(1L), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(member), org.springframework.data.domain.PageRequest.of(0, 20), 1)); + given(userAccountRepository.findByIdIn(List.of("user-2"))).willReturn(List.of(user)); + + mockMvc.perform(get("/api/v1/namespaces/global/members") + .with(auth("user-admin-1", Set.of("USER_ADMIN"))) + .requestAttr("userId", "user-admin-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].userId").value("user-2")) + .andExpect(jsonPath("$.data.items[0].email").value("alice@example.com")); + } + @Test void searchMemberCandidates_returnsCandidates() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java index 72b5d26d..b72551ed 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -95,7 +95,7 @@ class NamespaceWorkflowContractTest { .willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE"))); given(namespacePortalCommandAppService.addMember("team-flow", "user-admin", NamespaceRole.ADMIN, "owner-1")) .willReturn(adminMemberResponse); - given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"))) + given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"), eq(Set.of()))) .willReturn(new PageResponse<>(List.of(adminMemberResponse), 1, 0, 20)); given(namespacePortalCommandAppService.updateMemberRole(eq("team-flow"), eq("user-admin"), any(), eq("owner-1"))) .willReturn(adminMemberResponse); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index 111d1173..a605b7cc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -136,7 +136,7 @@ class SkillStarControllerTest { } @Test - void apiWebStarSkillWithoutCsrfShouldAllowSessionAuth() throws Exception { + void apiWebStarSkillWithCsrfShouldAllowSessionAuth() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( "user-42", "tester", @@ -152,7 +152,8 @@ class SkillStarControllerTest { ); mockMvc.perform(put("/api/web/skills/10/star") - .with(authentication(auth))) + .with(authentication(auth)) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java index f2224de0..d82aa32d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java @@ -55,6 +55,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)) @@ -75,6 +76,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(false)) @@ -95,6 +97,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "PRIVATE".getBytes())) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)); @@ -108,6 +111,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "BOGUS".getBytes())) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isBadRequest()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index d7edf81d..b2421c89 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -118,6 +118,7 @@ class CliSkillControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .delete("/api/cli/v1/skills/global/demo") + .header("Authorization", "Bearer test-token") .with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.ok").value(true)) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java index 809c22a9..4ebf9424 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java @@ -59,7 +59,8 @@ class SkillSubscriptionControllerTest { @Test void subscribe_skill_returns_envelope() throws Exception { mockMvc.perform(put("/api/web/skills/10/subscription") - .with(authentication(authenticatedUser()))) + .with(authentication(authenticatedUser())) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.timestamp").isNotEmpty()) @@ -80,7 +81,8 @@ class SkillSubscriptionControllerTest { @Test void unsubscribe_skill_returns_envelope() throws Exception { mockMvc.perform(delete("/api/web/skills/10/subscription") - .with(authentication(authenticatedUser()))) + .with(authentication(authenticatedUser())) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.timestamp").isNotEmpty()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java new file mode 100644 index 00000000..39603ebd --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.ratelimit; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.config.DownloadRateLimitProperties; +import org.junit.jupiter.api.Test; + +class AnonymousDownloadIdentityServiceTest { + + @Test + void validateAnonymousCookieSecretRejectsReleaseExamplePlaceholder() { + DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); + properties.setAnonymousCookieSecret("replace-with-random-download-secret-32-bytes"); + AnonymousDownloadIdentityService service = new AnonymousDownloadIdentityService(properties, new ClientIpResolver()); + + assertThatThrownBy(service::validateAnonymousCookieSecret) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must not use the default placeholder"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 10e58718..05a9fd3c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -29,6 +29,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import java.util.Map; +import java.util.Set; class NamespacePortalQueryAppServiceTest { @@ -131,7 +132,7 @@ class NamespacePortalQueryAppServiceTest { when(userAccountRepository.findByIdIn(List.of("user-2"))) .thenReturn(List.of(user)); - PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1", Set.of()); assertThat(result.items()).hasSize(1); MemberResponse mr = result.items().get(0); @@ -153,7 +154,7 @@ class NamespacePortalQueryAppServiceTest { when(userAccountRepository.findByIdIn(List.of("ghost-user"))) .thenReturn(List.of()); - PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1", Set.of()); assertThat(result.items()).hasSize(1); MemberResponse mr = result.items().get(0); @@ -161,4 +162,17 @@ class NamespacePortalQueryAppServiceTest { assertThat(mr.displayName()).isNull(); assertThat(mr.email()).isNull(); } + + @Test + void listMembers_globalNamespaceRejectsRegularUsersEvenWhenTheyAreGlobalMembers() { + Namespace ns = namespace(1L, "global"); + ns.setType(NamespaceType.GLOBAL); + when(namespaceService.getNamespaceBySlug("global")).thenReturn(ns); + when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + + assertThatThrownBy(() -> service.listMembers("global", PageRequest.of(0, 20), "user-1", Set.of())) + .isInstanceOf(DomainForbiddenException.class) + .hasMessageContaining("error.namespace.global.members.platformAdmin.required"); + } } diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index e7a6ea69..8749a355 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -41,6 +41,9 @@ skillhub: enforce-active-user-check: false access-policy: mode: OPEN + ratelimit: + download: + anonymous-cookie-secret: test-download-secret-32-bytes-long security: scanner: enabled: false diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 05b87025..8c2ff2dc 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; @@ -103,7 +105,7 @@ public class SecurityConfig { RequestMatcher csrfIgnoreMatcher = request -> { String path = request.getRequestURI(); String authorization = request.getHeader("Authorization"); - return routeSecurityPolicyRegistry.shouldIgnoreCsrf(path, authorization); + return routeSecurityPolicyRegistry.shouldIgnoreCsrf(request.getMethod(), path, authorization, hasSessionCookie(request)); }; http @@ -183,4 +185,20 @@ public class SecurityConfig { } } } + + static boolean hasSessionCookie(HttpServletRequest request) { + if (request.getRequestedSessionId() != null) { + return true; + } + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return false; + } + for (Cookie cookie : cookies) { + if ("SESSION".equals(cookie.getName()) || "JSESSIONID".equals(cookie.getName())) { + return true; + } + } + return false; + } } 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 5ac6c1d1..c4c52fff 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 @@ -150,14 +150,22 @@ public class RouteSecurityPolicyRegistry { return ApiTokenAuthorizationDecision.unsupported(path); } - public boolean shouldIgnoreCsrf(String path, String authorizationHeader) { - if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + public boolean shouldIgnoreCsrf(String method, String path, String authorizationHeader) { + return shouldIgnoreCsrf(method, path, authorizationHeader, false); + } + + public boolean shouldIgnoreCsrf(String method, String path, String authorizationHeader, boolean hasSessionCookie) { + if (!hasSessionCookie && authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { return true; } if (path == null) { return false; } - return path.startsWith("/api/"); + if (!"POST".equalsIgnoreCase(method)) { + return false; + } + return "/api/v1/auth/device/code".equals(path) + || "/api/v1/auth/device/token".equals(path); } public boolean shouldProjectRequestContext(String path) { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java new file mode 100644 index 00000000..2865f3fb --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.auth.config; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +class SecurityConfigTest { + + @Test + void hasSessionCookieDetectsSpringSessionCookie() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("SESSION", "session-id")); + + assertTrue(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieDetectsRequestedSessionIdFromServletContainer() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestedSessionId("custom-session-id"); + + assertTrue(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieIgnoresServerSideSessionWithoutClientSessionId() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true); + + assertFalse(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieIgnoresNonSessionCookies() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("XSRF-TOKEN", "csrf-token")); + + assertFalse(SecurityConfig.hasSessionCookie(request)); + } +} 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 8499ee4d..efb5b0bd 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 @@ -113,10 +113,16 @@ class RouteSecurityPolicyRegistryTest { } @Test - void shouldIgnoreCsrf_forBearerAndApiPaths() { - assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null)); - assertTrue(registry.shouldIgnoreCsrf("/not-api", "Bearer token")); - assertFalse(registry.shouldIgnoreCsrf("/ui/settings", null)); + void shouldIgnoreCsrf_onlyForBearerTokensAndDeviceTokenFlow() { + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/admin/users", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/local/change-password", null, false)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/not-api", "Bearer token", false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/not-api", "Bearer token", true)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/code", null, false)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/token", null, false)); + assertFalse(registry.shouldIgnoreCsrf("GET", "/api/v1/auth/device/code", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/authorize", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/ui/settings", null, false)); } @Test 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 698a0fdd..c204306a 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 @@ -170,6 +170,9 @@ public class SkillPublishService { errors.add("Publisher is not a member of namespace: " + namespaceSlug); } } + if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) { + errors.add("error.security.scanner.required"); + } // 3. Package validation ValidationResult packageValidation = skillPackageValidator.validate(entries); @@ -380,6 +383,9 @@ public class SkillPublishService { "error.skill.publish.precheck.confirmRequired", formatValidationMessages(publishWarnings)); } + if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) { + throw new DomainBadRequestException("error.security.scanner.required"); + } // 6. Find or create Skill record (with owner isolation) List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); @@ -588,6 +594,10 @@ public class SkillPublishService { skillVersionRepository.flush(); } + private boolean requiresSecurityScanner(SkillVisibility visibility) { + return visibility == SkillVisibility.PUBLIC || visibility == SkillVisibility.NAMESPACE_ONLY; + } + private String resolveNamespaceSlug(Long namespaceId) { return namespaceRepository.findById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.notFound", namespaceId)) 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 e40fa545..73f118fd 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 @@ -101,7 +101,7 @@ class SkillPublishServiceTest { eventPublisher, CLOCK ); - lenient().when(securityScanService.isEnabled()).thenReturn(false); + lenient().when(securityScanService.isEnabled()).thenReturn(true); lenient().when(skillVersionRepository.findBySkillIdAndStatus(anyLong(), eq(SkillVersionStatus.PENDING_REVIEW))) .thenReturn(List.of()); lenient().when(reviewTaskRepository.save(any(ReviewTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -1284,6 +1284,120 @@ class SkillPublishServiceTest { assertEquals(SkillVersionStatus.SCAN_FAILED, SkillVersionStatus.valueOf("SCAN_FAILED")); } + @Test + void testPublishFromEntries_PublicWhenScannerDisabled_ShouldRejectBeforeSideEffects() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "test-skill", "test-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString()); + verify(skillRepository, never()).save(any(Skill.class)); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_NamespaceOnlyWhenScannerDisabled_ShouldRejectBeforeSideEffects() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "team-skill", "team-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.NAMESPACE_ONLY, + Set.of() + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString()); + verify(skillRepository, never()).save(any(Skill.class)); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_PrivateWhenScannerDisabled_ShouldAllowUploadWithoutScan() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "private-skill", "private-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PRIVATE, + Set.of() + ); + + assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus()); + verify(securityScanService, never()).triggerScan(anyLong(), anyList(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_SuperAdminPublicWhenScannerDisabled_ShouldStillRejectBeforeAutoPublish() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "admin-user"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "admin-skill", "admin-skill", "1.0.0", false); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN") + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); + } + + @Test + void testValidateOnly_PublicWhenScannerDisabled_ShouldReturnScannerRequiredError() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + List entries = skillEntries("test-skill", "1.0.0"); + 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()); + when(securityScanService.isEnabled()).thenReturn(false); + 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(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + + SkillPublishService.DryRunResult result = service.validateOnly( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + ); + + assertFalse(result.valid()); + assertTrue(result.errors().contains("error.security.scanner.required")); + } + @Test void testPublishFromEntries_WhenScannerEnabled_ShouldCreateReviewTaskAndTriggerScan() throws Exception { String namespaceSlug = "test-ns"; @@ -1377,6 +1491,56 @@ class SkillPublishServiceTest { verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); } + private record PublishFixture(List entries) { + } + + private PublishFixture stubValidPublishInputs( + String namespaceSlug, + String publisherId, + String skillName, + String skillSlug, + String version, + boolean stubMembership) throws Exception { + List entries = skillEntries(skillName, version); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + SkillMetadata metadata = new SkillMetadata(skillName, "Test", version, "Body", Map.of()); + Skill skill = new Skill(namespace.getId(), skillSlug, publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + if (stubMembership) { + NamespaceMember member = mock(NamespaceMember.class); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + } + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + lenient().when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)).thenReturn(List.of(skill)); + lenient().when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)).thenReturn(Optional.of(skill)); + lenient().when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)).thenReturn(Optional.empty()); + lenient().when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + lenient().when(skillRepository.save(any(Skill.class))).thenReturn(skill); + return new PublishFixture(entries); + } + + private List skillEntries(String skillName, String version) { + String skillMdContent = "---\nname: " + skillName + "\ndescription: Test\nversion: " + version + "\n---\nBody"; + PackageEntry skillMd = new PackageEntry( + "SKILL.md", + skillMdContent.getBytes(StandardCharsets.UTF_8), + skillMdContent.getBytes(StandardCharsets.UTF_8).length, + "text/markdown"); + PackageEntry readme = new PackageEntry("README.md", "content".getBytes(StandardCharsets.UTF_8), 7, "text/markdown"); + return List.of(skillMd, readme); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/web/e2e/helpers/csrf.ts b/web/e2e/helpers/csrf.ts new file mode 100644 index 00000000..410b0837 --- /dev/null +++ b/web/e2e/helpers/csrf.ts @@ -0,0 +1,27 @@ +import type { Page } from '@playwright/test' + +const csrfCookieName = 'XSRF-TOKEN' +const csrfHeaderName = 'X-XSRF-TOKEN' +const requestTimeoutMs = process.env.CI ? 12_000 : 8_000 + +async function readCsrfToken(page: Page): Promise { + const cookie = (await page.context().cookies()).find((item) => item.name === csrfCookieName) + return cookie?.value?.trim() || null +} + +export async function csrfHeaders(page: Page, headers?: Record): Promise> { + let token = await readCsrfToken(page) + if (!token) { + await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs }) + token = await readCsrfToken(page) + } + + if (!token) { + throw new Error('Missing XSRF-TOKEN cookie after auth provider warm-up') + } + + return { + ...headers, + [csrfHeaderName]: token, + } +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index 763ef1c3..91554c62 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -1,4 +1,5 @@ import { expect, type Page, type TestInfo } from '@playwright/test' +import { csrfHeaders } from './csrf' const password = 'Passw0rd!123' const cachedUserByWorker = new Map() @@ -50,15 +51,17 @@ function isRetryableStatus(status: number): boolean { } async function loginWithRetry( - request: Page['request'], + page: Page, username: string, currentPassword = password, retries = process.env.CI ? 10 : 6, ): Promise { + const request = page.context().request for (let i = 0; i < retries; i += 1) { try { const login = await request.post('/api/v1/auth/local/login', { data: { username, password: currentPassword }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -187,13 +190,13 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re } // Prefer the known-good cached account to avoid repeated failed-logins on a fixed username. - if (cached && await loginWithRetry(request, cached)) { + if (cached && await loginWithRetry(page, cached)) { await cacheSession(page, worker, cached) return { username: cached, password } } // Support environments where a deterministic worker account already exists. - if (!cached && await loginWithRetry(request, username, password, process.env.CI ? 4 : 3)) { + if (!cached && await loginWithRetry(page, username, password, process.env.CI ? 4 : 3)) { cachedUserByWorker.set(worker, username) await cacheSession(page, worker, username) return { username, password } @@ -206,6 +209,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re password, email: `${username}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -215,7 +219,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re return { username, password } } - if (register.status() === 409 && await loginWithRetry(request, username, password, process.env.CI ? 8 : 6)) { + if (register.status() === 409 && await loginWithRetry(page, username, password, process.env.CI ? 8 : 6)) { cachedUserByWorker.set(worker, username) await cacheSession(page, worker, username) return { username, password } @@ -236,6 +240,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re password, email: `${uniqueUsername}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -269,7 +274,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re // Final fallback for environments where registration is temporarily unavailable. const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate)) for (const candidate of fallbackCandidates) { - if (await loginWithRetry(request, candidate, password, process.env.CI ? 12 : 8)) { + if (await loginWithRetry(page, candidate, password, process.env.CI ? 12 : 8)) { cachedUserByWorker.set(worker, candidate) await cacheSession(page, worker, candidate) return { username: candidate, password } @@ -295,6 +300,7 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { password, email: `${uniqueUsername}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -358,8 +364,6 @@ export async function createFreshSession(page: Page, testInfo?: TestInfo) { } export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) { - const request = page.context().request - await primeAuthProviders(page) const restored = await restoreCachedSessionForAccount(page, credentials.username) @@ -368,7 +372,7 @@ export async function loginWithCredentials(page: Page, credentials: TestCredenti } const loggedIn = await loginWithRetry( - request, + page, credentials.username, credentials.password, process.env.CI ? 12 : 8, diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index a6b77adc..f255ea4a 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' import path from 'node:path' import type { APIRequestContext, Page, TestInfo } from '@playwright/test' +import { csrfHeaders } from './csrf' type CleanupTask = () => Promise @@ -237,12 +238,14 @@ export class E2eTestDataBuilder { displayName, description: `E2E namespace ${slug}`, }, + headers: await csrfHeaders(this.page), }), ) this.cleanupTasks.push(async () => { await this.request.post(`/api/web/namespaces/${encodeURIComponent(created.slug)}/archive`, { data: { reason: 'e2e cleanup' }, + headers: await csrfHeaders(this.page), }) }) @@ -270,13 +273,17 @@ export class E2eTestDataBuilder { if (namespace.status === 'FROZEN' && namespace.canUnfreeze) { return parseEnvelope( - await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`), + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`, { + headers: await csrfHeaders(this.page), + }), ) } if (namespace.status === 'ARCHIVED' && namespace.canRestore) { return parseEnvelope( - await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`), + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`, { + headers: await csrfHeaders(this.page), + }), ) } @@ -483,11 +490,12 @@ export class E2eTestDataBuilder { async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { let lastError: unknown - for (let attempt = 0; attempt < 30; attempt += 1) { + for (let attempt = 0; attempt < 60; attempt += 1) { try { await parseEnvelope( await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { data: { comment }, + headers: await csrfHeaders(this.page), }), ) return @@ -498,7 +506,7 @@ export class E2eTestDataBuilder { if (!isScanInProgress) { throw error } - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 1_000)) } } throw lastError instanceof Error ? lastError : new Error('approveReview timed out') @@ -515,6 +523,7 @@ export class E2eTestDataBuilder { await parseEnvelope<{ userId: string; role: string }>( await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, { data: { userId, role }, + headers: await csrfHeaders(this.page), }), ) } @@ -533,11 +542,14 @@ export class E2eTestDataBuilder { }, visibility: 'PUBLIC', }, + headers: await csrfHeaders(this.page), }), ) this.cleanupTasks.push(async () => { - await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`) + await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`, { + headers: await csrfHeaders(this.page), + }) }) return result diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts index 720c2796..f5834812 100644 --- a/web/e2e/public-skill-detail-anonymous.spec.ts +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -18,6 +18,8 @@ function escapeRegExp(value: string) { let seeded: PreparedSearchSeed | undefined test.describe('Public Skill Detail Anonymous Access (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeAll(async ({ browser }, testInfo) => { seeded = await prepareSearchSeed(browser, testInfo, { count: 1 }) }) diff --git a/web/e2e/skill-subscription.spec.ts b/web/e2e/skill-subscription.spec.ts index c6d74d24..22003201 100644 --- a/web/e2e/skill-subscription.spec.ts +++ b/web/e2e/skill-subscription.spec.ts @@ -16,6 +16,8 @@ function adminCredentials() { } test.describe('Skill Subscription (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) await registerSession(page, testInfo) diff --git a/web/e2e/skill-version-compare.spec.ts b/web/e2e/skill-version-compare.spec.ts index c24da0a7..274197ac 100644 --- a/web/e2e/skill-version-compare.spec.ts +++ b/web/e2e/skill-version-compare.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' +import { csrfHeaders } from './helpers/csrf' import { loginWithCredentials, registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' @@ -16,6 +17,8 @@ function adminCredentials() { } test.describe('Skill Version Compare (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) await registerSession(page, testInfo) @@ -50,6 +53,7 @@ test.describe('Skill Version Compare (Real API)', () => { targetVersion: '1.1.0', confirmWarnings: true, }, + headers: await csrfHeaders(page), } ) expect(rereleaseResponse.ok()).toBe(true) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 9e056dff..07d8c65b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9938,6 +9938,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; @@ -9962,6 +9964,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; From abdad25df3898aa63a3fa7133fcf1840314dede7 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 15 Jun 2026 14:45:09 +0800 Subject: [PATCH 34/78] feat(bootstrap): add agentguard builtin skill Signed-off-by: dongmucat <1127093059@qq.com> --- .../resources/builtin-skills/manifest.json | 5 +++ .../skill/metadata/SkillMetadataParser.java | 12 ++++++ .../metadata/SkillMetadataParserTest.java | 40 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json index 8dba7bac..a9fc8cac 100644 --- a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json +++ b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json @@ -4,6 +4,11 @@ "slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/aicontest/2026-06-11/f8a59af3-30d4-4031-80f6-ebff74b05195.zip" + }, + { + "slug": "agentguard", + "version": "1.1", + "url": "https://bjcdn.openstorage.cn/aicontest/2026-06-12/9d063bc7-223a-4762-adeb-305c268aa29e.zip" } ] } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java index 36fc1ffc..c44a4516 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java @@ -50,6 +50,9 @@ public class SkillMetadataParser { String name = extractRequiredField(frontmatter, "name"); String description = extractRequiredField(frontmatter, "description"); String version = extractOptionalField(frontmatter, "version"); + if (version == null) { + version = extractNestedOptionalField(frontmatter, "metadata", "version"); + } return new SkillMetadata(name, description, version, body, frontmatter); } @@ -122,4 +125,13 @@ public class SkillMetadataParser { Object value = frontmatter.get(fieldName); return value == null ? null : value.toString(); } + + private String extractNestedOptionalField(Map frontmatter, String objectFieldName, String fieldName) { + Object nestedValue = frontmatter.get(objectFieldName); + if (!(nestedValue instanceof Map nestedMap)) { + return null; + } + Object value = nestedMap.get(fieldName); + return value == null ? null : value.toString(); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java index 15969023..aececcd2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java @@ -123,6 +123,46 @@ class SkillMetadataParserTest { assertNull(metadata.version()); } + @Test + void testUsesMetadataVersionWhenTopLevelVersionIsMissing() { + String content = """ + --- + name: agentguard + description: Agent security guard + metadata: + author: GoPlusSecurity + version: "1.1" + --- + Body + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("agentguard", metadata.name()); + assertEquals("Agent security guard", metadata.description()); + assertEquals("1.1", metadata.version()); + } + + @Test + void testTopLevelVersionTakesPrecedenceOverMetadataVersion() { + String content = """ + --- + name: versioned-skill + description: Prefer top-level version + version: 2.0.0 + metadata: + version: "1.1" + --- + Body + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("versioned-skill", metadata.name()); + assertEquals("Prefer top-level version", metadata.description()); + assertEquals("2.0.0", metadata.version()); + } + @Test void testFallsBackToLooseFrontmatterParsingWhenYamlSyntaxIsNotStrict() { String content = """ From 4aee3a64cdda8eb4960f141855c55613169ba4e4 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Tue, 16 Jun 2026 01:30:38 +0800 Subject: [PATCH 35/78] docs(faq): supplement FAQ (zh & en) with community-sourced Q&A Add questions frequently raised in the user community that were not yet covered in the SkillHub FAQ, for both Chinese and English pages: - Recommended deployment via the one-line script vs manual image pulls - Redirected back to login page after deploying (manual deployment) - Changing the admin password / why env changes need a restart - Password change/reset requires email code (SMTP setup) - Skill naming (English only; Chinese names error in OpenClaw) - Whether unreviewed skills can be downloaded - Hiding/removing GitHub & GitLab SSO login options - Built-in Skill Scanner: iFLYTEK integration over Cisco's scanner (Apache-2.0) - Which cisco-ai-skill-scanner version is used (unpinned in Dockerfile) - Note that upgrades preserve registered skills; online docs link Signed-off-by: FenjuFu --- docs/skillhub/en/faq.md | 53 ++++++++++++++++++++++++++++++++++++++++- docs/skillhub/faq.md | 53 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index ac5326e4..2d350fee 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0 ``` -> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway. +> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway. Upgrading does not wipe the database, so already-registered skill packages will not be lost. ## Q: Why can't administrators (admin) and regular users create namespaces? @@ -136,11 +136,62 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u A: When using the OpenClaw CLI, you can specify the namespace using the `--` format for operations like search or installation. If you encounter issues finding it on the web interface, you can also manage it by exporting the skill package and importing it into your target namespace. +## Q: What is the recommended deployment method? Can I pull the images and deploy manually? + +A: We recommend the official one-line deployment script. Pulling images and deploying manually is not recommended (manual deployment is prone to initialization issues such as being redirected back to the login page after logging in): + +```bash +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest +``` + +The script performs a series of initialization steps. The generated runtime configuration is located at `/tmp/skillhub-runtime/` by default (containing `.env.release` and the docker-compose file). + +## Q: After deployment, I enter the correct username and password but get redirected back to the login page? + +A: This is most commonly seen with **manual deployment** (caused by API errors or incomplete initialization). Suggestions: + +1. Switch to the one-line script above for deployment. +2. If necessary, clear and recreate the PostgreSQL data volume, then log in again. +3. If a reverse proxy is in front, verify that it forwards requests correctly. + +## Q: How do I change the admin password? Why don't my config changes take effect? + +A: Environment variables are read at container startup, so you must restart the containers after changing them. + +1. Edit `/tmp/skillhub-runtime/.env.release` in the runtime directory (refer to [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example)). +2. Restart the relevant containers. +3. If the password was already persisted to the database and the change still doesn't take effect, you may need to clear the corresponding data and re-initialize. + +## Q: Is an email verification code required to change / reset a password? + +A: Yes. By default, passwords are changed or reset via an email verification code, so SMTP must be configured first. See [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md). Administrators can also reset it via `.env.release`. + +## Q: Can a skill have a Chinese name? + +A: Skill names are generally in English; Chinese names are not currently supported (using a Chinese skill name in OpenClaw will cause an error). + +## Q: Can unreviewed skills be downloaded? + +A: As long as you have permission to view it, it can generally be downloaded. + +## Q: How do I hide or remove the GitHub / GitLab SSO login options on the login page? + +A: Edit `application.yml` and comment out or delete the `github` and `gitlab` blocks under `spring.security.oauth2.client.registration`, along with their corresponding `provider` sections. Spring Boot then won't create these registrations at startup, and the login page won't show those entries. + +## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use? + +A: SkillHub has built-in security scanning. The scanner integration, task orchestration, audit persistence, and deployment integration are implemented by the iFLYTEK team; the underlying scanning service uses Cisco's [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner) (Apache License 2.0, copyright Cisco). + +## Q: Which version of cisco-ai-skill-scanner does SkillHub use? + +A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly without pinning a version, so the latest version on PyPI is pulled when the image is built. To pin a version, do so yourself when customizing the build. + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues +- **Online Docs**: https://www.astron-skillhub.org/ - **Documentation**: Refer to the project README.md - **Community Discussions**: https://github.com/iflytek/skillhub/discussions diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index a29d6d85..f27253b0 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0 ``` -> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。 +> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。升级不会清空数据库,已录入的技能包不会丢失。 ## Q: 为什么管理员(admin)和普通用户都无法创建命名空间? @@ -136,11 +136,62 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u A: 使用 OpenClaw CLI 命令行工具时,可以通过 `--` 的格式来指定命名空间进行操作(例如搜索、安装)。如果在网页端搜索遇到问题,也可以尝试通过先导出技能、再导入到目标命名空间的方式来完成跨空间操作。 +## Q: 推荐的部署方式是什么?可以自己拉镜像手动部署吗? + +A: 推荐使用官方一键部署脚本,不建议自己拉取镜像手动部署(手动部署容易出现登录后跳回登录页等初始化问题): + +```bash +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest +``` + +脚本会执行一系列初始化操作,生成的运行时配置默认位于 `/tmp/skillhub-runtime/`(包含 `.env.release` 和 docker-compose 文件)。 + +## Q: 部署后输入正确的账号密码,却又跳回登录页? + +A: 该现象多见于「手动部署」场景(接口异常或初始化未完成导致)。建议: + +1. 改用上面的一键脚本部署。 +2. 必要时清空 PostgreSQL 数据卷后重建再登录。 +3. 若前置了反向代理,检查代理配置是否正确转发。 + +## Q: 如何修改 admin 密码?修改配置后不生效? + +A: 环境变量在容器启动时读取,修改后必须重启容器才会生效。 + +1. 修改运行时目录下的 `/tmp/skillhub-runtime/.env.release`(参考仓库 [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example))。 +2. 重启相关容器。 +3. 若此前密码已写入数据库导致仍不生效,可能需要清理对应数据后重新初始化。 + +## Q: 修改 / 找回密码必须使用邮箱验证码吗? + +A: 是的,默认通过邮箱验证码修改或找回密码,因此需要先配置 SMTP。配置方法参考 [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md)。管理员也可在 `.env.release` 中进行重置。 + +## Q: skill 可以起中文名吗? + +A: skill name 一般使用英文,目前不支持中文名(在 OpenClaw 中使用中文 skill 名会报错)。 + +## Q: 未审核的 skill 可以下载吗? + +A: 只要拥有可查看的权限,一般都可以下载。 + +## Q: 如何隐藏或删除登录页的 GitHub / GitLab SSO 登录方式? + +A: 修改 `application.yml`,注释或删除 `spring.security.oauth2.client.registration` 下的 `github` 和 `gitlab` 两块,并删除对应的 `provider` 段。Spring Boot 启动时便不会创建这两个注册,登录页也不会再显示对应入口。 + +## Q: SkillHub 的安全扫描(Skill Scanner)是讯飞自研的吗?使用什么协议? + +A: SkillHub 内置安全扫描能力。其中扫描接入、任务编排、审计落库和部署集成由讯飞团队实现;底层扫描服务使用 Cisco 的 [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner)(Apache License 2.0,版权归 Cisco)。 + +## Q: SkillHub 使用的 cisco-ai-skill-scanner 是哪个版本? + +A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`,未锁定版本,因此构建镜像时会拉取 PyPI 上的最新版本。如需固定版本,可在二次开发时自行锁定。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues +- **在线文档**: https://www.astron-skillhub.org/ - **文档**: 参考项目 README.md - **社区讨论**: https://github.com/iflytek/skillhub/discussions From e3f84b074f98b2c20c1364f732b14ff32e9c9437 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Tue, 16 Jun 2026 01:37:01 +0800 Subject: [PATCH 36/78] docs(faq): use github.io docs URL for online docs link Signed-off-by: FenjuFu --- docs/skillhub/en/faq.md | 2 +- docs/skillhub/faq.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 2d350fee..e65541f6 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -191,7 +191,7 @@ A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly witho A: You can get help through the following channels: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues -- **Online Docs**: https://www.astron-skillhub.org/ +- **Online Docs**: https://iflytek.github.io/skillhub/ - **Documentation**: Refer to the project README.md - **Community Discussions**: https://github.com/iflytek/skillhub/discussions diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index f27253b0..4d9715fc 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -191,7 +191,7 @@ A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`, A: 可以通过以下方式获取帮助: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues -- **在线文档**: https://www.astron-skillhub.org/ +- **在线文档**: https://iflytek.github.io/skillhub/ - **文档**: 参考项目 README.md - **社区讨论**: https://github.com/iflytek/skillhub/discussions From e57667ae60069890ccb040fbafd4944ea7d94483 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Tue, 16 Jun 2026 10:15:36 +0800 Subject: [PATCH 37/78] docs(faq): add CLI publish & deployment Q&A (zh & en) Add more community-sourced questions (both Chinese and English): - Troubleshooting CLI `skillhub publish` returning 400 (name conflict, SKILL.md location/frontmatter, namespace membership, etc.) - Required skill package structure (SKILL.md in root) - "malformed input" on publish caused by non-UTF-8 / Chinese-path zips - Per-package file-count limit and how to raise it - Minimum server version for CLI features (v0.2.7+) - PostgreSQL-only (no MySQL); plugins not distributable yet - How to check server/CLI versions and customize via secondary dev Signed-off-by: FenjuFu --- docs/skillhub/en/faq.md | 60 +++++++++++++++++++++++++++++++++++++++++ docs/skillhub/faq.md | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index e65541f6..9cf6d556 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -186,6 +186,66 @@ A: SkillHub has built-in security scanning. The scanner integration, task orches A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly without pinning a version, so the latest version on PyPI is pulled when the image is built. To pin a version, do so yourself when customizing the build. +## Q: How do I troubleshoot a `registry returned 400` error from `skillhub publish` (CLI)? + +A: A 400 usually means backend validation failed. Common causes: + +- `SKILL.md` is not in the package root directory; +- `SKILL.md` frontmatter is missing `name` / `description` or is malformed; +- name or version conflict (e.g. `error.skill.publish.nameConflict`, meaning a skill with the same name is already published in that namespace) — change `name` in `SKILL.md`, use another namespace, or have an admin handle the existing skill; +- the namespace does not exist, or you are not a member of it; +- the package contains suspected tokens/secrets that the CLI cannot confirm skipping; +- file type / size / path is not allowed. + +You can inspect the server logs to locate the cause: + +```bash +docker logs --tail=300 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest' +``` + +## Q: What directory structure does a skill package require? + +A: The package root directory must contain a `SKILL.md` file, whose frontmatter must include fields such as `name` and `description`. + +## Q: Publishing fails with "package validation failed / malformed input" — what do I do? + +A: This error occurs while unzipping and reading file names, usually because the archive is not UTF-8 encoded (e.g. created with the built-in Windows compression tool) or contains Chinese/non-ASCII paths. Repackage using UTF-8 encoding and avoid Chinese / special-character paths. + +## Q: How many files can a skill package contain? What if I hit the file-count limit? + +A: The default limit is **100 files** (this is separate from the 100MB size limit). To raise it, change the `skillhub.publish.max-file-count` setting, or override it via an environment variable at deploy time: + +```bash +SKILLHUB_PUBLISH_MAX_FILE_COUNT=500 +``` + +Restart the containers for the change to take effect. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended. + +## Q: Is there a server version requirement for using the CLI (publish / download, etc.)? + +A: A SkillHub server image of **v0.2.7 or later** is required for CLI features. + +## Q: Does SkillHub support MySQL? + +A: Currently only PostgreSQL is supported; MySQL is not supported. + +## Q: Can SkillHub be used to distribute Plugins? + +A: Not supported for now. + +## Q: How do I check the SkillHub version? How do I customize it (e.g. change the logo)? + +A: + +- Check the server image version: + +```bash +docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}' +``` + +- Check the CLI version: `skillhub version`. +- For customization (e.g. changing the logo), it is recommended to fork the latest code, modify it, and build your own Docker image. + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index 4d9715fc..2aac0a54 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -186,6 +186,66 @@ A: SkillHub 内置安全扫描能力。其中扫描接入、任务编排、审 A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`,未锁定版本,因此构建镜像时会拉取 PyPI 上的最新版本。如需固定版本,可在二次开发时自行锁定。 +## Q: 使用 CLI `skillhub publish` 报错 `registry returned 400` 怎么排查? + +A: 400 通常是后端校验未通过。常见原因: + +- `SKILL.md` 不在技能包根目录; +- `SKILL.md` 的 frontmatter 缺少 `name` / `description` 或格式错误; +- 名称或版本冲突(如 `error.skill.publish.nameConflict`,表示该 namespace 下已存在同名的已发布技能)——可改 `SKILL.md` 里的 `name`、换一个 namespace,或让管理员处理已有同名技能; +- namespace 不存在,或你不是该 namespace 的成员; +- 包内含疑似 token/secret,CLI 无法确认跳过; +- 文件类型 / 大小 / 路径不合规。 + +可用以下命令查看服务端日志定位: + +```bash +docker logs --tail=300 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest' +``` + +## Q: 技能包的目录结构有什么要求? + +A: 技能包根目录必须包含一个 `SKILL.md` 文件,且其 frontmatter 需包含 `name`、`description` 等字段。 + +## Q: 发布时报“技能包校验失败 / malformed input”怎么办? + +A: 该错误发生在 zip 解包读取文件名阶段,通常是压缩包不是 UTF-8 编码(例如用 Windows 自带压缩工具生成)或包内含中文路径导致。请使用 UTF-8 编码重新打包,并避免中文 / 特殊字符路径。 + +## Q: 技能包能包含多少个文件?提示文件数超限怎么办? + +A: 默认上限为 **100 个文件**(这与 100MB 的大小限制是两回事)。如需放宽,修改配置项 `skillhub.publish.max-file-count`,或在部署时用环境变量覆盖: + +```bash +SKILLHUB_PUBLISH_MAX_FILE_COUNT=500 +``` + +修改后需重启容器生效。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。 + +## Q: 使用 CLI(发布 / 下载等)对服务端版本有要求吗? + +A: 需要 SkillHub 服务端镜像 **v0.2.7 及以上** 才支持 CLI 功能。 + +## Q: SkillHub 支持 MySQL 数据库吗? + +A: 目前仅支持 PostgreSQL,暂不支持 MySQL。 + +## Q: SkillHub 可以用来分发 Plugin 吗? + +A: 暂不支持。 + +## Q: 如何查看 SkillHub 的版本?想做定制(如修改 logo)怎么办? + +A: + +- 查看服务端镜像版本: + +```bash +docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}' +``` + +- 查看 CLI 版本:`skillhub version`。 +- 如需定制(如修改 logo 等),建议基于最新代码进行二次开发并自行构建 docker 镜像。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: From 6d6577faa27f9eae586aa4bb45ff6ef5e491c4b9 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 10:23:02 +0800 Subject: [PATCH 38/78] fix(bootstrap): confirm builtin publish warnings Signed-off-by: dongmucat <1127093059@qq.com> --- .../bootstrap/BuiltinSkillInitializer.java | 3 +- .../BuiltinSkillInitializerTest.java | 31 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 20b9a363..7ed8c00d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -50,6 +50,7 @@ public class BuiltinSkillInitializer { private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class); private static final Set SYSTEM_PUBLISHER_ROLES = Set.of("SUPER_ADMIN"); + private static final boolean CONFIRM_BUILTIN_PUBLISH_WARNINGS = true; private final BuiltinSkillProperties properties; private final BuiltinSkillManifestLoader manifestLoader; @@ -243,7 +244,7 @@ public class BuiltinSkillInitializer { SYSTEM_PUBLISHER_ID, SkillVisibility.PUBLIC, SYSTEM_PUBLISHER_ROLES, - false + CONFIRM_BUILTIN_PUBLISH_WARNINGS ); log.info("Published built-in skill slug={} version={} to @{}", item.slug(), item.version(), GLOBAL_NAMESPACE); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 296f547a..bc5602a0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.bootstrap; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; @@ -106,7 +107,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(manifestLoader, never()).load(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -116,7 +117,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(manifestLoader, never()).load(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -144,7 +145,7 @@ class BuiltinSkillInitializerTest { verify(namespaceMemberRepository, never()).save(any()); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -162,7 +163,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -180,7 +181,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -192,7 +193,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -206,7 +207,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader).download(URI.create(ITEM.url())); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -222,7 +223,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); assertThat(output).doesNotContain("Failed to synchronize built-in skill slug=skillhub-hello"); } @@ -232,7 +233,7 @@ class BuiltinSkillInitializerTest { runInitializer(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -241,7 +242,7 @@ class BuiltinSkillInitializerTest { runInitializer(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -259,7 +260,7 @@ class BuiltinSkillInitializerTest { eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), - eq(false) + eq(true) ); assertThat(entriesCaptor.getValue()).isEqualTo(entries); } @@ -297,7 +298,7 @@ class BuiltinSkillInitializerTest { .thenReturn(List.of()) .thenReturn(List.of(builtinSkill)); when(skillPublishService.publishFromEntries( - eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false))) + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true))) .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); @@ -305,7 +306,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(skillPublishService).publishFromEntries( - eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true)); } @Test @@ -318,7 +319,7 @@ class BuiltinSkillInitializerTest { .thenReturn(List.of()) .thenReturn(List.of(builtinSkill)); when(skillPublishService.publishFromEntries( - eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false))) + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true))) .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( @@ -329,7 +330,7 @@ class BuiltinSkillInitializerTest { verify(skillFileRepository).findByVersionId(200L); verify(skillPublishService).publishFromEntries( - eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true)); assertThat(output).contains("Failed to publish built-in skill slug=skillhub-hello version=1.0.0"); assertThat(output).doesNotContain("was published concurrently, skipping"); } From a865960cf3ae5b873f51e63229b31f054341acbc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 09:54:32 +0800 Subject: [PATCH 39/78] fix(notification): ISSUE-51 add profile review notifications Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 247 ++++++++++++++++++ .../portal/NotificationController.java | 3 + .../listener/NotificationEventListener.java | 16 ++ .../skillhub/listener/RecipientResolver.java | 10 + .../portal/NotificationControllerTest.java | 22 ++ .../NotificationEventListenerTest.java | 15 ++ .../listener/RecipientResolverTest.java | 16 ++ .../event/ProfileReviewSubmittedEvent.java | 10 + .../domain/user/UserProfileService.java | 22 +- .../domain/user/UserProfileServiceTest.java | 56 ++++ web/e2e/reviews-pagination.spec.ts | 8 + web/src/api/generated/schema.d.ts | 4 + web/src/app/router.tsx | 3 + web/src/pages/dashboard/reviews.test.ts | 14 + web/src/pages/dashboard/reviews.tsx | 22 +- 15 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 docs/api/notification.openapi.yaml create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml new file mode 100644 index 00000000..af5ce4fd --- /dev/null +++ b/docs/api/notification.openapi.yaml @@ -0,0 +1,247 @@ +openapi: 3.0.3 +info: + title: SkillHub Notification API + version: 1.0.0 + description: In-app notification list, unread count, and SSE event contract. +servers: + - url: /api/web + - url: /api/v1 +paths: + /notifications: + get: + summary: List notifications for the current user + operationId: listNotifications + security: + - sessionAuth: [] + parameters: + - name: category + in: query + required: false + description: Optional category filter. Profile review notifications reuse REVIEW. + schema: + type: string + enum: [PUBLISH, REVIEW, PROMOTION, REPORT] + example: REVIEW + - name: page + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + example: 0 + - name: size + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + example: 20 + responses: + "200": + description: Notification page. + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationPageResponse" + "400": + description: Invalid category. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Authentication required. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /notifications/unread-count: + get: + summary: Get unread notification count for the current user + operationId: getNotificationUnreadCount + security: + - sessionAuth: [] + responses: + "200": + description: Unread count. + content: + application/json: + schema: + $ref: "#/components/schemas/UnreadCountResponse" + "401": + description: Authentication required. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /notifications/sse: + get: + summary: Open notification SSE stream for the current user + operationId: streamNotifications + security: + - sessionAuth: [] + responses: + "200": + description: SSE stream. Each `notification` event carries NotificationSsePayload. + content: + text/event-stream: + schema: + type: string + "401": + description: Authentication required. +components: + securitySchemes: + sessionAuth: + type: apiKey + in: cookie + name: SESSION + schemas: + NotificationPageResponse: + type: object + properties: + code: + type: integer + example: 0 + msg: + type: string + example: ok + data: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Notification" + total: + type: integer + format: int64 + example: 1 + page: + type: integer + example: 0 + size: + type: integer + example: 20 + timestamp: + type: string + format: date-time + requestId: + type: string + Notification: + type: object + properties: + id: + type: integer + format: int64 + example: 1001 + category: + type: string + enum: [PUBLISH, REVIEW, PROMOTION, REPORT] + example: REVIEW + eventType: + type: string + description: Known values include REVIEW_SUBMITTED, REVIEW_APPROVED, REVIEW_REJECTED, PROFILE_REVIEW_SUBMITTED, PROMOTION_SUBMITTED, PROMOTION_APPROVED, PROMOTION_REJECTED, REPORT_SUBMITTED, REPORT_RESOLVED, SKILL_PUBLISHED, SUBSCRIPTION_NEW_VERSION, and SUBSCRIPTION_VERSION_YANKED. + example: PROFILE_REVIEW_SUBMITTED + title: + type: string + example: Profile review submitted + bodyJson: + type: string + description: JSON string. For PROFILE_REVIEW_SUBMITTED it contains profileReviewId, submitterId, and fields. + example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' + entityType: + type: string + example: PROFILE_REVIEW + entityId: + type: integer + format: int64 + example: 77 + status: + type: string + enum: [UNREAD, READ] + example: UNREAD + createdAt: + type: string + format: date-time + readAt: + type: string + format: date-time + nullable: true + targetType: + type: string + example: PROFILE_REVIEW + targetId: + type: integer + format: int64 + example: 77 + targetRoute: + type: string + description: Profile review notifications route to the admin profile review queue. + example: /dashboard/reviews?type=profile + NotificationSsePayload: + type: object + properties: + id: + type: integer + format: int64 + example: 1001 + category: + type: string + example: REVIEW + eventType: + type: string + example: PROFILE_REVIEW_SUBMITTED + title: + type: string + example: Profile review submitted + bodyJson: + type: string + example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' + entityType: + type: string + example: PROFILE_REVIEW + entityId: + type: integer + format: int64 + example: 77 + createdAt: + type: string + format: date-time + UnreadCountResponse: + type: object + properties: + code: + type: integer + example: 0 + msg: + type: string + example: ok + data: + type: object + properties: + count: + type: integer + format: int64 + example: 1 + timestamp: + type: string + format: date-time + requestId: + type: string + ErrorResponse: + type: object + properties: + code: + type: integer + example: 400 + msg: + type: string + example: Invalid category + timestamp: + type: string + format: date-time + requestId: + type: string diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index 6a1f1c68..7ab2f759 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -113,6 +113,9 @@ public class NotificationController extends BaseApiController { if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) { return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); } + if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) { + return new NotificationTarget("PROFILE_REVIEW", entityId, "/dashboard/reviews?type=profile"); + } if ("PROMOTION_SUBMITTED".equals(eventType)) { return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions"); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index 74ad10d8..6d38c9f7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -127,6 +127,22 @@ public class NotificationEventListener { }); } + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) { + String title = "Profile review submitted"; + Map body = new LinkedHashMap<>(); + body.put("profileReviewId", event.profileReviewId()); + body.put("submitterId", event.submitterId()); + body.put("fields", event.fields()); + String json = toJson(body); + List admins = recipientResolver.resolvePlatformUserAdmins(); + for (String admin : admins.stream().distinct().toList()) { + dispatcher.dispatch(admin, NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId()); + } + } + @Async("skillhubEventExecutor") @TransactionalEventListener public void onReviewApproved(ReviewApprovedEvent event) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java index 90e076d7..0ea087d3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java @@ -39,4 +39,14 @@ public class RecipientResolver { List::copyOf )); } + + public List resolvePlatformUserAdmins() { + return userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN")) + .stream() + .map(binding -> binding.getUserId()) + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toCollection(LinkedHashSet::new), + List::copyOf + )); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index 2536180e..2f344f2c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -70,6 +70,28 @@ class NotificationControllerTest { verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class)); } + @Test + void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() { + Notification notification = notification( + 15L, + NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", + "{\"profileReviewId\":77,\"submitterId\":\"user-1\",\"fields\":[\"displayName\"]}", + "PROFILE_REVIEW", + 77L + ); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("admin-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(notification))); + + PageResponse page = controller.list("admin-1", "REVIEW", 0, 20).data(); + + assertThat(page.items()).singleElement().satisfies(item -> { + assertThat(item.targetType()).isEqualTo("PROFILE_REVIEW"); + assertThat(item.targetId()).isEqualTo(77L); + assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews?type=profile"); + }); + } + @Test void list_shouldExposeSkillRouteForResolvedWorkflowNotifications() { Notification notification = notification( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..8ffd251f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -99,6 +99,21 @@ class NotificationEventListenerTest { verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any()); } + @Test + void onProfileReviewSubmitted_shouldDispatchToPlatformUserAdmins() throws Exception { + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(recipientResolver.resolvePlatformUserAdmins()) + .thenReturn(List.of("user-admin-1", "super-admin-1", "user-admin-1")); + + listener.onProfileReviewSubmitted( + new ProfileReviewSubmittedEvent(77L, "submitter-1", List.of("displayName"))); + + verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW), + eq("PROFILE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("PROFILE_REVIEW"), eq(77L)); + verify(dispatcher).dispatch(eq("user-admin-1"), any(), any(), any(), any(), any(), any()); + verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); + } + @Test void onReviewApproved_shouldDispatchToSubmitter() throws Exception { Skill skill = mockSkill(1L); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java index 20325b9d..c056a1ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java @@ -80,4 +80,20 @@ class RecipientResolverTest { assertThat(result).containsExactly("skill-admin", "super-admin"); } + + @Test + void resolvePlatformUserAdmins_shouldReturnUserAdminsAndSuperAdmins() { + UserRoleBinding userAdmin = mock(UserRoleBinding.class); + UserRoleBinding superAdmin = mock(UserRoleBinding.class); + UserRoleBinding duplicate = mock(UserRoleBinding.class); + when(userAdmin.getUserId()).thenReturn("user-admin"); + when(superAdmin.getUserId()).thenReturn("super-admin"); + when(duplicate.getUserId()).thenReturn("user-admin"); + when(userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN"))) + .thenReturn(List.of(userAdmin, superAdmin, duplicate)); + + List result = resolver.resolvePlatformUserAdmins(); + + assertThat(result).containsExactly("user-admin", "super-admin"); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java new file mode 100644 index 00000000..f0c250d1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.event; + +import java.util.List; + +public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List fields) { + + public ProfileReviewSubmittedEvent { + fields = List.copyOf(fields); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java index 68b3951b..c6f037c3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java @@ -3,10 +3,13 @@ package com.iflytek.skillhub.domain.user; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -29,19 +32,22 @@ public class UserProfileService { private final ProfileModerationConfig moderationConfig; private final ProfileFieldPolicyConfig fieldPolicyConfig; private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; public UserProfileService(UserAccountRepository userAccountRepository, ProfileChangeRequestRepository changeRequestRepository, ProfileModerationService moderationService, ProfileModerationConfig moderationConfig, ProfileFieldPolicyConfig fieldPolicyConfig, - AuditLogService auditLogService) { + AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher) { this.userAccountRepository = userAccountRepository; this.changeRequestRepository = changeRequestRepository; this.moderationService = moderationService; this.moderationConfig = moderationConfig; this.fieldPolicyConfig = fieldPolicyConfig; this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; } /** @@ -112,8 +118,10 @@ public class UserProfileService { // 5. Queue review changes if (!reviewChanges.isEmpty()) { cancelPendingRequests(userId); - saveChangeRequest(userId, reviewChanges, oldValues, ProfileChangeStatus.PENDING, - machineTag, null); + ProfileChangeRequest pendingRequest = saveChangeRequest(userId, reviewChanges, oldValues, + ProfileChangeStatus.PENDING, machineTag, null); + eventPublisher.publishEvent(new ProfileReviewSubmittedEvent( + pendingRequest.getId(), userId, List.copyOf(reviewChanges.keySet()))); } // 6. Return appropriate result @@ -166,9 +174,9 @@ public class UserProfileService { /** * Persist a change request record for audit and review purposes. */ - private void saveChangeRequest(String userId, Map changes, - Map oldValues, ProfileChangeStatus status, - String machineResult, String machineReason) { + private ProfileChangeRequest saveChangeRequest(String userId, Map changes, + Map oldValues, ProfileChangeStatus status, + String machineResult, String machineReason) { ProfileChangeRequest request = new ProfileChangeRequest( userId, toJson(changes), @@ -177,7 +185,7 @@ public class UserProfileService { machineResult, machineReason ); - changeRequestRepository.save(request); + return changeRequestRepository.save(request); } private String toJson(Object obj) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java index d6af56f6..1346e6a2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java @@ -1,16 +1,21 @@ package com.iflytek.skillhub.domain.user; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -41,9 +46,24 @@ class UserProfileServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private UserProfileService userProfileService; + @BeforeEach + void setUp() { + AtomicLong ids = new AtomicLong(1L); + lenient().when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + if (request.getId() == null) { + setField(request, "id", ids.getAndIncrement()); + } + return request; + }); + } + // -- Helper -- private UserAccount testUser() { @@ -136,6 +156,32 @@ class UserProfileServiceTest { verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); } + @Test + void updateProfile_humanReviewEnabled_shouldPublishProfileReviewSubmittedEvent() { + var user = testUser(); + when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(moderationConfig.machineReview()).thenReturn(false); + when(moderationConfig.humanReview()).thenReturn(true); + stubFieldPolicies(true); + when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING)) + .thenReturn(List.of()); + when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + setField(request, "id", 77L); + return request; + }); + + userProfileService.updateProfile( + "user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent"); + + var eventCaptor = ArgumentCaptor.forClass(ProfileReviewSubmittedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + ProfileReviewSubmittedEvent event = eventCaptor.getValue(); + assertEquals(77L, event.profileReviewId()); + assertEquals("user-1", event.submitterId()); + assertEquals(List.of("displayName"), event.fields()); + } + // ===== AC-P-005: Overwrite existing PENDING request ===== @Test @@ -221,4 +267,14 @@ class UserProfileServiceTest { userProfileService.updateProfile( "nonexistent", displayNameChange("Name"), "req-1", "127.0.0.1", "TestAgent")); } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } } diff --git a/web/e2e/reviews-pagination.spec.ts b/web/e2e/reviews-pagination.spec.ts index f9906a35..83626e4d 100644 --- a/web/e2e/reviews-pagination.spec.ts +++ b/web/e2e/reviews-pagination.spec.ts @@ -81,4 +81,12 @@ test.describe('Review Management Pagination (Real API)', () => { } } }) + + test('opens the profile review queue from the review type search param', async ({ page }) => { + await page.goto('/dashboard/reviews?type=profile') + + await expect(page).toHaveURL(/\/dashboard\/reviews\?type=profile$/) + await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Profile Review Queue' })).toBeVisible() + }) }) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 9e056dff..07d8c65b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9938,6 +9938,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; @@ -9962,6 +9964,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d654e026..c9b3bac8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -306,6 +306,9 @@ const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reviews', beforeLoad: requireAuth, + validateSearch: (search: Record): { type?: 'skill' | 'profile' } => ({ + type: search.type === 'skill' || search.type === 'profile' ? search.type : undefined, + }), component: ReviewsPage, }) diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 694a0dce..186c15eb 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' +const useSearchMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), + useSearch: () => useSearchMock(), })) vi.mock('lucide-react', () => ({ @@ -119,6 +121,7 @@ describe('ReviewsPage', () => { data: [], isLoading: false, }) + useSearchMock.mockReturnValue({}) useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { if (!enabled) { return { data: null, isLoading: false } @@ -180,4 +183,15 @@ describe('ReviewsPage', () => { expect(paginationProps[0]?.page).toBe(0) expect(paginationProps[0]?.totalPages).toBe(1) }) + + it('uses the profile review tab when the review type search param requests it', () => { + hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN' || role === 'USER_ADMIN') + userMock.platformRoles = ['SUPER_ADMIN'] + useSearchMock.mockReturnValue({ type: 'profile' }) + + renderToStaticMarkup(createElement(ReviewsPage)) + + expect(useReviewListMock).toHaveBeenCalled() + expect(useReviewListMock.mock.calls.every((call) => call[5] === false)).toBe(true) + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 4ead6c84..94c0ded8 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' @@ -38,6 +38,7 @@ const PAGE_SIZE = 20 export function ReviewsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() + const search = useSearch({ from: '/dashboard/reviews' }) const { hasRole, user } = useAuth() const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ @@ -56,6 +57,9 @@ export function ReviewsPage() { // Determine default top-level tab const defaultType = isSkillAdmin ? 'skill' : 'profile' + const requestedType = search.type === 'skill' || search.type === 'profile' ? search.type : undefined + const activeType = showTypeTabs ? requestedType ?? defaultType : defaultType + const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill' useEffect(() => { if (hasGlobalReviewAccess || isLoadingNamespaces) { @@ -70,9 +74,9 @@ export function ReviewsPage() { void navigate({ to: '/dashboard', replace: true }) }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) - const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING') - const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED') - const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED') + const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING') + const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED') + const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'REJECTED') const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language) @@ -93,6 +97,14 @@ export function ReviewsPage() { }) } + function handleTypeChange(value: string) { + void navigate({ + to: '/dashboard/reviews', + search: { type: value === 'profile' ? 'profile' : 'skill' }, + replace: true, + }) + } + function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) { const currentPage = pages[status] return ( @@ -249,7 +261,7 @@ export function ReviewsPage() { {showTypeTabs ? ( - + Date: Fri, 12 Jun 2026 14:40:41 +0800 Subject: [PATCH 40/78] fix(review): allow super admin self promotion review Signed-off-by: dongmucat <1127093059@qq.com> --- docs/05-business-flows.md | 2 +- .../security/authorization.md | 2 +- .../security/authorization.md | 2 +- .../service/PromotionPortalAppService.java | 28 ++- .../PromotionApprovalFlowIntegrationTest.java | 92 ++++++++-- .../NotificationEventListenerTest.java | 26 +++ .../PromotionPortalAppServiceTest.java | 170 ++++++++++++++++++ .../review/ReviewPermissionChecker.java | 2 +- .../domain/review/PromotionServiceTest.java | 45 +++++ .../review/ReviewPermissionCheckerTest.java | 18 +- 10 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index e0719939..3cd6a7cb 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -112,7 +112,7 @@ | skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 | | namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 | | SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 | -| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;review 场景下还能审自己的提交 | 可 | 可 | +| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;promotion 和 review 场景下还能审自己的提交 | 可 | 可 | ### 对象存储写入策略 diff --git a/document/docs/02-administration/security/authorization.md b/document/docs/02-administration/security/authorization.md index 36d7fef3..75ff506e 100644 --- a/document/docs/02-administration/security/authorization.md +++ b/document/docs/02-administration/security/authorization.md @@ -23,7 +23,7 @@ SkillHub 采用基于角色的访问控制(RBAC)系统。 | 角色 | 代码 | 实际能力 | |------|------|----------| -| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion,且普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 | +| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;可以审批自己提交的 promotion;普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 | | 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本(yank)、处理技能举报;可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report;不能分配平台角色、不能看审计日志、不能管理用户。 | | 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 | | 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs` 和 `/actuator/prometheus`;治理工作台中只能看 activity,不能处理 review/promotion/report,也不能管理用户或技能。 | diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md index 4c0359be..ee411ca4 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md @@ -23,7 +23,7 @@ The database migration seeds only 4 explicit platform roles: | Role | Code | Effective behavior | |------|------|--------------------| -| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. | +| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Can approve their own promotion request. For normal review tasks, the self-submission exception is also only bypassed by `SUPER_ADMIN`. | | Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. | | User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. | | Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index 16a1e95b..ef4b1888 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -75,7 +75,8 @@ public class PromotionPortalAppService { comment, platformRoles(userId) ); - recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, detailWithComment(comment)); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, + detailWithComment(comment, promotion.getSubmittedBy().equals(userId))); return governanceQueryRepository.getPromotionResponse(promotion); } @@ -89,7 +90,8 @@ public class PromotionPortalAppService { comment, platformRoles(userId) ); - recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext, detailWithComment(comment)); + recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext, + detailWithComment(comment, promotion.getSubmittedBy().equals(userId))); return governanceQueryRepository.getPromotionResponse(promotion); } @@ -159,10 +161,26 @@ public class PromotionPortalAppService { ); } - private String detailWithComment(String comment) { - if (comment == null || comment.isBlank()) { + private String detailWithComment(String comment, boolean selfReview) { + boolean hasComment = comment != null && !comment.isBlank(); + if (!hasComment && !selfReview) { return null; } - return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + StringBuilder detail = new StringBuilder("{"); + if (hasComment) { + detail.append("\"comment\":\"").append(escapeJson(comment)).append("\""); + } + if (selfReview) { + if (hasComment) { + detail.append(","); + } + detail.append("\"selfReview\":true"); + } + detail.append("}"); + return detail.toString(); + } + + private String escapeJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java index 44d39712..a2268a67 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java @@ -38,6 +38,9 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -53,6 +56,8 @@ class PromotionApprovalFlowIntegrationTest { private static final String SUBMITTER_ID = "promotion-owner"; private static final String REVIEWER_ID = "docker-admin"; + private static final String SELF_SUPER_ADMIN_ID = "self-super-admin"; + private static final String SELF_SKILL_ADMIN_ID = "self-skill-admin"; @Autowired private MockMvc mockMvc; @@ -127,31 +132,86 @@ class PromotionApprovalFlowIntegrationTest { assertThat(targetVersions.get(0).getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); } + @Test + void approvePromotion_allowsSuperAdminToApproveOwnPromotionThroughV1Route() throws Exception { + when(rbacService.getUserRoleCodes(SELF_SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + PromotionGraph graph = createPromotionGraph(SELF_SUPER_ADMIN_ID); + + mockMvc.perform(post("/api/v1/promotions/" + graph.request().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"self approve\"}") + .with(authentication(portalAuth(SELF_SUPER_ADMIN_ID, "SUPER_ADMIN"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(graph.request().getId())) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.reviewedBy").value(SELF_SUPER_ADMIN_ID)) + .andExpect(jsonPath("$.data.submittedBy").value(SELF_SUPER_ADMIN_ID)); + + PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId())) + .stream() + .findFirst() + .orElseThrow(); + assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED); + assertThat(savedRequest.getReviewedBy()).isEqualTo(SELF_SUPER_ADMIN_ID); + assertThat(savedRequest.getTargetSkillId()).isNotNull(); + verify(governanceNotificationService).notifyUser( + eq(SELF_SUPER_ADMIN_ID), + eq("PROMOTION"), + eq("PROMOTION_REQUEST"), + eq(graph.request().getId()), + eq("Promotion approved"), + any() + ); + } + + @Test + void approvePromotion_rejectsSkillAdminSelfApprovalThroughWebRoute() throws Exception { + when(rbacService.getUserRoleCodes(SELF_SKILL_ADMIN_ID)).thenReturn(Set.of("SKILL_ADMIN")); + PromotionGraph graph = createPromotionGraph(SELF_SKILL_ADMIN_ID); + + mockMvc.perform(post("/api/web/promotions/" + graph.request().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"self approve\"}") + .with(authentication(portalAuth(SELF_SKILL_ADMIN_ID, "SKILL_ADMIN"))) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + + PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId())) + .stream() + .findFirst() + .orElseThrow(); + assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + assertThat(savedRequest.getTargetSkillId()).isNull(); + } + private PromotionGraph createPromotionGraph() { + return createPromotionGraph(SUBMITTER_ID); + } + + private PromotionGraph createPromotionGraph(String submitterId) { String suffix = UUID.randomUUID().toString().substring(0, 8); - userAccountRepository.saveAndFlush( - new UserAccount(SUBMITTER_ID, "Promotion Owner", "owner-" + suffix + "@example.com", null) - ); - userAccountRepository.saveAndFlush( - new UserAccount(REVIEWER_ID, "Admin", "admin-" + suffix + "@example.com", null) - ); + saveUserIfAbsent(submitterId, "Promotion Owner", "owner-" + suffix + "@example.com"); + saveUserIfAbsent(REVIEWER_ID, "Admin", "admin-" + suffix + "@example.com"); Namespace globalNamespace = new Namespace("global-" + suffix, "Global " + suffix, REVIEWER_ID); globalNamespace.setType(NamespaceType.GLOBAL); globalNamespace = namespaceRepository.saveAndFlush(globalNamespace); - Namespace teamNamespace = new Namespace("team-" + suffix, "Team " + suffix, SUBMITTER_ID); + Namespace teamNamespace = new Namespace("team-" + suffix, "Team " + suffix, submitterId); teamNamespace = namespaceRepository.saveAndFlush(teamNamespace); - Skill sourceSkill = new Skill(teamNamespace.getId(), "promote-skill-" + suffix, SUBMITTER_ID, SkillVisibility.PUBLIC); + Skill sourceSkill = new Skill(teamNamespace.getId(), "promote-skill-" + suffix, submitterId, SkillVisibility.PUBLIC); sourceSkill.setDisplayName("Promote Skill " + suffix); sourceSkill.setSummary("Used to verify promotion approval flow."); - sourceSkill.setCreatedBy(SUBMITTER_ID); - sourceSkill.setUpdatedBy(SUBMITTER_ID); + sourceSkill.setCreatedBy(submitterId); + sourceSkill.setUpdatedBy(submitterId); sourceSkill = skillRepository.saveAndFlush(sourceSkill); - SkillVersion sourceVersion = new SkillVersion(sourceSkill.getId(), "1.0.0", SUBMITTER_ID); + SkillVersion sourceVersion = new SkillVersion(sourceSkill.getId(), "1.0.0", submitterId); sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); sourceVersion.setPublishedAt(Instant.now()); sourceVersion.setRequestedVisibility(SkillVisibility.PUBLIC); @@ -160,16 +220,22 @@ class PromotionApprovalFlowIntegrationTest { sourceVersion = skillVersionRepository.saveAndFlush(sourceVersion); sourceSkill.setLatestVersionId(sourceVersion.getId()); - sourceSkill.setUpdatedBy(SUBMITTER_ID); + sourceSkill.setUpdatedBy(submitterId); sourceSkill = skillRepository.saveAndFlush(sourceSkill); PromotionRequest request = promotionRequestRepository.saveAndFlush( - new PromotionRequest(sourceSkill.getId(), sourceVersion.getId(), globalNamespace.getId(), SUBMITTER_ID) + new PromotionRequest(sourceSkill.getId(), sourceVersion.getId(), globalNamespace.getId(), submitterId) ); return new PromotionGraph(globalNamespace, sourceSkill, sourceVersion, request); } + private void saveUserIfAbsent(String userId, String displayName, String email) { + if (!userAccountRepository.existsById(userId)) { + userAccountRepository.saveAndFlush(new UserAccount(userId, displayName, email, null)); + } + } + private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) { PlatformPrincipal principal = new PlatformPrincipal( userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..ee943c4f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -144,6 +144,32 @@ class NotificationEventListenerTest { eq("PROMOTION_SUBMITTED"), anyString(), anyString(), eq("PROMOTION"), eq(200L)); } + @Test + void onPromotionApproved_shouldDispatchToSubmitterWhenReviewerIsSubmitter() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onPromotionApproved(new PromotionApprovedEvent(200L, 1L, "self-admin", "self-admin")); + + verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION), + eq("PROMOTION_APPROVED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } + + @Test + void onPromotionRejected_shouldDispatchToSubmitterWhenReviewerIsSubmitter() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onPromotionRejected(new PromotionRejectedEvent(200L, 1L, "self-admin", "self-admin", "not ready")); + + verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION), + eq("PROMOTION_REJECTED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } + @Test void onReportResolved_shouldDispatchToReporter() throws Exception { Skill skill = mockSkill(1L); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java new file mode 100644 index 00000000..abede8fc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -0,0 +1,170 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.PromotionService; +import com.iflytek.skillhub.dto.PromotionResponseDto; +import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import java.lang.reflect.Field; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PromotionPortalAppServiceTest { + + private static final Long PROMOTION_ID = 1L; + private static final String SUPER_ADMIN_ID = "super-admin"; + private static final String REVIEWER_ID = "reviewer"; + private static final String SUBMITTER_ID = "submitter"; + + @Mock + private PromotionService promotionService; + @Mock + private PromotionRequestRepository promotionRequestRepository; + @Mock + private GovernanceQueryRepository governanceQueryRepository; + @Mock + private RbacService rbacService; + @Mock + private AuditLogService auditLogService; + + private PromotionPortalAppService service; + + @BeforeEach + void setUp() { + service = new PromotionPortalAppService( + promotionService, + promotionRequestRepository, + governanceQueryRepository, + rbacService, + auditLogService + ); + } + + @Test + void approvePromotion_recordsSelfReviewAuditDetailForSuperAdminSelfApproval() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID); + when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + when(promotionService.approvePromotion(PROMOTION_ID, SUPER_ADMIN_ID, "ship", Set.of("SUPER_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.approvePromotion( + PROMOTION_ID, + "ship", + SUPER_ADMIN_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(SUPER_ADMIN_ID), + eq("PROMOTION_APPROVE"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"comment\":\"ship\",\"selfReview\":true}") + ); + } + + @Test + void rejectPromotion_recordsSelfReviewAuditDetailWithoutComment() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID); + when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + when(promotionService.rejectPromotion(PROMOTION_ID, SUPER_ADMIN_ID, null, Set.of("SUPER_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.rejectPromotion( + PROMOTION_ID, + null, + SUPER_ADMIN_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(SUPER_ADMIN_ID), + eq("PROMOTION_REJECT"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"selfReview\":true}") + ); + } + + @Test + void approvePromotion_keepsExistingAuditDetailForReviewerApprovingOthersPromotion() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUBMITTER_ID); + when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SKILL_ADMIN")); + when(promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ship", Set.of("SKILL_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.approvePromotion( + PROMOTION_ID, + "ship", + REVIEWER_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(REVIEWER_ID), + eq("PROMOTION_APPROVE"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"comment\":\"ship\"}") + ); + } + + private PromotionResponseDto response(PromotionRequest request) { + return new PromotionResponseDto( + request.getId(), + request.getSourceSkillId(), + "team-a", + "skill-a", + "1.0.0", + "global", + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), + "Submitter", + request.getReviewedBy(), + null, + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() + ); + } + + private PromotionRequest promotionRequest(Long id, String submittedBy) { + PromotionRequest request = new PromotionRequest(10L, 20L, 30L, submittedBy); + setId(request, id); + return request; + } + + private void setId(Object entity, Long id) { + try { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index c3c125b1..df86ec75 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -108,7 +108,7 @@ public class ReviewPermissionChecker { String userId, Set platformRoles) { if (request.getSubmittedBy().equals(userId)) { - return false; + return platformRoles.contains("SUPER_ADMIN"); } return hasPlatformReviewRole(platformRoles); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 19b1887f..1b10f394 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -401,6 +401,51 @@ class PromotionServiceTest { verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); } + + @Test + void shouldNotifySubmitterWhenSuperAdminApprovesOwnPromotion() { + PromotionRequest request = createPendingPromotion(); + PromotionRequest approvedRequest = approvedPromotion(request, "self approve"); + approvedRequest.setReviewedBy(USER_ID); + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + Skill newSkill = new Skill(TARGET_NAMESPACE_ID, "my-skill", USER_ID, SkillVisibility.PUBLIC); + setField(newSkill, "id", NEW_SKILL_ID); + SkillVersion newVersion = new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), USER_ID); + setField(newVersion, "id", NEW_VERSION_ID); + + when(promotionRequestRepository.findById(PROMOTION_ID)) + .thenReturn(Optional.of(request), Optional.of(approvedRequest)); + when(permissionChecker.canReviewPromotion(request, USER_ID, Set.of("SUPER_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, USER_ID, "self approve", null, request.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(skillRepository.save(any(Skill.class))).thenReturn(newSkill); + when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(newVersion); + when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); + when(promotionRequestRepository.save(approvedRequest)).thenReturn(approvedRequest); + + promotionService.approvePromotion(PROMOTION_ID, USER_ID, "self approve", Set.of("SUPER_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any()); + } + + @Test + void shouldNotifySubmitterWhenSuperAdminRejectsOwnPromotion() { + PromotionRequest request = createPendingPromotion(); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, USER_ID, Set.of("SUPER_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.REJECTED, USER_ID, "self reject", null, request.getVersion())) + .thenReturn(1); + + promotionService.rejectPromotion(PROMOTION_ID, USER_ID, "self reject", Set.of("SUPER_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); + } } @Nested diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 25ae2f70..faf94008 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -234,10 +234,26 @@ class ReviewPermissionCheckerTest { } @Test - void cannotReviewOwnPromotion() { + void skillAdminCannotReviewOwnPromotion() { String userId = "user-2"; PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); assertFalse(checker.canReviewPromotion(req, userId, Set.of("SKILL_ADMIN"))); } + + @Test + void regularUserCannotReviewOwnPromotion() { + String userId = "user-2"; + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); + assertFalse(checker.canReviewPromotion(req, userId, + Set.of())); + } + + @Test + void superAdminCanReviewOwnPromotion() { + String userId = "user-2"; + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); + assertTrue(checker.canReviewPromotion(req, userId, + Set.of("SUPER_ADMIN"))); + } } From 85274097426c9a859092849a00e4e2e7e03d6598 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 11:57:49 +0800 Subject: [PATCH 41/78] fix(notification): keep SSE live push streams open (#524) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 3 +- .../portal/NotificationController.java | 3 +- .../skillhub/filter/RequestLoggingFilter.java | 8 +++ .../filter/RequestLoggingFilterTest.java | 23 +++++++++ .../sse/SseEmitterManagerTest.java | 51 ++++++++++++++++++- web/src/api/generated/schema.d.ts | 4 +- 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml index af5ce4fd..86fe6c4c 100644 --- a/docs/api/notification.openapi.yaml +++ b/docs/api/notification.openapi.yaml @@ -80,12 +80,13 @@ paths: /notifications/sse: get: summary: Open notification SSE stream for the current user + description: Keeps a long-lived `text/event-stream` connection open. The server first sends a `connected` event, later sends `notification` events with NotificationSsePayload bodies, and may send heartbeat comments to keep the connection alive. operationId: streamNotifications security: - sessionAuth: [] responses: "200": - description: SSE stream. Each `notification` event carries NotificationSsePayload. + description: Long-lived SSE stream. Each `notification` event carries NotificationSsePayload. content: text/event-stream: schema: diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index 7ab2f759..ec62ebb9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -16,6 +16,7 @@ import jakarta.validation.constraints.Min; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -78,7 +79,7 @@ public class NotificationController extends BaseApiController { return ok("response.success.deleted", null); } - @GetMapping("/sse") + @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter sse(@RequestAttribute("userId") String userId) { return sseEmitterManager.register(userId); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index cda766ff..0f83faee 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -30,6 +30,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final Set SKIP_PREFIXES = Set.of( "/actuator", "/favicon.ico", "/assets/" ); + private static final Set SKIP_SUFFIXES = Set.of( + "/sse" + ); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -89,6 +92,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter { return true; } } + for (String suffix : SKIP_SUFFIXES) { + if (uri.endsWith(suffix)) { + return true; + } + } return false; } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index c79708a3..495eeb63 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -78,6 +78,29 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("/actuator/health")); } + @Test + void doFilterInternal_skipsSseEndpointsWithoutWrappingResponse() + throws ServletException, IOException { + RequestLoggingFilter filter = new RequestLoggingFilter(); + attachAppender(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain filterChain = (req, res) -> { + assertThat(res).isSameAs(response); + res.setContentType("text/event-stream"); + res.getWriter().write("event:connected\n"); + res.getWriter().flush(); + }; + + filter.doFilter(request, response, filterChain); + + assertThat(response.getHeader("Content-Length")).isNull(); + assertThat(response.getContentAsString()).isEqualTo("event:connected\n"); + assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/notifications/sse")); + } + @Test void doFilterInternal_logsCoreSummaryFields() throws ServletException, IOException { diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java index 82c502c5..43646817 100644 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java @@ -7,10 +7,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.IOException; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; class SseEmitterManagerTest { @@ -30,13 +34,19 @@ class SseEmitterManagerTest { @Test void register_shouldReturnEmitter() { - emitters.add(new TestEmitter()); + TestEmitter testEmitter = new TestEmitter(); + emitters.add(testEmitter); SseEmitter emitter = manager.register("user-1"); assertNotNull(emitter); assertEquals(1, manager.totalEmitters()); assertEquals(1, manager.emittersForUser("user-1")); + assertEquals(1, testEmitter.sentEventCount()); + assertTrue(testEmitter.sentEventData(0).stream() + .anyMatch(value -> value.toString().contains("event:connected"))); + assertTrue(testEmitter.sentEventData(0).contains("ok")); + assertTrue(testEmitter.isOpen()); } @Test @@ -93,6 +103,27 @@ class SseEmitterManagerTest { assertEquals(1, manager.emittersForUser("user-1")); } + @Test + void push_shouldSendNotificationEventToRegisteredOpenEmitter() { + TestEmitter emitter = new TestEmitter(); + emitters.add(emitter); + manager.register("user-1"); + + Map payload = Map.of( + "id", 42L, + "eventType", "PROFILE_REVIEW_SUBMITTED" + ); + manager.push("user-1", payload); + + assertEquals(2, emitter.sentEventCount()); + assertTrue(emitter.sentEventData(1).stream() + .anyMatch(value -> value.toString().contains("event:notification"))); + assertTrue(emitter.sentEventData(1).contains(payload)); + assertTrue(emitter.isOpen()); + assertEquals(1, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + } + @Test void heartbeat_shouldRemoveEmitterWhenSendFails() { TestEmitter healthy = new TestEmitter(); @@ -152,6 +183,8 @@ class SseEmitterManagerTest { private boolean failAfterConnected; private boolean throwOnComplete; private int sendCount; + private boolean completed; + private final List> sentEvents = new ArrayList<>(); private TestEmitter() { super(60_000L); @@ -173,6 +206,18 @@ class SseEmitterManagerTest { errorCallback.accept(new IOException("boom-" + userId + "-" + errorCallbacks.incrementAndGet())); } + boolean isOpen() { + return !completed; + } + + int sentEventCount() { + return sentEvents.size(); + } + + List sentEventData(int index) { + return sentEvents.get(index); + } + @Override public synchronized void onCompletion(Runnable callback) { this.completionCallback = callback; @@ -193,6 +238,7 @@ class SseEmitterManagerTest { if (throwOnComplete) { throw new IllegalStateException("already complete"); } + completed = true; completionCallback.run(); } @@ -202,6 +248,9 @@ class SseEmitterManagerTest { if (failAfterConnected && sendCount > 1) { throw new IOException("send failed"); } + sentEvents.add(builder.build().stream() + .map(ResponseBodyEmitter.DataWithMediaType::getData) + .toList()); } } } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 07d8c65b..a141fb20 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9717,7 +9717,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; @@ -9737,7 +9737,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; From 73767a9022ce73b235520ebf2a83b582afb7cfda Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 12:18:02 +0800 Subject: [PATCH 42/78] fix(notification): avoid publish notice for promotion reviewers Signed-off-by: dongmucat <1127093059@qq.com> --- .../listener/NotificationEventListener.java | 3 +- .../NotificationEventListenerTest.java | 38 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index 74ad10d8..c0d280fa 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -19,6 +19,7 @@ import org.springframework.transaction.event.TransactionalEventListener; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; @Component public class NotificationEventListener { @@ -53,7 +54,7 @@ public class NotificationEventListener { @TransactionalEventListener public void onSkillPublished(SkillPublishedEvent event) { skillRepository.findById(event.skillId()).ifPresent(skill -> { - if (!event.publisherId().equals(skill.getCreatedBy())) { + if (!Objects.equals(event.publisherId(), skill.getOwnerId())) { return; } String title = "Skill published: " + skillDisplayName(skill); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..bcdc8953 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.notification.domain.NotificationCategory; import com.iflytek.skillhub.notification.service.NotificationDispatcher; @@ -43,6 +44,24 @@ class NotificationEventListenerTest { return skill; } + private Skill skill(Long id, String ownerId, String createdBy) { + Skill skill = new Skill(5L, "test-skill", ownerId, SkillVisibility.PUBLIC); + skill.setCreatedBy(createdBy); + skill.setDisplayName("Test Skill"); + setId(skill, id); + return skill; + } + + private void setId(Skill skill, Long id) { + try { + java.lang.reflect.Field field = Skill.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(skill, id); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + private void mockNamespace() { Namespace namespace = mock(Namespace.class); when(namespace.getSlug()).thenReturn("demo"); @@ -51,8 +70,7 @@ class NotificationEventListenerTest { @Test void onSkillPublished_shouldDispatchToPublisher() throws Exception { - Skill skill = mockSkill(1L); - when(skill.getCreatedBy()).thenReturn("publisher-1"); + Skill skill = skill(1L, "publisher-1", "publisher-1"); when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); mockNamespace(); when(objectMapper.writeValueAsString(any())).thenReturn("{}"); @@ -64,9 +82,19 @@ class NotificationEventListenerTest { } @Test - void onSkillPublished_shouldSkipWhenPublisherIsNotSkillCreator() throws Exception { - Skill skill = mock(Skill.class); - when(skill.getCreatedBy()).thenReturn("submitter-1"); + void onSkillPublished_shouldSkipWhenPublisherIsNotSkillOwner() throws Exception { + Skill skill = skill(1L, "submitter-1", "submitter-1"); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + + listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); + + verifyNoInteractions(dispatcher); + } + + @Test + void onSkillPublished_shouldSkipPromotedSkillCopyCreatedByReviewer() throws Exception { + Skill skill = skill(1L, "submitter-1", "reviewer-1"); + skill.setSourceSkillId(99L); when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); From 7d0402e93783dc9ccd2efb7dba726dda981296ee Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 10:17:50 +0800 Subject: [PATCH 43/78] fix(security): address review blockers Signed-off-by: dongmucat <1127093059@qq.com> --- .env.release.example | 1 + .github/workflows/pr-scripts.yml | 5 + cli/src/platform/archive.ts | 8 +- cli/test/unit/platform/archive.test.ts | 11 +++ scripts/runtime.sh | 38 +++++++ scripts/tests/runtime-secret-test.sh | 99 +++++++++++++++++++ scripts/tests/validate-release-config-test.sh | 13 +++ scripts/tests/workflow-security-test.sh | 9 ++ scripts/validate-release-config.sh | 24 +++++ 9 files changed, 206 insertions(+), 2 deletions(-) create mode 100755 scripts/tests/runtime-secret-test.sh diff --git a/.env.release.example b/.env.release.example index 40e51ac1..2d30c7bc 100644 --- a/.env.release.example +++ b/.env.release.example @@ -105,6 +105,7 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub SKILLHUB_SECURITY_SCANNER_ENABLED=true # Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +# runtime.sh generates and persists one automatically when this placeholder is still present. SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes # Scanner LLM configuration (optional, for AI-powered scanning features) diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index 2f8cc80d..d15ee4bd 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -8,6 +8,9 @@ on: - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' +permissions: + contents: read + jobs: scripts-tests: name: Script Regression Tests @@ -16,10 +19,12 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: '21' - run: bash scripts/tests/publish-cli-test.sh + - run: bash scripts/tests/runtime-secret-test.sh - run: bash scripts/tests/validate-release-config-test.sh - run: bash scripts/tests/dev-web-host-test.sh - run: bash scripts/tests/workflow-security-test.sh diff --git a/cli/src/platform/archive.ts b/cli/src/platform/archive.ts index c1306711..f549185d 100644 --- a/cli/src/platform/archive.ts +++ b/cli/src/platform/archive.ts @@ -19,8 +19,12 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis const archive = new Uint8Array(buffer) validateZipCentralDirectory(archive) const files = unzipSync(archive) - for (const [name, data] of Object.entries(files)) { - const filePath = safeJoin(targetDir, name) + const entries = Object.entries(files).map(([name, data]) => ({ + name, + data, + filePath: safeJoin(targetDir, name), + })) + for (const { name, data, filePath } of entries) { if (name.endsWith('/')) { await mkdir(filePath, { recursive: true }) continue diff --git a/cli/test/unit/platform/archive.test.ts b/cli/test/unit/platform/archive.test.ts index 98972134..b175e279 100644 --- a/cli/test/unit/platform/archive.test.ts +++ b/cli/test/unit/platform/archive.test.ts @@ -32,6 +32,17 @@ describe('archive helpers', () => { await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path') }) + test('rejects unsafe zip before writing earlier safe entries', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-partial-')) + const unsafe = zipSync({ + 'SKILL.md': new TextEncoder().encode('# Partial'), + '../escape.txt': new TextEncoder().encode('bad'), + }) + + await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path') + await expect(readFile(join(target, 'SKILL.md'), 'utf-8')).rejects.toThrow() + }) + test('rejects zip entries with absolute paths', async () => { const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-abs-')) const unsafe = zipSync({ '/etc/passwd': new TextEncoder().encode('bad') }) diff --git a/scripts/runtime.sh b/scripts/runtime.sh index f5b89550..f46e7c3e 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -180,6 +180,42 @@ get_env_value() { fi } +generate_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return 0 + fi + + if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then + dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' + return 0 + fi + + echo "Unable to generate SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET. Install openssl or configure it manually." >&2 + exit 1 +} + +is_placeholder_secret() { + case "$1" in + ""|change-me-in-production|replace-me|replace-with-random-download-secret-32-bytes|TODO*|todo*|replace*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_anonymous_download_secret() { + secret="$(get_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "")" + if ! is_placeholder_secret "$secret" && [ "${#secret}" -ge 32 ]; then + return 0 + fi + + set_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$(generate_secret)" + echo "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET in $ENV_FILE" +} + wait_for_postgres_ready() { postgres_user="$1" postgres_db="$2" @@ -280,6 +316,8 @@ prepare_runtime_files() { if [ -n "$SKILLHUB_PUBLIC_BASE_URL_VALUE" ]; then set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE" fi + + ensure_anonymous_download_secret } run_compose() { diff --git a/scripts/tests/runtime-secret-test.sh b/scripts/tests/runtime-secret-test.sh new file mode 100755 index 00000000..34dd4fab --- /dev/null +++ b/scripts/tests/runtime-secret-test.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/runtime.sh" + +TMP_DIRS=() +cleanup() { + local d + for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do + rm -rf "$d" + done +} +trap cleanup EXIT + +new_tmp() { + local d + d="$(mktemp -d)" + TMP_DIRS+=("$d") + echo "$d" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +install_fake_tools() { + local bin_dir="$1" + mkdir -p "$bin_dir" + cat >"$bin_dir/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${DOCKER_LOG:?DOCKER_LOG is required}" +if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then + exit 0 +fi +exit 0 +EOF + chmod +x "$bin_dir/docker" + + cat >"$bin_dir/openssl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "rand" && "${2:-}" == "-hex" && "${3:-}" == "32" ]]; then + printf '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n' + exit 0 +fi +echo "unsupported openssl args: $*" >&2 +exit 1 +EOF + chmod +x "$bin_dir/openssl" +} + +run_runtime() { + local home="$1" + local bin_dir="$2" + local stdout="$3" + DOCKER_LOG="$home/docker.log" \ + SKILLHUB_HOME="$home" \ + SKILLHUB_RAW_BASE="file://$REPO_ROOT" \ + PATH="$bin_dir:$PATH" \ + sh "$SCRIPT" up --version sha-test --public-url http://localhost >"$stdout" +} + +tmp="$(new_tmp)" +bin_dir="$tmp/bin" +install_fake_tools "$bin_dir" + +home_generated="$tmp/generated" +stdout_generated="$tmp/generated.out" +mkdir -p "$home_generated" +run_runtime "$home_generated" "$bin_dir" "$stdout_generated" + +generated_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_generated/.env.release" | cut -d= -f2-)" +[[ "$generated_secret" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]] \ + || fail "runtime should generate a persisted anonymous download secret" +grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \ + || fail "runtime should explain that it generated the secret" +if grep -Fq "$generated_secret" "$stdout_generated"; then + fail "runtime must not print the generated secret value" +fi + +home_preserved="$tmp/preserved" +stdout_preserved="$tmp/preserved.out" +mkdir -p "$home_preserved" +cat >"$home_preserved/.env.release" <<'EOF' +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=already-valid-runtime-secret-32-bytes +EOF +run_runtime "$home_preserved" "$bin_dir" "$stdout_preserved" + +preserved_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_preserved/.env.release" | cut -d= -f2-)" +[[ "$preserved_secret" == "already-valid-runtime-secret-32-bytes" ]] \ + || fail "runtime must preserve an existing valid anonymous download secret" +if grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_preserved"; then + fail "runtime must not regenerate an existing valid secret" +fi + +echo "runtime-secret-test passed" diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index 3a52d7a3..fd76ddfc 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -80,4 +80,17 @@ short_env="$tmp/short.env" write_env "$short_env" "too-short" expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters" +draft_env="$tmp/draft.env" +while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=*) + printf '%s\n' "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum" + ;; + *) + printf '%s\n' "$line" + ;; + esac +done <"$REPO_ROOT/.env.release.draft" >"$draft_env" +expect_fail "$draft_env" "POSTGRES_PASSWORD" + echo "validate-release-config-test passed" diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index 8219af51..94cb2556 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -12,6 +12,13 @@ fail() { [[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required" +grep -Eq '^permissions:[[:space:]]*$' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must declare top-level permissions" +grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts GITHUB_TOKEN permissions must be read-only" +grep -Fq 'persist-credentials: false' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts checkout must not persist credentials" + grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \ || fail "security workflow must run dependency review" grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \ @@ -25,6 +32,8 @@ grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when security workflow changes" grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run validate-release-config-test" +grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run runtime-secret-test" grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run dev-web-host-test" grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \ diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 758b168f..03e42bd8 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -52,6 +52,23 @@ reject_values() { done } +reject_patterns() { + var_name="$1" + shift + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + for pattern in "$@"; do + case "$var_value" in + $pattern) + error "$var_name still uses placeholder/default pattern: $var_value" + return 0 + ;; + esac + done +} + validate_url() { var_name="$1" eval "var_value=\${$var_name:-}" @@ -115,15 +132,22 @@ validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes" +reject_patterns SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "TODO_*" "todo_*" "replace*" validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32 reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev" +reject_patterns POSTGRES_PASSWORD "TODO_*" "todo_*" reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026" +reject_patterns BOOTSTRAP_ADMIN_PASSWORD "TODO_*" "todo_*" "replace*" if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then require_non_empty BOOTSTRAP_ADMIN_PASSWORD fi reject_values SKILLHUB_STORAGE_S3_ACCESS_KEY "replace-me" reject_values SKILLHUB_STORAGE_S3_SECRET_KEY "replace-me" +reject_patterns SKILLHUB_STORAGE_S3_ACCESS_KEY "TODO_*" "todo_*" "replace*" +reject_patterns SKILLHUB_STORAGE_S3_SECRET_KEY "TODO_*" "todo_*" "replace*" +reject_patterns SPRING_MAIL_USERNAME "TODO_*" "todo_*" "replace*" +reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*" validate_boolean SESSION_COOKIE_SECURE validate_boolean BOOTSTRAP_ADMIN_ENABLED From b44c65443d761bff454dcec2ab29a6cf4c8563e6 Mon Sep 17 00:00:00 2001 From: SenLinLeo <1664761477@qq.com> Date: Tue, 16 Jun 2026 16:25:45 +0800 Subject: [PATCH 44/78] fix(cli): treat highlighted install target as selected Signed-off-by: SenLinLeo <1664761477@qq.com> --- cli/src/agents/resolver.ts | 9 ++++- .../unit/agents/resolver-interactive.test.ts | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 cli/test/unit/agents/resolver-interactive.test.ts diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 3d983727..2290ee09 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -160,6 +160,7 @@ function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] { async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise { const prompts = await import('prompts') + let highlightedIndex = 0 const { selected } = await prompts.default({ type: 'multiselect', name: 'selected', @@ -167,7 +168,13 @@ async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise choices: candidates.map(c => ({ title: `${c.agent} (${c.rootDir})`, value: c - })) + })), + onRender: function (this: { cursor?: number }) { + highlightedIndex = this.cursor ?? highlightedIndex + }, + format: (selectedTargets: AgentCandidate[]) => ( + selectedTargets.length > 0 ? selectedTargets : [candidates[highlightedIndex] ?? candidates[0]!] + ) }) if (!selected || selected.length === 0) { throw new CliError('installation cancelled', EXIT.usage) diff --git a/cli/test/unit/agents/resolver-interactive.test.ts b/cli/test/unit/agents/resolver-interactive.test.ts new file mode 100644 index 00000000..55bc99ac --- /dev/null +++ b/cli/test/unit/agents/resolver-interactive.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { AgentCandidate } from '../../../src/agents/types' + +interface PromptOptions { + onRender?: (this: { cursor?: number }) => void + format?: (selectedTargets: AgentCandidate[]) => AgentCandidate[] +} + +mock.module('prompts', () => ({ + default: (options: PromptOptions) => { + options.onRender?.call({ cursor: 1 }) + return { selected: options.format?.([]) ?? [] } + } +})) + +const { resolveInstallTargets } = await import('../../../src/agents/resolver') + +describe('resolveInstallTargets interactive prompt', () => { + test('uses the highlighted target when Enter submits an empty multiselect', async () => { + const detected: AgentCandidate[] = [ + { agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }, + { agent: 'claude-code', rootDir: '/repo/.claude/skills', scope: 'project', source: 'detected' } + ] + const highlighted = detected[1]! + + const targets = await resolveInstallTargets({ + cwd: '/repo', + agents: [], + json: false, + interactive: true, + detected + }) + + expect(targets).toEqual([highlighted]) + }) +}) From e50140272b6f18fca41865f8f80672c2ab3b502d Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 17:48:15 +0800 Subject: [PATCH 45/78] fix(security): close review hardening gaps Signed-off-by: dongmucat <1127093059@qq.com> --- .github/workflows/pr-cli.yml | 5 ++ .github/workflows/pr-e2e.yml | 2 + .github/workflows/pr-scripts.yml | 6 ++ .github/workflows/pr-tests.yml | 6 ++ .github/workflows/security.yml | 4 + cli/src/services/install-service.ts | 83 ++++++++++++------- .../unit/services/install-service.test.ts | 40 +++++++++ scripts/runtime.sh | 62 ++++++++++++-- scripts/tests/runtime-secret-test.sh | 26 +++++- scripts/tests/workflow-security-test.sh | 33 ++++++-- .../skill/service/SkillDownloadService.java | 36 ++++++-- .../service/SkillDownloadServiceTest.java | 28 +++++++ 12 files changed, 280 insertions(+), 51 deletions(-) diff --git a/.github/workflows/pr-cli.yml b/.github/workflows/pr-cli.yml index 7de2a31e..65c0ca6a 100644 --- a/.github/workflows/pr-cli.yml +++ b/.github/workflows/pr-cli.yml @@ -7,6 +7,9 @@ on: - 'Makefile' - '.github/workflows/pr-cli.yml' +permissions: + contents: read + jobs: cli: strategy: @@ -16,6 +19,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.13 diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml index f25d8072..f94ffdc2 100644 --- a/.github/workflows/pr-e2e.yml +++ b/.github/workflows/pr-e2e.yml @@ -34,6 +34,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index d15ee4bd..1e31dccf 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -4,7 +4,13 @@ on: pull_request: paths: - 'scripts/**' + - '.env.release.example' + - '.env.release.draft' + - 'compose.release.yml' - 'Makefile' + - '.github/workflows/pr-cli.yml' + - '.github/workflows/pr-e2e.yml' + - '.github/workflows/pr-tests.yml' - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index da4d7622..4e957ec3 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -25,6 +25,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up pnpm uses: pnpm/action-setup@v4 @@ -52,6 +54,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Java uses: actions/setup-java@v4 @@ -74,6 +78,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Detect docs changes id: changed diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9b476ca9..3329a267 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Review dependency changes uses: actions/dependency-review-action@v4 @@ -54,6 +56,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Java if: matrix.language == 'java-kotlin' diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index ed09ffd6..4293ff6e 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { SkillHubClient } from '../clients/skillhub-client' import { InventoryStore } from '../stores/inventory-store' @@ -39,35 +39,62 @@ export async function installSkill(options: InstallOptions): Promise<{ installed }) } - if (await pathExists(skillDir) && options.force) { - await store.removeTargetsByInstallDir(skillDir) - await rm(skillDir, { recursive: true, force: true }) + await mkdir(target.rootDir, { recursive: true }) + const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`)) + let movedIntoPlace = false + + try { + await extractZip(buffer, tempDir) + + const installedAt = new Date().toISOString() + const metaDir = join(tempDir, '.skillhub') + await mkdir(metaDir, { recursive: true }) + await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({ + registry: options.registry, + namespace: options.namespace, + slug: options.slug, + version: resolved.version, + agent: target.agent, + installedAt + }, null, 2)) + + if (await pathExists(skillDir) && !options.force) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } + + if (await pathExists(skillDir) && options.force) { + await store.removeTargetsByInstallDir(skillDir) + await rm(skillDir, { recursive: true, force: true }) + } + + try { + await rename(tempDir, skillDir) + } catch (error) { + if (!options.force && await pathExists(skillDir)) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } + throw error + } + movedIntoPlace = true + + await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, { + agent: target.agent, + rootDir: target.rootDir, + installDir: skillDir, + installedAt + }) + } finally { + if (!movedIntoPlace) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } } - // Create skill directory and extract into a clean skill-specific directory. - await mkdir(skillDir, { recursive: true }) - await extractZip(buffer, skillDir) - - // Write .skillhub/metadata.json - const metaDir = join(skillDir, '.skillhub') - await mkdir(metaDir, { recursive: true }) - await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({ - registry: options.registry, - namespace: options.namespace, - slug: options.slug, - version: resolved.version, - agent: target.agent, - installedAt: new Date().toISOString() - }, null, 2)) - - // Update inventory - await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, { - agent: target.agent, - rootDir: target.rootDir, - installDir: skillDir, - installedAt: new Date().toISOString() - }) - installed.push({ agent: target.agent, dir: skillDir }) } diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index 784f4394..fd08e1b6 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -132,6 +132,46 @@ describe('installSkill', () => { expect(inventory.items[0].targets[0].installDir).toBe(skillDir) }) + test('force keeps old installation and inventory when replacement extraction fails', async () => { + globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 })) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + const skillDir = join(rootDir, 'demo') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Old') + const inventoryPath = join(home, '.skillhub', 'inventory.json') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(inventoryPath, JSON.stringify({ + items: [{ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + version: '0.1.0', + targets: [{ + agent: 'codex', + rootDir, + installDir: skillDir, + installedAt: '2026-04-20T00:00:00.000Z' + }] + }] + }, null, 2)) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home + })).rejects.toThrow('invalid zip central directory') + + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old') + const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) + expect(inventory.items).toHaveLength(1) + expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' }) + expect(inventory.items[0].targets[0].installDir).toBe(skillDir) + }) + test('rejects downloads whose content-length exceeds the package limit', async () => { globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), { status: 200, diff --git a/scripts/runtime.sh b/scripts/runtime.sh index f46e7c3e..fec6375e 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -159,13 +159,34 @@ set_env_value() { fi tmp="$ENV_FILE.tmp" - if grep -q "^$key=" "$ENV_FILE"; then - sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp" - else - cat "$ENV_FILE" >"$tmp" - printf '%s=%s\n' "$key" "$value" >>"$tmp" - fi + found=false + old_umask="$(umask)" + umask 077 + { + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "$key="*) + printf '%s=%s\n' "$key" "$value" + found=true + ;; + *) + printf '%s\n' "$line" + ;; + esac + done <"$ENV_FILE" + if [ "$found" = "false" ]; then + printf '%s=%s\n' "$key" "$value" + fi + } >"$tmp" + umask "$old_umask" mv "$tmp" "$ENV_FILE" + secure_env_file +} + +secure_env_file() { + if [ -f "$ENV_FILE" ]; then + chmod 600 "$ENV_FILE" + fi } get_env_value() { @@ -235,6 +256,23 @@ wait_for_postgres_ready() { exit 1 } +wait_for_redis_ready() { + attempt=1 + + while [ "$attempt" -le 60 ]; do + if run_compose exec -T redis redis-cli ping >/dev/null 2>&1; then + return 0 + fi + + attempt=$((attempt + 1)) + sleep 2 + done + + echo "Redis did not become ready in time." >&2 + run_compose logs redis >&2 || true + exit 1 +} + ensure_postgres_password_matches_env() { postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")" postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")" @@ -267,8 +305,12 @@ prepare_runtime_files() { download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE" if [ ! -f "$ENV_FILE" ]; then + old_umask="$(umask)" + umask 077 cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" + umask "$old_umask" fi + secure_env_file if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}" @@ -317,6 +359,10 @@ prepare_runtime_files() { set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE" fi + if [ "$DISABLE_SCANNER" = "true" ]; then + set_env_value "SKILLHUB_SECURITY_SCANNER_ENABLED" "false" + fi + ensure_anonymous_download_secret } @@ -333,7 +379,9 @@ case "$COMMAND" in run_compose up -d postgres ensure_postgres_password_matches_env if [ "$DISABLE_SCANNER" = "true" ]; then - SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0 + run_compose up -d redis + wait_for_redis_ready + SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --no-deps --scale skill-scanner=0 server web else run_compose up -d fi diff --git a/scripts/tests/runtime-secret-test.sh b/scripts/tests/runtime-secret-test.sh index 34dd4fab..506b58fd 100755 --- a/scripts/tests/runtime-secret-test.sh +++ b/scripts/tests/runtime-secret-test.sh @@ -56,11 +56,21 @@ run_runtime() { local home="$1" local bin_dir="$2" local stdout="$3" + shift 3 DOCKER_LOG="$home/docker.log" \ SKILLHUB_HOME="$home" \ SKILLHUB_RAW_BASE="file://$REPO_ROOT" \ PATH="$bin_dir:$PATH" \ - sh "$SCRIPT" up --version sha-test --public-url http://localhost >"$stdout" + sh "$SCRIPT" up --version sha-test --public-url http://localhost "$@" >"$stdout" +} + +file_mode() { + local file="$1" + if stat -c %a "$file" >/dev/null 2>&1; then + stat -c %a "$file" + else + stat -f %Lp "$file" + fi } tmp="$(new_tmp)" @@ -75,6 +85,8 @@ run_runtime "$home_generated" "$bin_dir" "$stdout_generated" generated_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_generated/.env.release" | cut -d= -f2-)" [[ "$generated_secret" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]] \ || fail "runtime should generate a persisted anonymous download secret" +[[ "$(file_mode "$home_generated/.env.release")" == "600" ]] \ + || fail "runtime env file must be readable only by the owner" grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \ || fail "runtime should explain that it generated the secret" if grep -Fq "$generated_secret" "$stdout_generated"; then @@ -92,8 +104,20 @@ run_runtime "$home_preserved" "$bin_dir" "$stdout_preserved" preserved_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_preserved/.env.release" | cut -d= -f2-)" [[ "$preserved_secret" == "already-valid-runtime-secret-32-bytes" ]] \ || fail "runtime must preserve an existing valid anonymous download secret" +[[ "$(file_mode "$home_preserved/.env.release")" == "600" ]] \ + || fail "runtime env file must remain owner-readable only when an existing secret is preserved" if grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_preserved"; then fail "runtime must not regenerate an existing valid secret" fi +home_no_scanner="$tmp/no-scanner" +stdout_no_scanner="$tmp/no-scanner.out" +mkdir -p "$home_no_scanner" +run_runtime "$home_no_scanner" "$bin_dir" "$stdout_no_scanner" --no-scanner + +grep -Fq "SKILLHUB_SECURITY_SCANNER_ENABLED=false" "$home_no_scanner/.env.release" \ + || fail "runtime should persist scanner disabled state for --no-scanner" +grep -Fq -- "up -d --no-deps --scale skill-scanner=0 server web" "$home_no_scanner/docker.log" \ + || fail "runtime --no-scanner should start server/web without waiting on scanner dependencies" + echo "runtime-secret-test passed" diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index 94cb2556..2d6deb43 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -10,14 +10,23 @@ fail() { exit 1 } +assert_pr_workflow_hardened() { + local workflow="$1" + grep -Eq '^permissions:[[:space:]]*$' "$workflow" \ + || fail "$workflow must declare top-level permissions" + grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$workflow" \ + || fail "$workflow GITHUB_TOKEN permissions must include contents: read" + grep -Fq 'persist-credentials: false' "$workflow" \ + || fail "$workflow checkout steps must not persist credentials" +} + [[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required" -grep -Eq '^permissions:[[:space:]]*$' "$PR_SCRIPTS_WORKFLOW" \ - || fail "pr-scripts must declare top-level permissions" -grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$PR_SCRIPTS_WORKFLOW" \ - || fail "pr-scripts GITHUB_TOKEN permissions must be read-only" -grep -Fq 'persist-credentials: false' "$PR_SCRIPTS_WORKFLOW" \ - || fail "pr-scripts checkout must not persist credentials" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-cli.yml" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-e2e.yml" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-tests.yml" +assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW" +assert_pr_workflow_hardened "$SECURITY_WORKFLOW" grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \ || fail "security workflow must run dependency review" @@ -30,6 +39,18 @@ grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \ grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when security workflow changes" +grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR CLI workflow changes" +grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR E2E workflow changes" +grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR Tests workflow changes" +grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release env example changes" +grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release env draft changes" +grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release compose changes" grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run validate-release-config-test" grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \ diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index b53a6c63..56b69ddd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -102,7 +102,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -123,7 +123,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -150,7 +150,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findById(tag.getVersionId()) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.version.notFound", tagName)); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -161,9 +161,12 @@ public class SkillDownloadService { return buildDownloadResult(skill, version); } - private DownloadResult downloadVersion(Skill skill, SkillVersion version) { + private DownloadResult downloadVersion(Skill skill, + SkillVersion version, + String currentUserId, + Map userNsRoles) { assertPublishedAccessible(skill); - assertDownloadableVersion(skill, version); + assertDownloadableVersion(skill, version, currentUserId, userNsRoles); DownloadResult result = buildDownloadResult(skill, version); // Only increment download count for PUBLISHED versions @@ -304,18 +307,33 @@ public class SkillDownloadService { /** * Asserts that the version can be downloaded. * - PUBLISHED: anyone with skill access can download - * - UPLOADED/PENDING_REVIEW: only skill owner can download + * - UPLOADED/PENDING_REVIEW: only skill owner or namespace admin can download */ - private void assertDownloadableVersion(Skill skill, SkillVersion version) { + private void assertDownloadableVersion(Skill skill, + SkillVersion version, + String currentUserId, + Map userNsRoles) { switch (version.getStatus()) { case PUBLISHED -> { // Anyone with skill access can download published versions } case UPLOADED, PENDING_REVIEW -> { - // Only owner can download UPLOADED/PENDING_REVIEW versions - // Note: This check is already done in assertCanDownload via visibilityChecker + if (!canManageSkillDraft(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } } default -> throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); } } + + private boolean canManageSkillDraft(Skill skill, String currentUserId, Map userNsRoles) { + if (currentUserId == null) { + return false; + } + if (skill.getOwnerId().equals(currentUserId)) { + return true; + } + NamespaceRole role = userNsRoles == null ? null : userNsRoles.get(skill.getNamespaceId()); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 4bc20d34..ce84509d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -351,6 +352,33 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_RejectsAnonymousPendingReviewPublicSkill() throws Exception { + Namespace namespace = new Namespace("global", "Global", "system"); + setId(namespace, 1L); + namespace.setType(NamespaceType.GLOBAL); + + Skill skill = new Skill(1L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.1.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.1.0")).thenReturn(Optional.of(version)); + + assertThrows(DomainForbiddenException.class, () -> + service.downloadVersion("global", "demo-skill", "1.1.0", null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); From 52251fcd0edd76f9372e14481458e1e61e0c20dd Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 10:25:34 +0800 Subject: [PATCH 46/78] chore(docs): remove handwritten notification OpenAPI (#524) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 248 ----------------------------- 1 file changed, 248 deletions(-) delete mode 100644 docs/api/notification.openapi.yaml diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml deleted file mode 100644 index 86fe6c4c..00000000 --- a/docs/api/notification.openapi.yaml +++ /dev/null @@ -1,248 +0,0 @@ -openapi: 3.0.3 -info: - title: SkillHub Notification API - version: 1.0.0 - description: In-app notification list, unread count, and SSE event contract. -servers: - - url: /api/web - - url: /api/v1 -paths: - /notifications: - get: - summary: List notifications for the current user - operationId: listNotifications - security: - - sessionAuth: [] - parameters: - - name: category - in: query - required: false - description: Optional category filter. Profile review notifications reuse REVIEW. - schema: - type: string - enum: [PUBLISH, REVIEW, PROMOTION, REPORT] - example: REVIEW - - name: page - in: query - required: false - schema: - type: integer - minimum: 0 - default: 0 - example: 0 - - name: size - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - example: 20 - responses: - "200": - description: Notification page. - content: - application/json: - schema: - $ref: "#/components/schemas/NotificationPageResponse" - "400": - description: Invalid category. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - "401": - description: Authentication required. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /notifications/unread-count: - get: - summary: Get unread notification count for the current user - operationId: getNotificationUnreadCount - security: - - sessionAuth: [] - responses: - "200": - description: Unread count. - content: - application/json: - schema: - $ref: "#/components/schemas/UnreadCountResponse" - "401": - description: Authentication required. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /notifications/sse: - get: - summary: Open notification SSE stream for the current user - description: Keeps a long-lived `text/event-stream` connection open. The server first sends a `connected` event, later sends `notification` events with NotificationSsePayload bodies, and may send heartbeat comments to keep the connection alive. - operationId: streamNotifications - security: - - sessionAuth: [] - responses: - "200": - description: Long-lived SSE stream. Each `notification` event carries NotificationSsePayload. - content: - text/event-stream: - schema: - type: string - "401": - description: Authentication required. -components: - securitySchemes: - sessionAuth: - type: apiKey - in: cookie - name: SESSION - schemas: - NotificationPageResponse: - type: object - properties: - code: - type: integer - example: 0 - msg: - type: string - example: ok - data: - type: object - properties: - items: - type: array - items: - $ref: "#/components/schemas/Notification" - total: - type: integer - format: int64 - example: 1 - page: - type: integer - example: 0 - size: - type: integer - example: 20 - timestamp: - type: string - format: date-time - requestId: - type: string - Notification: - type: object - properties: - id: - type: integer - format: int64 - example: 1001 - category: - type: string - enum: [PUBLISH, REVIEW, PROMOTION, REPORT] - example: REVIEW - eventType: - type: string - description: Known values include REVIEW_SUBMITTED, REVIEW_APPROVED, REVIEW_REJECTED, PROFILE_REVIEW_SUBMITTED, PROMOTION_SUBMITTED, PROMOTION_APPROVED, PROMOTION_REJECTED, REPORT_SUBMITTED, REPORT_RESOLVED, SKILL_PUBLISHED, SUBSCRIPTION_NEW_VERSION, and SUBSCRIPTION_VERSION_YANKED. - example: PROFILE_REVIEW_SUBMITTED - title: - type: string - example: Profile review submitted - bodyJson: - type: string - description: JSON string. For PROFILE_REVIEW_SUBMITTED it contains profileReviewId, submitterId, and fields. - example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' - entityType: - type: string - example: PROFILE_REVIEW - entityId: - type: integer - format: int64 - example: 77 - status: - type: string - enum: [UNREAD, READ] - example: UNREAD - createdAt: - type: string - format: date-time - readAt: - type: string - format: date-time - nullable: true - targetType: - type: string - example: PROFILE_REVIEW - targetId: - type: integer - format: int64 - example: 77 - targetRoute: - type: string - description: Profile review notifications route to the admin profile review queue. - example: /dashboard/reviews?type=profile - NotificationSsePayload: - type: object - properties: - id: - type: integer - format: int64 - example: 1001 - category: - type: string - example: REVIEW - eventType: - type: string - example: PROFILE_REVIEW_SUBMITTED - title: - type: string - example: Profile review submitted - bodyJson: - type: string - example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' - entityType: - type: string - example: PROFILE_REVIEW - entityId: - type: integer - format: int64 - example: 77 - createdAt: - type: string - format: date-time - UnreadCountResponse: - type: object - properties: - code: - type: integer - example: 0 - msg: - type: string - example: ok - data: - type: object - properties: - count: - type: integer - format: int64 - example: 1 - timestamp: - type: string - format: date-time - requestId: - type: string - ErrorResponse: - type: object - properties: - code: - type: integer - example: 400 - msg: - type: string - example: Invalid category - timestamp: - type: string - format: date-time - requestId: - type: string From b56973fb80a21448ba2de8a76b93b004c3951fe2 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 11:50:23 +0800 Subject: [PATCH 47/78] fix(auth): fail closed invalid cli bearer tokens Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 1 + .../cli/CliSkillControllerTest.java | 112 +++++++++++++++++ .../token/ApiTokenAuthenticationFilter.java | 112 ++++++++++++----- .../ApiTokenAuthenticationFilterTest.java | 118 +++++++++++++++++- .../auth/token/ApiTokenServiceTest.java | 40 ++++++ 5 files changed, 353 insertions(+), 30 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index db7c8b22..1c62b1c6 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -377,6 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成 - 存储:只存 SHA-256 哈希,明文只展示一次 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 +- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` > **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index b2421c89..fa40e0fa 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -1,15 +1,27 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.cli.CliSkillAppService; import jakarta.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -19,11 +31,17 @@ import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -35,7 +53,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @ActiveProfiles("test") class CliSkillControllerTest { @Autowired MockMvc mockMvc; + @Autowired NamespaceMemberRepository namespaceMemberRepository; @MockBean CliSkillAppService cliSkillAppService; + @MockBean ApiTokenService apiTokenService; + @MockBean UserAccountRepository userAccountRepository; + @MockBean UserRoleBindingRepository userRoleBindingRepository; @Test void downloadRoutesUseDownloadRateLimit() throws Exception { @@ -73,6 +95,47 @@ class CliSkillControllerTest { .andExpect(jsonPath("$.data.items[0].latestVersion").value("1.2.0")); } + @Test + void searchRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.search("pdf", 20, null, null)).willReturn( + new CliSkillAppService.CliSearchResult(List.of(), 0, 20) + ); + + mockMvc.perform(get("/api/cli/v1/skills/search") + .param("q", "pdf") + .param("limit", "20") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(cliSkillAppService); + } + + @Test + void searchWithValidBearerProjectsIdentityAndNamespaceRoles() throws Exception { + ApiToken token = new ApiToken("user-cli-token", "cli", "sk_test", "hash", "[]"); + UserAccount user = new UserAccount("user-cli-token", "CLI User", "cli@example.com", ""); + Map nsRoles = Map.of(9L, NamespaceRole.MEMBER); + + given(apiTokenService.validateToken("raw-token")).willReturn(Optional.of(token)); + given(userAccountRepository.findById("user-cli-token")).willReturn(Optional.of(user)); + given(userRoleBindingRepository.findByUserId("user-cli-token")).willReturn(List.of()); + namespaceMemberRepository.save(new NamespaceMember(9L, "user-cli-token", NamespaceRole.MEMBER)); + given(cliSkillAppService.search("private", 20, "user-cli-token", nsRoles)).willReturn( + new CliSkillAppService.CliSearchResult(List.of(), 0, 20) + ); + + mockMvc.perform(get("/api/cli/v1/skills/search") + .param("q", "private") + .param("limit", "20") + .header(HttpHeaders.AUTHORIZATION, "Bearer raw-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + + verify(cliSkillAppService).search("private", 20, "user-cli-token", nsRoles); + verify(apiTokenService).touchLastUsed(token); + } + @Test void resolveReturnsCliResolveResponse() throws Exception { given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn( @@ -91,6 +154,47 @@ class CliSkillControllerTest { .andExpect(jsonPath("$.data.fingerprint").value("abc123")); } + @Test + void resolveRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn( + new com.iflytek.skillhub.dto.cli.CliResolveResponse( + "global", "demo", "2.0.0", 42L, "abc123", + "/api/v1/skills/global/demo/versions/2.0.0/download" + ) + ); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).resolve(any(), any(), any(), any(), any()); + } + + @Test + void downloadLatestRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.downloadLatest(any(), any(), any())).willReturn(downloadResponse()); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/download") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).downloadLatest(any(), any(), any()); + } + + @Test + void downloadVersionRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.downloadVersion(any(), any(), any(), any())).willReturn(downloadResponse()); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).downloadVersion(any(), any(), any(), any()); + } + @Test void deleteRequiresAuthentication() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders @@ -132,4 +236,12 @@ class CliSkillControllerTest { assertEquals(120, rateLimit.authenticated()); assertEquals(30, rateLimit.anonymous()); } + + private static ResponseEntity downloadResponse() { + return ResponseEntity.ok(new InputStreamResource(new ByteArrayInputStream("zip".getBytes()))); + } + + private void givenInvalidBearerToken() { + given(apiTokenService.validateToken("unknown-token")).willReturn(Optional.empty()); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 6f594c1e..8b24aa86 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -10,9 +10,12 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -37,49 +40,74 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; private final ApiTokenScopeService apiTokenScopeService; + private final AuthenticationEntryPoint authenticationEntryPoint; + @Autowired public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, UserAccountRepository userRepo, UserRoleBindingRepository roleBindingRepo, - ApiTokenScopeService apiTokenScopeService) { + ApiTokenScopeService apiTokenScopeService, + AuthenticationEntryPoint authenticationEntryPoint) { this.apiTokenService = apiTokenService; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; this.apiTokenScopeService = apiTokenScopeService; + this.authenticationEntryPoint = authenticationEntryPoint; + } + + ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo, + ApiTokenScopeService apiTokenScopeService) { + this(apiTokenService, userRepo, roleBindingRepo, apiTokenScopeService, + (request, response, authException) -> + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage())); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader(AUTH_HEADER); - if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { - String rawToken = authHeader.substring(BEARER_PREFIX.length()); - apiTokenService.validateToken(rawToken).ifPresent(token -> { - userRepo.findById(token.getUserId()).ifPresent(user -> { - if (!user.isActive()) { - return; - } - Set roles = roleBindingRepo.findByUserId(user.getId()).stream() - .map(rb -> rb.getRole().getCode()) - .collect(Collectors.toSet()); - roles = PlatformRoleDefaults.withDefaultUserRole(roles); - Set scopes = apiTokenScopeService.parseScopes(token.getScopeJson()); - PlatformPrincipal principal = new PlatformPrincipal( - user.getId(), user.getDisplayName(), user.getEmail(), - user.getAvatarUrl(), "api_token", roles - ); - List authorities = new ArrayList<>(); - authorities.addAll(roles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .toList()); - authorities.addAll(scopes.stream() - .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) - .toList()); - var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); - apiTokenService.touchLastUsed(token); - }); - }); + if (authHeader != null && isBearerAuthorization(authHeader)) { + String rawToken = extractBearerToken(authHeader); + if (rawToken == null) { + rejectBearer(request, response); + return; + } + + var token = apiTokenService.validateToken(rawToken); + if (token.isEmpty()) { + rejectBearer(request, response); + return; + } + + ApiToken apiToken = token.get(); + var user = userRepo.findById(apiToken.getUserId()); + if (user.isEmpty() || !user.get().isActive()) { + rejectBearer(request, response); + return; + } + + UserAccount userAccount = user.get(); + Set roles = roleBindingRepo.findByUserId(userAccount.getId()).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + Set scopes = apiTokenScopeService.parseScopes(apiToken.getScopeJson()); + PlatformPrincipal principal = new PlatformPrincipal( + userAccount.getId(), userAccount.getDisplayName(), userAccount.getEmail(), + userAccount.getAvatarUrl(), "api_token", roles + ); + List authorities = new ArrayList<>(); + authorities.addAll(roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList()); + authorities.addAll(scopes.stream() + .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) + .toList()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + apiTokenService.touchLastUsed(apiToken); } filterChain.doFilter(request, response); } @@ -91,4 +119,30 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { || path.startsWith("/api/web/") || path.startsWith("/api/cli/")); } + + private boolean isBearerAuthorization(String authHeader) { + if (!authHeader.regionMatches(true, 0, "Bearer", 0, "Bearer".length())) { + return false; + } + return authHeader.length() == "Bearer".length() + || Character.isWhitespace(authHeader.charAt("Bearer".length())); + } + + private String extractBearerToken(String authHeader) { + if (authHeader.length() <= BEARER_PREFIX.length() - 1 + || authHeader.charAt(BEARER_PREFIX.length() - 1) != ' ') { + return null; + } + String rawToken = authHeader.substring(BEARER_PREFIX.length()).trim(); + return rawToken.isEmpty() ? null : rawToken; + } + + private void rejectBearer(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + SecurityContextHolder.clearContext(); + authenticationEntryPoint.commence( + request, + response, + new BadCredentialsException("Invalid bearer token") + ); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index e82f7a1a..d9f030a9 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -17,11 +17,13 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.context.SecurityContextHolder; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -89,12 +91,117 @@ class ApiTokenAuthenticationFilterTest { request.setRequestURI("/api/v1/publish"); request.addHeader("Authorization", "Bearer raw-token"); - filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(chain.getRequest()); verify(apiTokenService, never()).touchLastUsed(token); } + @Test + void shouldRejectUnknownBearerTokenOnCliReadRoutes() throws Exception { + when(apiTokenService.validateToken("unknown-token")).thenReturn(Optional.empty()); + + for (String route : cliReadRoutes()) { + SecurityContextHolder.clearContext(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", route); + request.addHeader("Authorization", "Bearer unknown-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), route); + assertNull(SecurityContextHolder.getContext().getAuthentication(), route); + assertNull(chain.getRequest(), route); + } + } + + @Test + void shouldRejectBearerTokenWhenUserIsMissing() throws Exception { + ApiToken token = new ApiToken("missing-user", "cli", "sk_test", "hash", "[]"); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("missing-user")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer raw-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).touchLastUsed(token); + } + + @Test + void shouldRejectEmptyBearerTokenWithoutValidatingIt() throws Exception { + when(apiTokenService.validateToken("")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer "); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldRejectMalformedBearerHeaderWithoutValidatingIt() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldAllowAnonymousCliReadsWhenAuthorizationHeaderIsAbsent() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_OK, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNotNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldIgnoreNonBearerAuthorizationHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Basic abc123"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_OK, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNotNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + @Test void shouldAuthenticateBearerTokensForApiWebRequests() throws Exception { ApiToken token = new ApiToken("user-3", "cli", "sk_test", "hash", "[\"skill:publish\"]"); @@ -113,4 +220,13 @@ class ApiTokenAuthenticationFilterTest { assertNotNull(SecurityContextHolder.getContext().getAuthentication()); verify(apiTokenService).touchLastUsed(token); } + + private static List cliReadRoutes() { + return Stream.of( + "/api/cli/v1/skills/search", + "/api/cli/v1/skills/global/demo/resolve", + "/api/cli/v1/skills/global/demo/download", + "/api/cli/v1/skills/global/demo/versions/1.0.0/download" + ).toList(); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java index 2e4de008..d9ed2c75 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -10,9 +10,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @@ -124,4 +129,39 @@ class ApiTokenServiceTest { .isInstanceOf(DomainBadRequestException.class) .hasMessageContaining("error.token.name.duplicate"); } + + @Test + void validateToken_returnsEmptyForUnknownToken() { + when(tokenRepo.findByTokenHash(sha256("missing-token"))).thenReturn(Optional.empty()); + + assertThat(service.validateToken("missing-token")).isEmpty(); + } + + @Test + void validateToken_returnsEmptyForExpiredToken() { + ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("expired-token"), "[]"); + token.setExpiresAt(Instant.parse("2026-03-17T23:59:59Z")); + when(tokenRepo.findByTokenHash(sha256("expired-token"))).thenReturn(Optional.of(token)); + + assertThat(service.validateToken("expired-token")).isEmpty(); + } + + @Test + void validateToken_returnsEmptyForRevokedToken() { + ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("revoked-token"), "[]"); + token.setRevokedAt(Instant.parse("2026-03-17T23:59:59Z")); + when(tokenRepo.findByTokenHash(sha256("revoked-token"))).thenReturn(Optional.of(token)); + + assertThat(service.validateToken("revoked-token")).isEmpty(); + } + + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } } From 9520cf63e074d27909ce3f257eadd1d897213a48 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 14:01:21 +0800 Subject: [PATCH 48/78] fix(cli): add token auth to search Signed-off-by: dongmucat <1127093059@qq.com> --- cli/README.md | 5 +- cli/src/commands/help.ts | 4 +- cli/src/index.ts | 3 +- cli/test/integration/install-command.test.ts | 59 +++++++++++ cli/test/integration/search-command.test.ts | 103 +++++++++++++++++++ 5 files changed, 170 insertions(+), 4 deletions(-) diff --git a/cli/README.md b/cli/README.md index 57a0e495..6d98df80 100644 --- a/cli/README.md +++ b/cli/README.md @@ -112,6 +112,9 @@ Logout only removes the token for the specified registry, preserving registry co # Keyword search skillhub search pdf +# Search with a one-off token +skillhub search pdf --token sk_xxx + # List all skills (empty query) skillhub search "" --limit 50 @@ -333,7 +336,7 @@ Update mechanism: | `skillhub login --token [--registry ] [--json]` | Save token and registry configuration | | `skillhub logout [--registry ] [--json]` | Remove token for specified registry | | `skillhub whoami [--registry ] [--token ] [--json]` | Validate current token and display user information | -| `skillhub search [--registry ] [--limit ] [--json]` | Search published skills | +| `skillhub search [--registry ] [--token ] [--limit ] [--json]` | Search published skills | | `skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--registry ] [--token ] [--json]` | Install a skill | | `skillhub list [--agent ] [--dir ] [--registry ] [--json]` | List installed skills | | `skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--registry ] [--token ] [--json]` | Remove a skill | diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index 083d72aa..9b35f3b4 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -28,8 +28,8 @@ export const commands = { }, search: { summary: 'Search published skills', - usage: 'skillhub search [query] [--limit ] [--registry ] [--json]', - examples: ['skillhub search', 'skillhub search pdf'] + usage: 'skillhub search [query] [--limit ] [--registry ] [--token ] [--json]', + examples: ['skillhub search', 'skillhub search pdf', 'skillhub search pdf --token sk_xxx'] }, install: { summary: 'Install a skill locally', diff --git a/cli/src/index.ts b/cli/src/index.ts index 15a7eb84..512b5b1b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -223,9 +223,10 @@ cli cli .command('search [query]', 'Search published skills') .option('--registry ', 'Registry URL') + .option('--token ', 'API token') .option('--limit ', 'Max results', { default: 20 }) .option('--json', 'Output JSON') - .action((query: string | undefined, options: { registry?: string; limit?: number; json?: boolean }) => { + .action((query: string | undefined, options: { registry?: string; token?: string; limit?: number; json?: boolean }) => { return runCommand(() => searchCommand(query ?? '', options), Boolean(options.json)) }) diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index 134672f6..a4ca3bd5 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -218,6 +218,65 @@ describe('install command — P1', () => { expect(result.stderr.toLowerCase()).toMatch(/auth|unauthorized|401/) }) + test('bad token stops on 401 without retrying resolve anonymously', async () => { + const env = await createTempHome() + const installDir = join(env.cwd, 'skills-no-anon-retry') + await mkdir(installDir, { recursive: true }) + + const resolveAuthHeaders: Array = [] + let downloadRequests = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/) + if (resolveMatch) { + const auth = req.headers.get('authorization') + resolveAuthHeaders.push(auth) + if (auth === 'Bearer sk_bad') { + return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) + } + return Response.json({ + code: 0, + data: { + namespace: resolveMatch[1], + slug: resolveMatch[2], + version: '1.0.0', + versionId: 1, + fingerprint: 'abc123', + downloadUrl: `${url.protocol}//${url.host}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/download` + } + }) + } + if (url.pathname.endsWith('/download')) { + downloadRequests += 1 + return new Response(makeSkillZip() as BodyInit, { + status: 200, + headers: { 'Content-Type': 'application/zip' } + }) + } + return Response.json({ code: 404 }, { status: 404 }) + } + }) + + try { + const registryUrl = `http://localhost:${server.port}` + const result = await runCli( + ['install', 'pdf-parser', '--dir', installDir, '--registry', registryUrl, '--token', 'sk_bad'], + { HOME: env.home, USERPROFILE: env.home } + ) + + expect(result.exitCode).toBe(2) + expect(result.stderr).toContain('Error: authentication failed') + expect(result.stderr).toContain(`Context: registry ${registryUrl}`) + expect(result.stderr).toContain('Next:') + expect(resolveAuthHeaders).toEqual(['Bearer sk_bad']) + expect(downloadRequests).toBe(0) + } finally { + server.stop() + } + }) + // ------------------------------------------------------------------------- // P1 — --namespace override // ------------------------------------------------------------------------- diff --git a/cli/test/integration/search-command.test.ts b/cli/test/integration/search-command.test.ts index 1902142c..f352b158 100644 --- a/cli/test/integration/search-command.test.ts +++ b/cli/test/integration/search-command.test.ts @@ -10,6 +10,109 @@ afterEach(() => { }) describe('search command', () => { + test('--token sends bearer auth and takes priority over SKILLHUB_TOKEN', async () => { + let capturedAuth = '' + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (url.pathname === '/api/cli/v1/skills/search') { + capturedAuth = req.headers.get('authorization') ?? '' + return Response.json({ + code: 0, + data: { + items: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }], + total: 1, + limit: 20 + } + }) + } + return Response.json({ code: 404 }, { status: 404 }) + } + }) + + try { + const result = await runCli( + ['search', 'pdf', '--registry', `http://localhost:${server.port}`, '--token', 'sk_ok'], + { SKILLHUB_TOKEN: 'sk_bad' } + ) + + expect(result.exitCode).toBe(0) + expect(capturedAuth).toBe('Bearer sk_ok') + expect(result.stdout).toContain('global/pdf-parser') + } finally { + server.stop() + } + }) + + test('bad --token fails with auth output and does not retry anonymously', async () => { + const authHeaders: Array = [] + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (url.pathname === '/api/cli/v1/skills/search') { + const auth = req.headers.get('authorization') + authHeaders.push(auth) + if (auth === 'Bearer sk_bad') { + return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) + } + return Response.json({ + code: 0, + data: { + items: [{ namespace: 'global', slug: 'anonymous-only', latestVersion: '1.0.0', summary: 'anonymous fallback' }], + total: 1, + limit: 20 + } + }) + } + return Response.json({ code: 404 }, { status: 404 }) + } + }) + + try { + const registryUrl = `http://localhost:${server.port}` + const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad']) + + expect(result.exitCode).toBe(2) + expect(result.stderr).toContain('Error: authentication failed') + expect(result.stderr).toContain(`Context: registry ${registryUrl}`) + expect(result.stderr).toContain('Next:') + expect(authHeaders).toEqual(['Bearer sk_bad']) + } finally { + server.stop() + } + }) + + test('bad --token returns structured json auth error', async () => { + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (url.pathname === '/api/cli/v1/skills/search') { + return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) + } + return Response.json({ code: 404 }, { status: 404 }) + } + }) + + try { + const registryUrl = `http://localhost:${server.port}` + const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad', '--json']) + + expect(result.exitCode).toBe(2) + const parsed = JSON.parse(result.stderr) + expect(parsed.ok).toBe(false) + expect(parsed.message).toBe('authentication failed') + expect(parsed.exitCode).toBe(2) + expect(parsed.details.registry).toBe(registryUrl) + expect(typeof parsed.details.next).toBe('string') + expect(parsed.details.next).toContain('skillhub login') + } finally { + server.stop() + } + }) + test('prints compact search table', async () => { registry = await startFakeRegistry({ searchItems: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }] From 32cc316b30b46899787b8f94c7a5659101e8bfed Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 14:43:23 +0800 Subject: [PATCH 49/78] fix(cli): align anonymous installability rules Signed-off-by: dongmucat <1127093059@qq.com> --- .../service/cli/CliSkillAppService.java | 5 +- .../service/SkillSearchAppServiceTest.java | 32 ++++++++ .../service/cli/CliSkillAppServiceTest.java | 31 ++++++++ .../domain/skill/SkillInstallability.java | 18 +++++ .../skill/service/SkillDownloadService.java | 6 +- .../SkillLifecycleProjectionService.java | 7 +- .../skill/service/SkillQueryService.java | 15 ++-- .../service/SkillDownloadServiceTest.java | 73 +++++++++++++++++++ .../skill/service/SkillQueryServiceTest.java | 44 +++++++++++ 9 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index e431eaf3..a4030ebf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -56,15 +56,16 @@ public class CliSkillAppService { ); List items = response.items().stream() + .filter(item -> item.publishedVersion() != null) .map(item -> new CliSearchItem( item.namespace(), item.slug(), - item.publishedVersion() != null ? item.publishedVersion().version() : null, + item.publishedVersion().version(), item.summary() )) .toList(); - return new CliSearchResult(items, response.total(), limit); + return new CliSearchResult(items, items.size(), limit); } public CliResolveResponse resolve(String namespace, String slug, String version, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index fdac46f8..984be1d0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -8,7 +8,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.search.SearchQuery; @@ -184,6 +186,36 @@ class SkillSearchAppServiceTest { .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); } + @Test + void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { + Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion version = new SkillVersion(10L, "1.0.0", "owner-1"); + setField(version, "id", 101L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version)); + when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(version)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("not-ready", response.items().getFirst().slug()); + assertEquals(null, response.items().getFirst().publishedVersion()); + } + @Test void search_shouldNormalizeAndPassLabelSlugs() { when(searchQueryService.search(any())) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index b7fbe1d7..b2c230fe 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -76,6 +76,37 @@ class CliSkillAppServiceTest { assertEquals(20, result.limit()); } + @Test + void search_filtersResultsWithoutInstallablePublishedVersion() { + var searchResponse = new SkillSearchAppService.SearchResponse( + List.of( + new SkillSummaryResponse( + 1L, "draft-only", "Draft Only", "No installable version", + "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, + "global", Instant.now(), false, + null, null, null, "NONE" + ), + new SkillSummaryResponse( + 2L, "ready", "Ready", "Installable", + "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, + "global", Instant.now(), false, + new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), + new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), + null, "PUBLISHED" + ) + ), + 2L, 0, 20 + ); + given(skillSearchAppService.search("demo", null, "newest", 0, 20, null, null)) + .willReturn(searchResponse); + + var result = service.search("demo", 20, null, null); + + assertEquals(1, result.items().size()); + assertEquals("ready", result.items().getFirst().slug()); + assertEquals(1L, result.total()); + } + @Test void resolve_delegatesToQueryService() { given(skillQueryService.resolveVersion("global", "demo", "2.0.0", null, null, "user-1", Map.of())) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java new file mode 100644 index 00000000..dfeeed1c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.domain.skill; + +/** + * Defines whether a skill version can be installed through public download + * paths. Storage object presence is checked later by the download service so + * fallback bundle behavior stays separate from domain publication state. + */ +public final class SkillInstallability { + private SkillInstallability() { + } + + public static boolean isInstallableVersion(SkillVersion version) { + return version != null + && version.getStatus() == SkillVersionStatus.PUBLISHED + && version.isDownloadReady() + && version.getYankedAt() == null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 56b69ddd..db14fe2e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -306,7 +306,7 @@ public class SkillDownloadService { /** * Asserts that the version can be downloaded. - * - PUBLISHED: anyone with skill access can download + * - PUBLISHED: must be installable before public download * - UPLOADED/PENDING_REVIEW: only skill owner or namespace admin can download */ private void assertDownloadableVersion(Skill skill, @@ -315,7 +315,9 @@ public class SkillDownloadService { Map userNsRoles) { switch (version.getStatus()) { case PUBLISHED -> { - // Anyone with skill access can download published versions + if (!SkillInstallability.isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); + } } case UPLOADED, PENDING_REVIEW -> { if (!canManageSkillDraft(skill, currentUserId, userNsRoles)) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 368ae850..1ad3fd35 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillInstallability; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; @@ -91,7 +92,7 @@ public class SkillLifecycleProjectionService { List unresolvedSkillIds = new java.util.ArrayList<>(); for (Skill skill : skills) { SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId()); - if (latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED) { + if (SkillInstallability.isInstallableVersion(latestVersion)) { publishedBySkillId.put(skill.getId(), latestVersion); } else { unresolvedSkillIds.add(skill.getId()); @@ -100,7 +101,9 @@ public class SkillLifecycleProjectionService { if (!unresolvedSkillIds.isEmpty()) { for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) { - publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); + if (SkillInstallability.isInstallableVersion(version)) { + publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 7d966327..66c5e319 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -487,13 +487,7 @@ public class SkillQueryService { } public boolean isDownloadAvailable(SkillVersion version) { - if (version == null) { - return false; - } - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - return false; - } - return version.isDownloadReady(); + return SkillInstallability.isInstallableVersion(version); } public ReviewSkillSnapshotDTO getReviewSkillSnapshot(Long skillVersionId) { @@ -565,6 +559,7 @@ public class SkillQueryService { Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); + assertInstallableVersion(resolved, resolved.getVersion()); String fingerprint = computeFingerprint(resolved); Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); @@ -916,6 +911,12 @@ public class SkillQueryService { } } + private void assertInstallableVersion(SkillVersion version, String versionStr) { + if (!SkillInstallability.isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", versionStr); + } + } + /** * Checks whether the caller may preview a specific version's files and metadata. * Published versions are visible to everyone; all other statuses are restricted diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index ce84509d..2b7ea78b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -92,6 +92,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -137,6 +138,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -180,6 +182,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -232,6 +235,73 @@ class SkillDownloadServiceTest { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_ShouldRejectDownloadUnavailablePublishedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{versionStr}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadVersion_ShouldRejectYankedPublishedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + 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)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{versionStr}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + @Test void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throws Exception { String namespaceSlug = "test-ns"; @@ -249,6 +319,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); SkillFile file = new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -297,6 +368,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); @@ -332,6 +404,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 7ee683ac..032bc683 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -27,6 +27,7 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.UncheckedIOException; import java.lang.reflect.Field; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -439,6 +440,17 @@ class SkillQueryServiceTest { assertTrue(service.isDownloadAvailable(version)); } + @Test + void testIsDownloadAvailable_ShouldReturnFalseWhenVersionIsYanked() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + assertFalse(service.isDownloadAvailable(version)); + } + @Test void testIsDownloadAvailable_ShouldNotHitObjectStorageForListSignals() throws Exception { SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); @@ -565,9 +577,11 @@ class SkillQueryServiceTest { SkillVersion version100 = new SkillVersion(1L, "1.0.0", "user-100"); setId(version100, 9L); version100.setStatus(SkillVersionStatus.PUBLISHED); + version100.setDownloadReady(true); SkillVersion version110 = new SkillVersion(1L, "1.1.0", "user-100"); setId(version110, 10L); version110.setStatus(SkillVersionStatus.PUBLISHED); + version110.setDownloadReady(true); SkillFile version100File = new SkillFile(9L, "SKILL.md", 10L, "text/markdown", "hash100", "key100"); SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110"); @@ -611,6 +625,7 @@ class SkillQueryServiceTest { SkillVersion version = new SkillVersion(3L, "1.0.0 beta", "user-100"); setId(version, 11L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -632,6 +647,35 @@ class SkillQueryServiceTest { assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl()); } + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns"; From f5259daa947a73c4ee8105b9d46366ab52f7db5f Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 15:12:13 +0800 Subject: [PATCH 50/78] fix(cli): require installable latest in search Signed-off-by: dongmucat <1127093059@qq.com> --- .../service/SkillSearchAppServiceTest.java | 97 +++++++++-- .../skill/service/SkillDownloadService.java | 9 + .../SkillLifecycleProjectionService.java | 15 -- .../service/SkillDownloadServiceTest.java | 131 +++++++++++++++ .../skill/service/SkillQueryServiceTest.java | 154 ++++++++++++++++++ .../PostgresFullTextQueryServiceTest.java | 37 +++++ 6 files changed, 416 insertions(+), 27 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 984be1d0..3cd408e4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -24,11 +24,13 @@ import org.mockito.Mock; import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -98,8 +100,6 @@ class SkillSearchAppServiceTest { when(skillRepository.findByIdIn(List.of(11L))).thenReturn(List.of(visibleSkill)); when(namespaceRepository.findByIdIn(List.of(2L))).thenReturn(List.of(activeNamespace)); when(skillVersionRepository.findByIdIn(List.of(111L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null); @@ -147,8 +147,6 @@ class SkillSearchAppServiceTest { when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(visibleSkill)); when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 20, "user-9", Map.of()); @@ -166,6 +164,9 @@ class SkillSearchAppServiceTest { setField(second, "id", 11L); second.setLatestVersionId(102L); + SkillVersion firstVersion = publishedVersion(10L, 101L, "1.0.0"); + SkillVersion secondVersion = publishedVersion(11L, 102L, "2.0.0"); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); setField(namespace, "id", 1L); namespace.setStatus(NamespaceStatus.ACTIVE); @@ -174,20 +175,80 @@ class SkillSearchAppServiceTest { .thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20)); when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(first, second)); when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); - when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); + when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of(firstVersion, secondVersion)); SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); assertEquals(2, response.items().size()); + assertEquals("1.0.0", response.items().get(0).publishedVersion().version()); + assertEquals("2.0.0", response.items().get(1).publishedVersion().version()); verify(skillVersionRepository, times(1)).findByIdIn(List.of(101L, 102L)); - verify(skillVersionRepository, times(1)) + verify(skillVersionRepository, times(0)) .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); } @Test - void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsMissing() { + Skill skill = new Skill(1L, "missing-latest", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("missing-latest", response.items().getFirst().slug()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); + } + + @Test + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsYanked() { + Skill skill = new Skill(1L, "yanked-latest", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion latest = publishedVersion(10L, 101L, "1.0.0"); + latest.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(latest)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("yanked-latest", response.items().getFirst().slug()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); + } + + @Test + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestDownloadUnavailable() { Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC); setField(skill, "id", 10L); skill.setLatestVersionId(101L); @@ -196,6 +257,7 @@ class SkillSearchAppServiceTest { setField(version, "id", 101L); version.setStatus(SkillVersionStatus.PUBLISHED); version.setDownloadReady(false); + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); Namespace namespace = new Namespace("global", "Global", "owner-1"); setField(namespace, "id", 1L); @@ -206,14 +268,17 @@ class SkillSearchAppServiceTest { when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version)); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of(version)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); assertEquals(1, response.items().size()); assertEquals("not-ready", response.items().getFirst().slug()); - assertEquals(null, response.items().getFirst().publishedVersion()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); } @Test @@ -272,4 +337,12 @@ class SkillSearchAppServiceTest { throw new RuntimeException(e); } } + + private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) { + SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1"); + setField(version, "id", versionId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + return version; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index db14fe2e..294563ad 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -284,12 +285,20 @@ public class SkillDownloadService { if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); + } } private boolean isAnonymousDownloadAllowed(Skill skill) { return skill.getVisibility() == SkillVisibility.PUBLIC; } + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { + return currentUserId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); + } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { return skillSlugResolutionService.resolve( namespaceId, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 1ad3fd35..31ff9a6b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -89,21 +89,10 @@ public class SkillLifecycleProjectionService { .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); Map publishedBySkillId = new java.util.HashMap<>(); - List unresolvedSkillIds = new java.util.ArrayList<>(); for (Skill skill : skills) { SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId()); if (SkillInstallability.isInstallableVersion(latestVersion)) { publishedBySkillId.put(skill.getId(), latestVersion); - } else { - unresolvedSkillIds.add(skill.getId()); - } - } - - if (!unresolvedSkillIds.isEmpty()) { - for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) { - if (SkillInstallability.isInstallableVersion(version)) { - publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); - } } } @@ -160,10 +149,6 @@ public class SkillLifecycleProjectionService { .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())); } - private SkillVersion newerVersion(SkillVersion left, SkillVersion right) { - return versionComparator().compare(left, right) >= 0 ? left : right; - } - private VersionProjection toProjection(SkillVersion version) { if (version == null) { return null; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 2b7ea78b..89a2e4cb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -119,6 +119,137 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadLatest_ShouldRejectSkillWithoutLatest() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "missing-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest(namespaceSlug, skillSlug, null, Map.of())); + + assertEquals("error.skill.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{skillSlug}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectYankedLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "yanked-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest(namespaceSlug, skillSlug, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectAnonymousArchivedNamespaceSkill() throws Exception { + String namespaceSlug = "archived"; + String skillSlug = "archived-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived", "owner-1"); + setId(namespace, 1L); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + org.mockito.Mockito.lenient().when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + org.mockito.Mockito.lenient().when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(true); + org.mockito.Mockito.lenient().when(objectStorageService.getMetadata("packages/1/10/bundle.zip")).thenReturn(metadata); + org.mockito.Mockito.lenient().when(objectStorageService.getObject("packages/1/10/bundle.zip")) + .thenReturn(new ByteArrayInputStream("test".getBytes())); + org.mockito.Mockito.lenient() + .when(objectStorageService.generatePresignedUrl(eq("packages/1/10/bundle.zip"), any(), eq("archived-skill-1.0.0.zip"))) + .thenReturn(null); + + assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () -> + service.downloadLatest(namespaceSlug, skillSlug, null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectAnonymousHiddenPrivateAndUnpublishedSkills() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setId(namespace, 1L); + + Skill hiddenSkill = new Skill(1L, "hidden", "owner-1", SkillVisibility.PUBLIC); + setId(hiddenSkill, 11L); + hiddenSkill.setStatus(SkillStatus.ACTIVE); + hiddenSkill.setLatestVersionId(101L); + hiddenSkill.setHidden(true); + + Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE); + setId(privateSkill, 12L); + privateSkill.setStatus(SkillStatus.ACTIVE); + privateSkill.setLatestVersionId(102L); + + Skill unpublishedSkill = new Skill(1L, "unpublished", "owner-1", SkillVisibility.PUBLIC); + setId(unpublishedSkill, 13L); + unpublishedSkill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "hidden")).thenReturn(List.of(hiddenSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "unpublished")).thenReturn(List.of(unpublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest("global", "hidden", null, Map.of())); + assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () -> + service.downloadLatest("global", "private", null, Map.of())); + assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest("global", "unpublished", null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + @Test void testDownloadByTag_Success() throws Exception { // Arrange diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 032bc683..99cb448b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -676,6 +676,160 @@ class SkillQueryServiceTest { assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); } + @Test + void testResolveVersion_ShouldRejectSkillWithoutLatest() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "missing-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{skillSlug}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectYankedLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "yanked-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableExplicitVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "explicit-not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(3L, "1.0.0")).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, "1.0.0", null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableTaggedVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "tag-not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + SkillTag tag = new SkillTag(3L, "stable", 11L, "owner-1"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillTagRepository.findBySkillIdAndTagName(3L, "stable")).thenReturn(Optional.of(tag)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, "stable", null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectAnonymousHiddenPrivateArchivedAndUnpublishedSkills() throws Exception { + Namespace activeNamespace = new Namespace("global", "Global", "owner-1"); + setId(activeNamespace, 1L); + Namespace archivedNamespace = new Namespace("archived", "Archived", "owner-1"); + setId(archivedNamespace, 2L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + + Skill hiddenSkill = new Skill(1L, "hidden", "owner-1", SkillVisibility.PUBLIC); + setId(hiddenSkill, 10L); + hiddenSkill.setStatus(SkillStatus.ACTIVE); + hiddenSkill.setLatestVersionId(101L); + hiddenSkill.setHidden(true); + + Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE); + setId(privateSkill, 11L); + privateSkill.setStatus(SkillStatus.ACTIVE); + privateSkill.setLatestVersionId(102L); + + Skill archivedSkill = new Skill(2L, "archived", "owner-1", SkillVisibility.PUBLIC); + setId(archivedSkill, 12L); + archivedSkill.setStatus(SkillStatus.ACTIVE); + archivedSkill.setLatestVersionId(103L); + + Skill unpublishedSkill = new Skill(1L, "unpublished", "owner-1", SkillVisibility.PUBLIC); + setId(unpublishedSkill, 13L); + unpublishedSkill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(activeNamespace)); + when(namespaceRepository.findBySlug("archived")).thenReturn(Optional.of(archivedNamespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "hidden")).thenReturn(List.of(hiddenSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill)); + when(skillRepository.findByNamespaceIdAndSlug(2L, "archived")).thenReturn(List.of(archivedSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "unpublished")).thenReturn(List.of(unpublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion("global", "hidden", null, null, null, null, Map.of())); + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("global", "private", null, null, null, null, Map.of())); + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("archived", "archived", null, null, null, null, Map.of())); + assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion("global", "unpublished", null, null, null, null, Map.of())); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index f89d05e9..15804b92 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -363,6 +363,43 @@ class PostgresFullTextQueryServiceTest { .contains("ORDER BY s.updated_at DESC, d.skill_id DESC"); } + @Test + void anonymousSearchSqlShouldOnlyReadPublicActiveVisibleNonArchivedSkills() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + null, + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "newest", + 0, + 12 + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("AND (d.visibility = 'PUBLIC' )") + .contains("AND d.status = 'ACTIVE'") + .contains("AND s.status = 'ACTIVE'") + .contains("AND s.hidden = FALSE") + .contains("AND (n.status <> 'ARCHIVED' )") + .doesNotContain("memberNamespaceIds"); + verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); + verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); + } + @Test void authenticatedQueriesShouldAllowArchivedNamespacesForMembers() { EntityManager entityManager = mock(EntityManager.class); From 5a708a5bd6bcf0c7ceeb7a5b9f16ad045b4a0cf0 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 15 Jun 2026 17:25:35 +0800 Subject: [PATCH 51/78] fix(domain): reject anonymous restricted resolves cleanly Signed-off-by: dongmucat <1127093059@qq.com> --- .../domain/skill/VisibilityChecker.java | 7 +++--- .../skill/service/SkillQueryServiceTest.java | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index 65c8e753..30b52a66 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -16,19 +16,20 @@ public class VisibilityChecker { } public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles, Set platformRoles) { + Map roles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); if (isSuperAdmin(platformRoles)) { return true; } if (skill.isHidden()) { - return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); + return isOwner(skill, currentUserId) || isAdminOrAbove(roles.get(skill.getNamespaceId())); } if (skill.getLatestVersionId() == null) { return isOwner(skill, currentUserId); } return switch (skill.getVisibility()) { case PUBLIC -> true; - case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId()); - case PRIVATE -> isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); + case NAMESPACE_ONLY -> roles.containsKey(skill.getNamespaceId()); + case PRIVATE -> isOwner(skill, currentUserId) || isAdminOrAbove(roles.get(skill.getNamespaceId())); }; } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 99cb448b..02cf02f1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -830,6 +830,31 @@ class SkillQueryServiceTest { service.resolveVersion("global", "unpublished", null, null, null, null, Map.of())); } + @Test + void testResolveVersion_ShouldRejectAnonymousPrivateAndNamespaceOnlyWhenRolesAreMissing() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setId(namespace, 1L); + + Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE); + setId(privateSkill, 11L); + privateSkill.setStatus(SkillStatus.ACTIVE); + privateSkill.setLatestVersionId(101L); + + Skill namespaceOnlySkill = new Skill(1L, "team-only", "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(namespaceOnlySkill, 12L); + namespaceOnlySkill.setStatus(SkillStatus.ACTIVE); + namespaceOnlySkill.setLatestVersionId(102L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "team-only")).thenReturn(List.of(namespaceOnlySkill)); + + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("global", "private", null, null, null, null, null)); + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("global", "team-only", null, null, null, null, null)); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns"; From cb4bf947112e259bb1f124c1dc86bfc889762939 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 11:15:03 +0800 Subject: [PATCH 52/78] fix(cli): filter installable search before pagination Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 8 +- .../service/SkillSearchAppService.java | 21 ++- .../service/cli/CliSkillAppService.java | 5 +- .../service/cli/CliSkillAppServiceTest.java | 144 ++++++++++++++++-- .../iflytek/skillhub/search/SearchQuery.java | 16 +- .../PostgresFullTextQueryService.java | 8 + .../PostgresFullTextQueryServiceTest.java | 45 ++++++ 7 files changed, 225 insertions(+), 22 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 1c62b1c6..7a7e46d6 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -605,8 +605,8 @@ window.location.href = '/oauth2/authorization/github' | `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/admin)` | `SearchVisibilityScope` + 搜索索引状态 | | `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` | | `GET /api/v1/skills/{ns}/{slug}/versions` | 仅 `PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 | -| `GET /api/v1/skills/{ns}/{slug}/download` | 仅全局 namespace 下的 `PUBLIC` skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须是 `PUBLISHED` | visibility + namespace type + version status | -| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅全局 namespace 下的 `PUBLIC` skill 可匿名 | 同上 | visibility + namespace type + version status | +| `GET /api/v1/skills/{ns}/{slug}/download` | 仅 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须可安装 | visibility + namespace status + `SkillInstallability` | +| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 可匿名 | 同上 | visibility + namespace status + `SkillInstallability` | | `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 | ### 10.2 Authenticated API @@ -655,6 +655,6 @@ window.location.href = '/oauth2/authorization/github' |------|---------|---------| | `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | | `GET /api/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` | -| `GET /api/v1/resolve` | 可选(匿名仅限全局 namespace 下的 PUBLIC) | visibility + namespace type + version status | -| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限全局 namespace 下的 PUBLIC) | visibility + namespace type + version status | +| `GET /api/v1/resolve` | 可选(匿名仅限 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装) | visibility + namespace status + `SkillInstallability` | +| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装) | visibility + namespace status + `SkillInstallability` | | `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过(namespace 由 canonical slug 解析) | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index bffa778a..63410678 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -85,7 +85,20 @@ public class SkillSearchAppService { SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); - return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope); + return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false); + } + + public SearchResponse searchInstallableLatest( + String keyword, + String namespaceSlug, + String sortBy, + int page, + int size, + String userId, + Map userNsRoles) { + Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); + SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); + return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), scope, true); } private Long resolveNamespaceId(String namespaceSlug, String userId, Map userNsRoles) { @@ -133,7 +146,8 @@ public class SkillSearchAppService { int page, int size, List labelSlugs, - SearchVisibilityScope scope) { + SearchVisibilityScope scope, + boolean requireInstallableLatest) { SearchResult result = searchQueryService.search(new SearchQuery( keyword, namespaceId, @@ -141,7 +155,8 @@ public class SkillSearchAppService { sortBy, page, size, - normalizeLabelSlugs(labelSlugs) + normalizeLabelSlugs(labelSlugs), + requireInstallableLatest )); List pageItems = mapVisibleSkillSummaries(result.skillIds()); return new SearchResponse(pageItems, result.total(), page, size); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index a4030ebf..1fcd2e25 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -51,12 +51,11 @@ public class CliSkillAppService { public record CliSearchResult(List items, long total, int limit) {} public CliSearchResult search(String q, int limit, String userId, Map userNsRoles) { - SkillSearchAppService.SearchResponse response = skillSearchAppService.search( + SkillSearchAppService.SearchResponse response = skillSearchAppService.searchInstallableLatest( q, null, "newest", 0, limit, userId, userNsRoles ); List items = response.items().stream() - .filter(item -> item.publishedVersion() != null) .map(item -> new CliSearchItem( item.namespace(), item.slug(), @@ -65,7 +64,7 @@ public class CliSkillAppService { )) .toList(); - return new CliSearchResult(items, items.size(), limit); + return new CliSearchResult(items, response.total(), limit); } public CliResolveResponse resolve(String namespace, String slug, String version, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index b2c230fe..0c75ca34 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -1,9 +1,18 @@ package com.iflytek.skillhub.service.cli; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -15,6 +24,9 @@ import com.iflytek.skillhub.dto.cli.CliResolveResponse; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.SkillDeleteAppService; import com.iflytek.skillhub.service.SkillSearchAppService; +import com.iflytek.skillhub.search.SearchQuery; +import com.iflytek.skillhub.search.SearchQueryService; +import com.iflytek.skillhub.search.SearchResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -39,6 +51,11 @@ class CliSkillAppServiceTest { @Mock SkillDownloadService skillDownloadService; @Mock SkillDeleteAppService skillDeleteAppService; @Mock SkillPublishService skillPublishService; + @Mock SkillRepository skillRepository; + @Mock NamespaceRepository namespaceRepository; + @Mock SkillVersionRepository skillVersionRepository; + @Mock NamespaceService namespaceService; + @Mock RbacService rbacService; private CliSkillAppService service; @@ -62,7 +79,7 @@ class CliSkillAppServiceTest { )), 1L, 0, 20 ); - given(skillSearchAppService.search("pdf", null, "newest", 0, 20, null, null)) + given(skillSearchAppService.searchInstallableLatest("pdf", null, "newest", 0, 20, null, null)) .willReturn(searchResponse); var result = service.search("pdf", 20, null, null); @@ -77,15 +94,9 @@ class CliSkillAppServiceTest { } @Test - void search_filtersResultsWithoutInstallablePublishedVersion() { + void search_mapsInstallableSearchTotalFromQueryStage() { var searchResponse = new SkillSearchAppService.SearchResponse( List.of( - new SkillSummaryResponse( - 1L, "draft-only", "Draft Only", "No installable version", - "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, - "global", Instant.now(), false, - null, null, null, "NONE" - ), new SkillSummaryResponse( 2L, "ready", "Ready", "Installable", "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, @@ -95,9 +106,9 @@ class CliSkillAppServiceTest { null, "PUBLISHED" ) ), - 2L, 0, 20 + 1L, 0, 20 ); - given(skillSearchAppService.search("demo", null, "newest", 0, 20, null, null)) + given(skillSearchAppService.searchInstallableLatest("demo", null, "newest", 0, 20, null, null)) .willReturn(searchResponse); var result = service.search("demo", 20, null, null); @@ -107,6 +118,93 @@ class CliSkillAppServiceTest { assertEquals(1L, result.total()); } + @Test + void search_limitOneSkipsUninstallableMatchAndReturnsNextInstallableWithFilteredTotal() { + Skill unavailableFirstMatch = new Skill(1L, "draft-first", "owner-1", SkillVisibility.PUBLIC); + setField(unavailableFirstMatch, "id", 1L); + assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of()); + } + + @Test + void search_limitOneSkipsYankedLatestMatchAndReturnsNextInstallableWithFilteredTotal() { + Skill unavailableFirstMatch = new Skill(1L, "yanked-first", "owner-1", SkillVisibility.PUBLIC); + setField(unavailableFirstMatch, "id", 1L); + unavailableFirstMatch.setLatestVersionId(10L); + SkillVersion yanked = publishedVersion(1L, 10L, "1.0.0"); + yanked.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of(yanked)); + } + + @Test + void search_limitOneSkipsDownloadUnavailableLatestAndReturnsNextInstallableWithFilteredTotal() { + Skill unavailableFirstMatch = new Skill(1L, "not-ready-first", "owner-1", SkillVisibility.PUBLIC); + setField(unavailableFirstMatch, "id", 1L); + unavailableFirstMatch.setLatestVersionId(10L); + SkillVersion notReady = publishedVersion(1L, 10L, "1.0.0"); + notReady.setDownloadReady(false); + + assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of(notReady)); + } + + private void assertLimitOneSkipsUninstallableFirstMatch( + Skill unavailableFirstMatch, + List unavailableLatestVersions) { + SearchQueryService rankedSearch = query -> requiresInstallableLatest(query) + ? new SearchResult(List.of(2L), 1L, 0, 1) + : new SearchResult(List.of(1L), 2L, 0, 1); + SkillSearchAppService realSearchAppService = new SkillSearchAppService( + rankedSearch, + skillRepository, + namespaceRepository, + namespaceService, + new SkillLifecycleProjectionService(skillVersionRepository), + rbacService + ); + CliSkillAppService realService = new CliSkillAppService( + realSearchAppService, + skillQueryService, + skillDownloadService, + skillDeleteAppService, + skillPublishService + ); + + Skill installableSecondMatch = new Skill(1L, "ready-second", "owner-1", SkillVisibility.PUBLIC); + setField(installableSecondMatch, "id", 2L); + installableSecondMatch.setLatestVersionId(20L); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + SkillVersion installableVersion = publishedVersion(2L, 20L, "1.0.0"); + + org.mockito.Mockito.lenient() + .when(skillRepository.findByIdIn(List.of(1L))) + .thenReturn(List.of(unavailableFirstMatch)); + org.mockito.Mockito.lenient() + .when(skillRepository.findByIdIn(List.of(2L))) + .thenReturn(List.of(installableSecondMatch)); + org.mockito.Mockito.lenient() + .when(namespaceRepository.findByIdIn(List.of(1L))) + .thenReturn(List.of(namespace)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findByIdIn(List.of())) + .thenReturn(List.of()); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findByIdIn(List.of(10L))) + .thenReturn(unavailableLatestVersions); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findByIdIn(List.of(20L))) + .thenReturn(List.of(installableVersion)); + + var result = realService.search("demo", 1, null, null); + + assertEquals(1, result.items().size()); + assertEquals("ready-second", result.items().getFirst().slug()); + assertEquals("1.0.0", result.items().getFirst().latestVersion()); + assertEquals(1L, result.total()); + assertEquals(1, result.limit()); + } + @Test void resolve_delegatesToQueryService() { given(skillQueryService.resolveVersion("global", "demo", "2.0.0", null, null, "user-1", Map.of())) @@ -156,4 +254,30 @@ class CliSkillAppServiceTest { assertEquals("1.0.0", response.version()); assertEquals("PUBLIC", response.visibility()); } + + private boolean requiresInstallableLatest(SearchQuery query) { + try { + return (boolean) query.getClass().getMethod("requireInstallableLatest").invoke(query); + } catch (ReflectiveOperationException e) { + return false; + } + } + + private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) { + SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1"); + setField(version, "id", versionId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + return version; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java index 14c2cc4d..5a54d0a6 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java @@ -12,8 +12,20 @@ public record SearchQuery( String sortBy, int page, int size, - List labelSlugs + List labelSlugs, + boolean requireInstallableLatest ) { + public SearchQuery( + String keyword, + Long namespaceId, + SearchVisibilityScope visibilityScope, + String sortBy, + int page, + int size, + List labelSlugs) { + this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, false); + } + public SearchQuery( String keyword, Long namespaceId, @@ -21,6 +33,6 @@ public record SearchQuery( String sortBy, int page, int size) { - this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of()); + this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of(), false); } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 2e1ffcb1..64015844 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -107,6 +107,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("FROM skill_search_document d "); sql.append("JOIN skill s ON s.id = d.skill_id "); sql.append("JOIN namespace n ON n.id = d.namespace_id "); + if (query.requireInstallableLatest()) { + sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id "); + } sql.append("WHERE 1=1 "); // Visibility filtering @@ -120,6 +123,11 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND d.status = 'ACTIVE' "); sql.append("AND s.status = 'ACTIVE' "); sql.append("AND s.hidden = FALSE "); + if (query.requireInstallableLatest()) { + sql.append("AND latest.status = 'PUBLISHED' "); + sql.append("AND latest.download_ready = TRUE "); + sql.append("AND latest.yanked_at IS NULL "); + } sql.append("AND (n.status <> 'ARCHIVED' "); if (query.visibilityScope().userId() != null) { sql.append("OR d.namespace_id IN :memberNamespaceIds "); diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index 15804b92..c84c6cbd 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -400,6 +400,51 @@ class PostgresFullTextQueryServiceTest { verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); } + @Test + void installableLatestFilterShouldApplyToSearchAndCountQueries() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(2L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + var result = service.search(new SearchQuery( + "demo", + null, + SearchVisibilityScope.anonymous(), + "newest", + 0, + 1, + List.of(), + true + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("JOIN skill_version latest ON latest.id = s.latest_version_id") + .contains("AND latest.status = 'PUBLISHED'") + .contains("AND latest.download_ready = TRUE") + .contains("AND latest.yanked_at IS NULL") + .contains("LIMIT :limit OFFSET :offset"); + assertThat(sqlCaptor.getAllValues().get(1)) + .contains("JOIN skill_version latest ON latest.id = s.latest_version_id") + .contains("AND latest.status = 'PUBLISHED'") + .contains("AND latest.download_ready = TRUE") + .contains("AND latest.yanked_at IS NULL") + .doesNotContain("LIMIT :limit") + .doesNotContain("ORDER BY"); + assertThat(result.skillIds()).containsExactly(2L); + assertThat(result.total()).isEqualTo(1L); + } + @Test void authenticatedQueriesShouldAllowArchivedNamespacesForMembers() { EntityManager entityManager = mock(EntityManager.class); From cf22f568f76efda6099f2f32ceb388a0e8b5e8ac Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 11:27:09 +0800 Subject: [PATCH 53/78] test(cli): align auth tests with bearer hardening Refs: 25f57a32-5f1d-4d56-b6b7-9b6b7b868799 Signed-off-by: dongmucat <1127093059@qq.com> --- .../controller/cli/CliDryRunValidateTest.java | 43 +++++++++++-------- .../cli/CliSkillControllerTest.java | 18 +++----- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java index d82aa32d..64f8c7c9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java @@ -1,7 +1,11 @@ package com.iflytek.skillhub.controller.cli; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.cli.CliDryRunResponse; import com.iflytek.skillhub.service.cli.CliSkillAppService; import org.junit.jupiter.api.Test; @@ -10,18 +14,16 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -32,18 +34,22 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class CliDryRunValidateTest { @Autowired MockMvc mockMvc; @MockBean CliSkillAppService cliSkillAppService; + @MockBean ApiTokenService apiTokenService; + @MockBean UserAccountRepository userAccountRepository; + @MockBean UserRoleBindingRepository userRoleBindingRepository; - private UsernamePasswordAuthenticationToken auth() { - PlatformPrincipal principal = new PlatformPrincipal( - "user-1", "tester", "t@example.com", "", "api_token", Set.of("USER")); - return new UsernamePasswordAuthenticationToken( - principal, null, List.of( - new SimpleGrantedAuthority("ROLE_USER"), - new SimpleGrantedAuthority("SCOPE_skill:publish"))); + private void givenValidPublishToken() { + ApiToken token = new ApiToken("user-1", "cli", "sk_test", "hash", "[\"skill:publish\"]"); + UserAccount user = new UserAccount("user-1", "tester", "t@example.com", ""); + + given(apiTokenService.validateToken("test-token")).willReturn(Optional.of(token)); + given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user)); + given(userRoleBindingRepository.findByUserId("user-1")).willReturn(List.of()); } @Test void validatePublish_returnsValidResult() throws Exception { + givenValidPublishToken(); given(cliSkillAppService.validatePublish( eq("global"), any(), eq("user-1"), eq(SkillVisibility.PUBLIC), eq(Set.of("USER")))) .willReturn(new CliDryRunResponse( @@ -55,8 +61,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) - .header("Authorization", "Bearer test-token") - .with(authentication(auth()))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)) .andExpect(jsonPath("$.data.resolvedSlug").value("my-skill")) @@ -65,6 +70,7 @@ class CliDryRunValidateTest { @Test void validatePublish_returnsInvalidResult() throws Exception { + givenValidPublishToken(); given(cliSkillAppService.validatePublish( eq("global"), any(), eq("user-1"), eq(SkillVisibility.PUBLIC), eq(Set.of("USER")))) .willReturn(new CliDryRunResponse( @@ -76,8 +82,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) - .header("Authorization", "Bearer test-token") - .with(authentication(auth()))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(false)) .andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root")) @@ -86,6 +91,7 @@ class CliDryRunValidateTest { @Test void validatePublish_acceptsCustomVisibility() throws Exception { + givenValidPublishToken(); given(cliSkillAppService.validatePublish( eq("global"), any(), eq("user-1"), eq(SkillVisibility.PRIVATE), eq(Set.of("USER")))) .willReturn(new CliDryRunResponse( @@ -97,22 +103,21 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "PRIVATE".getBytes())) - .header("Authorization", "Bearer test-token") - .with(authentication(auth()))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)); } @Test void validatePublish_rejectsInvalidVisibility() throws Exception { + givenValidPublishToken(); MockMultipartFile file = new MockMultipartFile("file", "skill.zip", "application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04}); mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "BOGUS".getBytes())) - .header("Authorization", "Bearer test-token") - .with(authentication(auth()))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isBadRequest()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index fa40e0fa..9f6fe695 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -22,8 +22,6 @@ import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.bind.annotation.PostMapping; @@ -33,7 +31,6 @@ import java.lang.reflect.Method; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,7 +40,6 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.BDDMockito.given; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -204,13 +200,12 @@ class CliSkillControllerTest { @Test void deleteReturnsCliDeleteResponse() throws Exception { - PlatformPrincipal principal = new PlatformPrincipal( - "user-1", "tester", "t@example.com", "", "api_token", Set.of("USER")); - var auth = new UsernamePasswordAuthenticationToken( - principal, null, List.of( - new SimpleGrantedAuthority("ROLE_USER"), - new SimpleGrantedAuthority("SCOPE_skill:delete"))); + ApiToken token = new ApiToken("user-1", "cli", "sk_test", "hash", "[\"skill:delete\"]"); + UserAccount user = new UserAccount("user-1", "tester", "t@example.com", ""); + given(apiTokenService.validateToken("test-token")).willReturn(Optional.of(token)); + given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user)); + given(userRoleBindingRepository.findByUserId("user-1")).willReturn(List.of()); given(cliSkillAppService.deleteRemote( org.mockito.ArgumentMatchers.eq("global"), org.mockito.ArgumentMatchers.eq("demo"), @@ -222,8 +217,7 @@ class CliSkillControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .delete("/api/cli/v1/skills/global/demo") - .header("Authorization", "Bearer test-token") - .with(authentication(auth))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.ok").value(true)) .andExpect(jsonPath("$.data.namespace").value("global")) From 47a0ef1f7f29fcf187bfa078d2fb50c2d2cc281a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 11:48:04 +0800 Subject: [PATCH 54/78] chore(deps): patch dependabot security alerts Signed-off-by: dongmucat <1127093059@qq.com> --- docs/skillhub/.vitepress/config.ts | 7 +- docs/skillhub/package-lock.json | 220 +++++++------- docs/skillhub/package.json | 4 +- web/package.json | 7 +- web/pnpm-lock.yaml | 456 ++++++++++++++++++----------- web/vite.config.ts | 5 +- 6 files changed, 403 insertions(+), 296 deletions(-) diff --git a/docs/skillhub/.vitepress/config.ts b/docs/skillhub/.vitepress/config.ts index c99092f4..cdacab91 100644 --- a/docs/skillhub/.vitepress/config.ts +++ b/docs/skillhub/.vitepress/config.ts @@ -7,6 +7,11 @@ export default defineConfig({ ignoreDeadLinks: [/^http:\/\/localhost/], head: [], + vite: { + build: { + target: 'es2020', + }, + }, // Define root locale for redirect locales: { @@ -124,4 +129,4 @@ export default defineConfig({ }, }, }, -}) \ No newline at end of file +}) diff --git a/docs/skillhub/package-lock.json b/docs/skillhub/package-lock.json index e9bf813d..4bdf62a4 100644 --- a/docs/skillhub/package-lock.json +++ b/docs/skillhub/package-lock.json @@ -369,9 +369,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -386,9 +386,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -403,9 +403,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -420,9 +420,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -437,9 +437,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -454,9 +454,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -471,9 +471,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -488,9 +488,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -505,9 +505,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -522,9 +522,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -539,9 +539,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -556,9 +556,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -573,9 +573,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -590,9 +590,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -607,9 +607,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -624,9 +624,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -641,9 +641,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -658,9 +658,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -675,9 +675,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -692,9 +692,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -709,9 +709,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -726,9 +726,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -777,9 +777,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -794,9 +794,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1757,9 +1757,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1770,32 +1770,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -2475,9 +2475,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/docs/skillhub/package.json b/docs/skillhub/package.json index 4b119b04..a044c51f 100644 --- a/docs/skillhub/package.json +++ b/docs/skillhub/package.json @@ -11,8 +11,8 @@ "vitepress": "^1.6.3" }, "overrides": { - "vite": "^6.4.2", + "vite": "^6.4.3", "postcss": "^8.5.10", - "esbuild": "^0.25.0" + "esbuild": "^0.28.1" } } diff --git a/web/package.json b/web/package.json index 8dc05dfb..4b969147 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,10 @@ "esbuild" ], "overrides": { - "vite@<6.4.2": "^6.4.2", + "vite@<6.4.3": "^6.4.3", + "esbuild@<0.28.1": "^0.28.1", + "js-yaml@<4.2.0": "^4.2.0", + "@babel/core@<7.29.6": "^7.29.6", "postcss@<8.5.10": "^8.5.10", "picomatch@<2.3.2": "^2.3.2", "picomatch@>=4.0.0 <4.0.4": "^4.0.4", @@ -76,7 +79,7 @@ "postcss": "^8.5.10", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", - "vite": "^6.4.2", + "vite": "^6.4.3", "vitest": "^4.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index b12a2c88..389b1abb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -5,7 +5,10 @@ settings: excludeLinksFromLockfile: false overrides: - vite@<6.4.2: ^6.4.2 + vite@<6.4.3: ^6.4.3 + esbuild@<0.28.1: ^0.28.1 + js-yaml@<4.2.0: ^4.2.0 + '@babel/core@<7.29.6': ^7.29.6 postcss@<8.5.10: ^8.5.10 picomatch@<2.3.2: ^2.3.2 picomatch@>=4.0.0 <4.0.4: ^4.0.4 @@ -116,7 +119,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^4.3.0 - version: 4.7.0(vite@6.4.2(jiti@1.21.7)) + version: 4.7.0(vite@6.4.3(jiti@1.21.7)) autoprefixer: specifier: ^10.4.0 version: 10.4.27(postcss@8.5.15) @@ -145,11 +148,11 @@ importers: specifier: ^5.7.0 version: 5.9.3 vite: - specifier: ^6.4.2 - version: 6.4.2(jiti@1.21.7) + specifier: ^6.4.3 + version: 6.4.3(jiti@1.21.7) vitest: specifier: ^4.1.0 - version: 4.1.8(jsdom@29.1.1)(vite@6.4.2(jiti@1.21.7)) + version: 4.1.8(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)) packages: @@ -176,35 +179,51 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^7.29.6 '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} @@ -214,16 +233,24 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.0': @@ -231,17 +258,22 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^7.29.6 '@babel/plugin-transform-react-jsx-source@7.27.1': resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^7.29.6 '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} @@ -251,14 +283,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -343,158 +387,158 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1217,7 +1261,7 @@ packages: resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^6.4.2 + vite: ^6.4.3 '@vitest/expect@4.1.8': resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} @@ -1226,7 +1270,7 @@ packages: resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: msw: optional: true @@ -1523,8 +1567,8 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -1851,8 +1895,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsdom@29.1.1: @@ -2712,8 +2756,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -2768,7 +2812,7 @@ packages: '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -2906,19 +2950,25 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) @@ -2936,16 +2986,26 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/generator@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -2953,12 +3013,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -2966,27 +3033,35 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helpers@7.28.6': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/parser@7.29.7': dependencies: - '@babel/core': 7.29.0 + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime@7.28.6': {} @@ -2997,6 +3072,12 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3009,11 +3090,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -3116,82 +3214,82 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} - '@esbuild/aix-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.12': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.25.12': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.25.12': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.25.12': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.25.12': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.25.12': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.12': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.25.12': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': @@ -3209,7 +3307,7 @@ snapshots: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3585,7 +3683,7 @@ snapshots: colorette: 1.4.0 https-proxy-agent: 7.0.6(supports-color@10.2.2) js-levenshtein: 1.1.6 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 5.1.9 pluralize: 8.0.0 yaml-ast-parser: 0.0.43 @@ -3878,15 +3976,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@1.21.7))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@1.21.7))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) transitivePeerDependencies: - supports-color @@ -3899,13 +3997,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@6.4.2(jiti@1.21.7))': + '@vitest/mocker@4.1.8(vite@6.4.3(jiti@1.21.7))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) '@vitest/pretty-format@4.1.8': dependencies: @@ -4172,34 +4270,34 @@ snapshots: es-module-lexer@2.1.0: {} - esbuild@0.25.12: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -4253,7 +4351,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.1 + js-yaml: 4.2.0 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -4543,7 +4641,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -5206,7 +5304,7 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) classnames: 2.5.1 diff: 8.0.4 - js-yaml: 4.1.1 + js-yaml: 4.2.0 memoize-one: 6.0.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -5665,9 +5763,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.2(jiti@1.21.7): + vite@6.4.3(jiti@1.21.7): dependencies: - esbuild: 0.25.12 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 @@ -5677,10 +5775,10 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.8(jsdom@29.1.1)(vite@6.4.2(jiti@1.21.7)): + vitest@4.1.8(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@6.4.2(jiti@1.21.7)) + '@vitest/mocker': 4.1.8(vite@6.4.3(jiti@1.21.7)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -5697,7 +5795,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: jsdom: 29.1.1 diff --git a/web/vite.config.ts b/web/vite.config.ts index f49b7759..c5abfe3b 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' +const JS_BUILD_TARGET = 'es2020' const LEGACY_BROWSER_TARGETS = ['chrome83', 'edge83', 'firefox78', 'safari14'] export default defineConfig({ @@ -12,12 +13,12 @@ export default defineConfig({ }, }, build: { - target: LEGACY_BROWSER_TARGETS, + target: JS_BUILD_TARGET, cssTarget: LEGACY_BROWSER_TARGETS, }, optimizeDeps: { esbuildOptions: { - target: LEGACY_BROWSER_TARGETS, + target: JS_BUILD_TARGET, }, }, test: { From 32f34fbf4c6c56489a734a317ccf09a660275adc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 11:27:19 +0800 Subject: [PATCH 55/78] fix(ci): remove unsupported Python CodeQL scan Signed-off-by: dongmucat <1127093059@qq.com> --- .github/workflows/pr-scripts.yml | 1 + .github/workflows/security.yml | 2 -- scripts/tests/workflow-security-test.sh | 13 +++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index 1e31dccf..e521eb9e 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -13,6 +13,7 @@ on: - '.github/workflows/pr-tests.yml' - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' + - '**/*.py' permissions: contents: read diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3329a267..9382d933 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -50,8 +50,6 @@ jobs: build-mode: manual - language: javascript-typescript build-mode: none - - language: python - build-mode: none steps: - name: Check out repository diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index 2d6deb43..9a1688ef 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -37,6 +37,17 @@ grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \ grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \ || fail "security workflow must grant SARIF upload permission" +python_source="$(find "$REPO_ROOT" \ + \( -path "$REPO_ROOT/.git" -o -path '*/node_modules' -o -path '*/.venv' \) -prune -o \ + -type f -name '*.py' -print -quit)" +if [[ -n "$python_source" ]]; then + grep -Fq 'language: python' "$SECURITY_WORKFLOW" \ + || fail "security workflow must run Python CodeQL when Python source exists" +else + ! grep -Fq 'language: python' "$SECURITY_WORKFLOW" \ + || fail "security workflow must not run Python CodeQL without Python source" +fi + grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when security workflow changes" grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \ @@ -45,6 +56,8 @@ grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR E2E workflow changes" grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR Tests workflow changes" +grep -Fq "'**/*.py'" "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when Python source changes" grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when release env example changes" grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \ From 48a1de9640d4b298ccc5d0c20ac0827c766f234f Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 15:19:07 +0800 Subject: [PATCH 56/78] fix(notification): preserve SSE headers for notification stream Signed-off-by: dongmucat <1127093059@qq.com> --- .../skillhub/filter/RequestLoggingFilter.java | 17 +++++ .../filter/RequestLoggingFilterTest.java | 66 +++++++++++++++---- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index 0f83faee..d46a1e49 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -8,6 +8,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; @@ -39,6 +41,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter { throws ServletException, IOException { String uri = request.getRequestURI(); + if (isNotificationSse(uri)) { + prepareSseResponse(response); + filterChain.doFilter(request, response); + return; + } if (shouldSkip(uri)) { filterChain.doFilter(request, response); return; @@ -100,6 +107,16 @@ public class RequestLoggingFilter extends OncePerRequestFilter { return false; } + private boolean isNotificationSse(String uri) { + return uri != null && uri.endsWith("/notifications/sse"); + } + + private void prepareSseResponse(HttpServletResponse response) { + response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform"); + response.setHeader("X-Accel-Buffering", "no"); + } + private String getRequestBody(ContentCachingRequestWrapper request) { byte[] buf = request.getContentAsByteArray(); if (buf.length > 0) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index 495eeb63..11ec0aec 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -1,22 +1,26 @@ package com.iflytek.skillhub.filter; +import static org.assertj.core.api.Assertions.assertThat; + import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - +import jakarta.servlet.ServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.util.ContentCachingResponseWrapper; class RequestLoggingFilterTest { @@ -79,12 +83,12 @@ class RequestLoggingFilterTest { } @Test - void doFilterInternal_skipsSseEndpointsWithoutWrappingResponse() + void doFilterInternal_skipsOtherSseEndpointsWithoutWrappingResponse() throws ServletException, IOException { RequestLoggingFilter filter = new RequestLoggingFilter(); attachAppender(); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/scan/sse"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = (req, res) -> { @@ -97,8 +101,10 @@ class RequestLoggingFilterTest { filter.doFilter(request, response, filterChain); assertThat(response.getHeader("Content-Length")).isNull(); + assertThat(response.getHeader("X-Accel-Buffering")).isNull(); + assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isNull(); assertThat(response.getContentAsString()).isEqualTo("event:connected\n"); - assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/notifications/sse")); + assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/scan/sse")); } @Test @@ -124,6 +130,44 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("Headers: {")); } + @Test + void doFilterInternal_shouldBypassCachingWrapperForNotificationSse() throws Exception { + RequestLoggingFilter filter = new RequestLoggingFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference responseSeenByChain = new AtomicReference<>(); + FilterChain chain = (servletRequest, servletResponse) -> { + responseSeenByChain.set(servletResponse); + servletResponse.getWriter().write("event: connected\n"); + servletResponse.flushBuffer(); + }; + + filter.doFilter(request, response, chain); + + assertThat(responseSeenByChain.get()).isSameAs(response); + assertThat(response.getHeader("X-Accel-Buffering")).isEqualTo("no"); + assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache, no-transform"); + assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE); + assertThat(response.getContentAsString()).contains("event: connected"); + } + + @Test + void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception { + RequestLoggingFilter filter = new RequestLoggingFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/unread-count"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference responseSeenByChain = new AtomicReference<>(); + FilterChain chain = (servletRequest, servletResponse) -> { + responseSeenByChain.set(servletResponse); + servletResponse.getWriter().write("{\"count\":1}"); + }; + + filter.doFilter(request, response, chain); + + assertThat(responseSeenByChain.get()).isInstanceOf(ContentCachingResponseWrapper.class); + assertThat(response.getContentAsString()).isEqualTo("{\"count\":1}"); + } + private void attachAppender() { logger.setLevel(Level.INFO); appender = new ListAppender<>(); From 78b8b34ed18db24c260e073d5da929a52171c46c Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 18 Jun 2026 15:52:49 +0800 Subject: [PATCH 57/78] chore(cli): bump version to 0.1.8 Signed-off-by: dongmucat <1127093059@qq.com> --- cli/package.json | 2 +- cli/src/generated/pkg-info.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/package.json b/cli/package.json index 7edb6493..3845975b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@astron-team/skillhub", - "version": "0.1.7", + "version": "0.1.8", "description": "Manage and install skills for AI coding agents", "keywords": [ "skillhub", diff --git a/cli/src/generated/pkg-info.ts b/cli/src/generated/pkg-info.ts index f73a487a..bc445c51 100644 --- a/cli/src/generated/pkg-info.ts +++ b/cli/src/generated/pkg-info.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-pkg-info.ts - do not edit by hand. export const PKG_NAME = "@astron-team/skillhub" -export const PKG_VERSION = "0.1.7" +export const PKG_VERSION = "0.1.8" From 665ee0499af78f3c6d026b2acfcd1ad7e7f6830b Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 22 Jun 2026 12:36:47 +0800 Subject: [PATCH 58/78] feat(auth): add ISSUE-60 password capability field Signed-off-by: dongmucat <1127093059@qq.com> --- .../skillhub/controller/AuthController.java | 12 ++++++--- .../controller/LocalAuthController.java | 10 ++++--- .../iflytek/skillhub/dto/AuthMeResponse.java | 4 ++- .../service/AuthMeResponseAssembler.java | 27 +++++++++++++++++++ .../controller/AuthControllerTest.java | 10 ++++++- .../controller/DirectAuthControllerTest.java | 9 ++++++- .../controller/LocalAuthControllerTest.java | 12 +++++++-- .../SessionBootstrapControllerTest.java | 9 ++++++- .../auth/local/LocalCredentialRepository.java | 2 ++ .../auth/local/LocalAuthServiceTest.java | 14 ++++++++++ web/src/api/generated/schema.d.ts | 1 + 11 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index 1552f6f2..4ba7b27b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.AuthProviderResponse; import com.iflytek.skillhub.dto.DirectLoginRequest; import com.iflytek.skillhub.dto.SessionBootstrapRequest; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.service.AuthMeResponseAssembler; import com.iflytek.skillhub.service.AuthMethodCatalog; import com.iflytek.skillhub.service.DirectAuthService; import com.iflytek.skillhub.service.SessionBootstrapService; @@ -56,6 +57,7 @@ public class AuthController extends BaseApiController { private final UserRoleBindingRepository userRoleBindingRepository; private final PlatformSessionService platformSessionService; private final UserAccountRepository userAccountRepository; + private final AuthMeResponseAssembler authMeResponseAssembler; public AuthController(ApiResponseFactory responseFactory, AuthMethodCatalog authMethodCatalog, @@ -64,7 +66,8 @@ public class AuthController extends BaseApiController { AuthFailureThrottleService authFailureThrottleService, UserRoleBindingRepository userRoleBindingRepository, PlatformSessionService platformSessionService, - UserAccountRepository userAccountRepository) { + UserAccountRepository userAccountRepository, + AuthMeResponseAssembler authMeResponseAssembler) { super(responseFactory); this.authMethodCatalog = authMethodCatalog; this.sessionBootstrapService = sessionBootstrapService; @@ -73,6 +76,7 @@ public class AuthController extends BaseApiController { this.userRoleBindingRepository = userRoleBindingRepository; this.platformSessionService = platformSessionService; this.userAccountRepository = userAccountRepository; + this.authMeResponseAssembler = authMeResponseAssembler; } /** @@ -111,7 +115,7 @@ public class AuthController extends BaseApiController { freshRoles); platformSessionService.establishSession(principal, request, false); } - return ok("response.success.read", AuthMeResponse.from(principal)); + return ok("response.success.read", authMeResponseAssembler.from(principal)); } /** @@ -146,7 +150,7 @@ public class AuthController extends BaseApiController { HttpServletRequest httpRequest) { return ok( "response.success.read", - AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest)) + authMeResponseAssembler.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest)) ); } @@ -178,7 +182,7 @@ public class AuthController extends BaseApiController { authFailureThrottleService.resetIdentifier(category, request.username()); return ok( "response.success.read", - AuthMeResponse.from(principal) + authMeResponseAssembler.from(principal) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 8442939d..17e54fbe 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.security.AuthFailureThrottleService; +import com.iflytek.skillhub.service.AuthMeResponseAssembler; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; @@ -38,19 +39,22 @@ public class LocalAuthController extends BaseApiController { private final PlatformSessionService platformSessionService; private final AuthFailureThrottleService authFailureThrottleService; private final PasswordResetService passwordResetService; + private final AuthMeResponseAssembler authMeResponseAssembler; public LocalAuthController(ApiResponseFactory responseFactory, LocalAuthService localAuthService, SkillHubMetrics skillHubMetrics, PlatformSessionService platformSessionService, AuthFailureThrottleService authFailureThrottleService, - PasswordResetService passwordResetService) { + PasswordResetService passwordResetService, + AuthMeResponseAssembler authMeResponseAssembler) { super(responseFactory); this.localAuthService = localAuthService; this.skillHubMetrics = skillHubMetrics; this.platformSessionService = platformSessionService; this.authFailureThrottleService = authFailureThrottleService; this.passwordResetService = passwordResetService; + this.authMeResponseAssembler = authMeResponseAssembler; } @PostMapping("/register") @@ -60,7 +64,7 @@ public class LocalAuthController extends BaseApiController { PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email()); skillHubMetrics.incrementUserRegister(); platformSessionService.establishSession(principal, httpRequest); - return ok("response.success.created", AuthMeResponse.from(principal)); + return ok("response.success.created", authMeResponseAssembler.from(principal)); } @PostMapping("/login") @@ -84,7 +88,7 @@ public class LocalAuthController extends BaseApiController { authFailureThrottleService.resetIdentifier("local", request.username()); skillHubMetrics.recordLocalLogin(true); platformSessionService.establishSession(principal, httpRequest); - return ok("response.success.read", AuthMeResponse.from(principal)); + return ok("response.success.read", authMeResponseAssembler.from(principal)); } @PostMapping("/change-password") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java index 470b2fdb..d5447884 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java @@ -10,15 +10,17 @@ public record AuthMeResponse( String email, String avatarUrl, String oauthProvider, + boolean canChangePassword, Set platformRoles ) { - public static AuthMeResponse from(PlatformPrincipal principal) { + public static AuthMeResponse from(PlatformPrincipal principal, boolean canChangePassword) { return new AuthMeResponse( principal.userId(), principal.displayName(), principal.email() != null ? principal.email() : "", principal.avatarUrl() != null ? principal.avatarUrl() : "", principal.oauthProvider(), + canChangePassword, principal.platformRoles() ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java new file mode 100644 index 00000000..60b0066b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.AuthMeResponse; +import org.springframework.stereotype.Service; + +/** + * Builds the current-user API response with account capabilities derived from + * authoritative backend state. + */ +@Service +public class AuthMeResponseAssembler { + + private final LocalCredentialRepository localCredentialRepository; + + public AuthMeResponseAssembler(LocalCredentialRepository localCredentialRepository) { + this.localCredentialRepository = localCredentialRepository; + } + + public AuthMeResponse from(PlatformPrincipal principal) { + return AuthMeResponse.from( + principal, + localCredentialRepository.existsByUserId(principal.userId()) + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index a25d3104..d79d368a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.user.UserAccount; @@ -64,6 +65,9 @@ class AuthControllerTest { @MockBean private UserRoleBindingRepository userRoleBindingRepository; + @MockBean + private LocalCredentialRepository localCredentialRepository; + @Test void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) @@ -77,6 +81,7 @@ class AuthControllerTest { given(userAccountRepository.findById("user-42")) .willReturn(java.util.Optional.of(new UserAccount("user-42", "tester", "tester@example.com", "https://example.com/avatar.png"))); given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of()); + given(localCredentialRepository.existsByUserId("user-42")).willReturn(false); PlatformPrincipal principal = new PlatformPrincipal( "user-42", @@ -102,6 +107,7 @@ class AuthControllerTest { .andExpect(jsonPath("$.data.userId").value("user-42")) .andExpect(jsonPath("$.data.displayName").value("tester")) .andExpect(jsonPath("$.data.oauthProvider").value("github")) + .andExpect(jsonPath("$.data.canChangePassword").value(false)) .andExpect(jsonPath("$.data.platformRoles[0]").value("USER")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); @@ -115,6 +121,7 @@ class AuthControllerTest { var user = new UserAccount("user-42", "UpdatedName", "tester@example.com", "https://example.com/avatar.png"); given(userAccountRepository.findById("user-42")).willReturn(java.util.Optional.of(user)); given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of()); + given(localCredentialRepository.existsByUserId("user-42")).willReturn(true); PlatformPrincipal principal = new PlatformPrincipal( "user-42", @@ -134,7 +141,8 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.displayName").value("UpdatedName")); // should return DB value + .andExpect(jsonPath("$.data.displayName").value("UpdatedName")) // should return DB value + .andExpect(jsonPath("$.data.canChangePassword").value(true)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java index 8878e286..97db51d4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -7,6 +7,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.local.LocalAuthService; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; @@ -52,6 +53,9 @@ class DirectAuthControllerTest { @MockBean private UserRoleBindingRepository userRoleBindingRepository; + @MockBean + private LocalCredentialRepository localCredentialRepository; + @Test void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -67,6 +71,7 @@ class DirectAuthControllerTest { given(userAccountRepository.findById("usr_direct_1")) .willReturn(java.util.Optional.of(new UserAccount("usr_direct_1", "direct-user", null, null))); given(userRoleBindingRepository.findByUserId("usr_direct_1")).willReturn(List.of()); + given(localCredentialRepository.existsByUserId("usr_direct_1")).willReturn(true); MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/direct/login") .with(csrf()) @@ -77,6 +82,7 @@ class DirectAuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("usr_direct_1")) + .andExpect(jsonPath("$.data.canChangePassword").value(true)) .andReturn() .getRequest() .getSession(false); @@ -84,7 +90,8 @@ class DirectAuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").session(session)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.userId").value("usr_direct_1")); + .andExpect(jsonPath("$.data.userId").value("usr_direct_1")) + .andExpect(jsonPath("$.data.canChangePassword").value(true)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index 440fe4bf..2425acde 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -12,6 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -55,6 +56,9 @@ class LocalAuthControllerTest { @MockBean private PasswordResetService passwordResetService; + @MockBean + private LocalCredentialRepository localCredentialRepository; + @Test void login_returnsCurrentUserEnvelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -66,6 +70,7 @@ class LocalAuthControllerTest { Set.of("SUPER_ADMIN") ); given(localAuthService.login("alice", "Abcd123!")).willReturn(principal); + given(localCredentialRepository.existsByUserId("usr_1")).willReturn(true); mockMvc.perform(post("/api/v1/auth/local/login") .with(csrf()) @@ -76,7 +81,8 @@ class LocalAuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("usr_1")) - .andExpect(jsonPath("$.data.oauthProvider").value("local")); + .andExpect(jsonPath("$.data.oauthProvider").value("local")) + .andExpect(jsonPath("$.data.canChangePassword").value(true)); verify(skillHubMetrics).recordLocalLogin(true); verify(skillHubMetrics, never()).recordLocalLogin(false); verify(authFailureThrottleService).resetIdentifier("local", "alice"); @@ -93,6 +99,7 @@ class LocalAuthControllerTest { Set.of() ); given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal); + given(localCredentialRepository.existsByUserId("usr_2")).willReturn(true); mockMvc.perform(post("/api/v1/auth/local/register") .with(csrf()) @@ -102,7 +109,8 @@ class LocalAuthControllerTest { """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.displayName").value("bob")); + .andExpect(jsonPath("$.data.displayName").value("bob")) + .andExpect(jsonPath("$.data.canChangePassword").value(true)); verify(skillHubMetrics).incrementUserRegister(); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java index b842efbc..36fd80bf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -48,12 +49,16 @@ class SessionBootstrapControllerTest { @MockBean private UserRoleBindingRepository userRoleBindingRepository; + @MockBean + private LocalCredentialRepository localCredentialRepository; + @Test void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception { given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of()); given(userAccountRepository.findById("sso-user-1")) .willReturn(Optional.of(new UserAccount("sso-user-1", "Private SSO User", null, null))); given(userRoleBindingRepository.findByUserId("sso-user-1")).willReturn(List.of()); + given(localCredentialRepository.existsByUserId("sso-user-1")).willReturn(false); MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap") .with(csrf()) @@ -65,6 +70,7 @@ class SessionBootstrapControllerTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("sso-user-1")) .andExpect(jsonPath("$.data.displayName").value("Private SSO User")) + .andExpect(jsonPath("$.data.canChangePassword").value(false)) .andReturn() .getRequest() .getSession(false); @@ -73,7 +79,8 @@ class SessionBootstrapControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("sso-user-1")) - .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")); + .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")) + .andExpect(jsonPath("$.data.canChangePassword").value(false)); } @Test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java index 8346b9c2..a80d44ac 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java @@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository findByUserId(String userId); boolean existsByUsernameIgnoreCase(String username); + + boolean existsByUserId(String userId); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6b9f4344..b6eaf5af 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -230,6 +230,20 @@ class LocalAuthServiceTest { assertThat(principal.platformRoles()).containsExactly("USER"); } + @Test + void changePassword_withoutLocalCredential_rejectsRequest() { + given(credentialRepository.findByUserId("oauth-only")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.changePassword("oauth-only", "old", "Newpass123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.notEnabled") + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(passwordEncoder, never()).matches(any(), any()); + verify(credentialRepository, never()).save(any(LocalCredential.class)); + } + @Test void register_rejectsInvalidEmailFormat() { given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a141fb20..68d7ceb9 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3846,6 +3846,7 @@ export interface components { email?: string; avatarUrl?: string; oauthProvider?: string; + canChangePassword?: boolean; platformRoles?: string[]; }; LocalRegisterRequest: { From 54006e72a4db90ced5e2ed6e7b4dfb309738edeb Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 22 Jun 2026 14:00:00 +0800 Subject: [PATCH 59/78] fix(web): ISSUE-62 gate security settings by capability Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/settings-security-capability.spec.ts | 102 ++++++++++++++++ web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/zh.json | 2 + web/src/pages/settings/security.test.ts | 61 ---------- web/src/pages/settings/security.test.tsx | 119 +++++++++++++++++++ web/src/pages/settings/security.tsx | 60 ++++++---- web/src/shared/components/user-menu.test.ts | 18 --- web/src/shared/components/user-menu.test.tsx | 108 +++++++++++++++++ web/src/shared/components/user-menu.tsx | 5 +- 9 files changed, 371 insertions(+), 106 deletions(-) create mode 100644 web/e2e/settings-security-capability.spec.ts delete mode 100644 web/src/pages/settings/security.test.ts create mode 100644 web/src/pages/settings/security.test.tsx delete mode 100644 web/src/shared/components/user-menu.test.ts create mode 100644 web/src/shared/components/user-menu.test.tsx diff --git a/web/e2e/settings-security-capability.spec.ts b/web/e2e/settings-security-capability.spec.ts new file mode 100644 index 00000000..30d94ea8 --- /dev/null +++ b/web/e2e/settings-security-capability.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +interface MockSessionUser { + userId: string + displayName: string + email: string + avatarUrl: string + oauthProvider: string + canChangePassword: boolean + platformRoles: string[] +} + +function apiEnvelope(data: unknown) { + return { + code: 0, + msg: 'OK', + data, + timestamp: new Date().toISOString(), + requestId: 'e2e-security-capability', + } +} + +async function mockSession(page: Page, user: MockSessionUser) { + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope(user)), + }) + }) + + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope([])), + }) + }) + + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope({ count: 0 })), + }) + }) + + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: ': ok\n\n', + }) + }) +} + +test.describe('Security Settings capability', () => { + test('shows the security menu entry and password form for local admin accounts', async ({ page }) => { + await setEnglishLocale(page) + await mockSession(page, { + userId: 'local-admin', + displayName: 'Local Admin', + email: 'local-admin@example.test', + avatarUrl: '', + oauthProvider: '', + canChangePassword: true, + platformRoles: ['USER', 'SUPER_ADMIN'], + }) + + await page.goto('/settings/security') + await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() + await expect(page.getByLabel('Current Password')).toBeVisible() + await expect(page.getByLabel('New Password')).toBeVisible() + + await page.getByRole('button', { name: 'Local Admin' }).click() + await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible() + }) + + test('hides the security menu entry and form when password changes are unavailable', async ({ page }) => { + await setEnglishLocale(page) + await mockSession(page, { + userId: 'oauth-only-user', + displayName: 'OAuth Only User', + email: 'oauth-only@example.test', + avatarUrl: '', + oauthProvider: 'github', + canChangePassword: false, + platformRoles: ['USER'], + }) + + await page.goto('/settings/security') + + await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() + await expect(page.getByText('Password changes are unavailable for this account.')).toBeVisible() + await expect(page.getByLabel('Current Password')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Update Password' })).toHaveCount(0) + + await page.getByRole('button', { name: 'OAuth Only User' }).click() + await expect(page.getByRole('link', { name: 'Security Settings' })).toHaveCount(0) + }) +}) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 56039107..6cf7fc71 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -743,6 +743,8 @@ "successTitle": "Password changed successfully", "successDescription": "Please sign in again with your new password.", "defaultError": "Failed to change password", + "unavailableTitle": "Password changes are unavailable for this account.", + "unavailableDescription": "This account signs in through an external identity provider or has no local password credential.", "submitting": "Submitting...", "submit": "Update Password" }, diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 1920b158..2086a5a4 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -743,6 +743,8 @@ "successTitle": "密码修改成功", "successDescription": "请使用新密码重新登录。", "defaultError": "修改密码失败", + "unavailableTitle": "此账号暂不可修改密码。", + "unavailableDescription": "此账号通过外部身份提供方登录,或尚未配置本地密码凭据。", "submitting": "提交中...", "submit": "更新密码" }, diff --git a/web/src/pages/settings/security.test.ts b/web/src/pages/settings/security.test.ts deleted file mode 100644 index 6c11ae40..00000000 --- a/web/src/pages/settings/security.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@tanstack/react-router', () => ({ - useNavigate: () => vi.fn(), -})) - -vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ setQueryData: vi.fn() }), -})) - -vi.mock('react-i18next', async () => { - const actual = await vi.importActual('react-i18next') - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => key, - }), - } -}) - -vi.mock('@/api/client', () => ({ - ApiError: class ApiError extends Error { - status?: number - }, - authApi: { - changePassword: vi.fn(), - logout: vi.fn(), - }, -})) - -vi.mock('@/shared/lib/error-display', () => ({ - truncateErrorMessage: (v: string) => v, -})) - -vi.mock('@/shared/lib/toast', () => ({ - toast: { success: vi.fn(), error: vi.fn() }, -})) - -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/card', () => ({ - Card: ({ children }: { children: unknown }) => children, - CardContent: ({ children }: { children: unknown }) => children, - CardDescription: ({ children }: { children: unknown }) => children, - CardHeader: ({ children }: { children: unknown }) => children, - CardTitle: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/input', () => ({ - Input: () => null, -})) - -import { SecuritySettingsPage } from './security' - -describe('SecuritySettingsPage', () => { - it('exports a named component function', () => { - expect(typeof SecuritySettingsPage).toBe('function') - }) -}) diff --git a/web/src/pages/settings/security.test.tsx b/web/src/pages/settings/security.test.tsx new file mode 100644 index 00000000..7f965912 --- /dev/null +++ b/web/src/pages/settings/security.test.tsx @@ -0,0 +1,119 @@ +import type { InputHTMLAttributes, ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useAuthMock = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ setQueryData: vi.fn() }), +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/api/client', () => ({ + ApiError: class ApiError extends Error { + status?: number + }, + authApi: { + changePassword: vi.fn(), + logout: vi.fn(), + }, +})) + +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: useAuthMock, +})) + +vi.mock('@/shared/lib/error-display', () => ({ + truncateErrorMessage: (v: string) => v, +})) + +vi.mock('@/shared/lib/toast', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@/shared/ui/button', () => ({ + Button: ({ + children, + disabled, + type, + }: { + children: ReactNode + disabled?: boolean + type?: 'button' | 'submit' | 'reset' + }) => ( + + ), +})) + +vi.mock('@/shared/ui/card', () => ({ + Card: ({ children }: { children: ReactNode }) => children, + CardContent: ({ children }: { children: ReactNode }) => children, + CardDescription: ({ children }: { children: ReactNode }) => children, + CardHeader: ({ children }: { children: ReactNode }) => children, + CardTitle: ({ children }: { children: ReactNode }) => children, +})) + +vi.mock('@/shared/ui/input', () => ({ + Input: (props: InputHTMLAttributes) => , +})) + +import { SecuritySettingsPage } from './security' + +beforeEach(() => { + useAuthMock.mockReturnValue({ + user: { + userId: 'user-1', + displayName: 'Local User', + platformRoles: ['USER'], + canChangePassword: true, + }, + }) +}) + +describe('SecuritySettingsPage', () => { + it('exports a named component function', () => { + expect(typeof SecuritySettingsPage).toBe('function') + }) + + it('renders the password form when password changes are allowed', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('security.currentPassword') + expect(html).toContain('security.newPassword') + expect(html).toContain('security.submit') + }) + + it('renders a read-only unavailable state when password changes are not allowed', () => { + useAuthMock.mockReturnValue({ + user: { + userId: 'oauth-user', + displayName: 'OAuth User', + oauthProvider: 'github', + platformRoles: ['USER'], + canChangePassword: false, + }, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('security.unavailableTitle') + expect(html).toContain('security.unavailableDescription') + expect(html).not.toContain('security.currentPassword') + expect(html).not.toContain('security.submit') + }) +}) diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx index d9285910..37e85d56 100644 --- a/web/src/pages/settings/security.tsx +++ b/web/src/pages/settings/security.tsx @@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { ApiError, authApi } from '@/api/client' +import { useAuth } from '@/features/auth/use-auth' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { truncateErrorMessage } from '@/shared/lib/error-display' import { toast } from '@/shared/lib/toast' @@ -19,10 +20,12 @@ export function SecuritySettingsPage() { const { t } = useTranslation() const navigate = useNavigate() const queryClient = useQueryClient() + const { user } = useAuth() const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [errorMessage, setErrorMessage] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const passwordChangeUnavailable = user?.canChangePassword === false /** * Submits the password change request and clears local auth state afterward, @@ -78,32 +81,39 @@ export function SecuritySettingsPage() { {t('security.subtitle')} -
-
- - setCurrentPassword(event.target.value)} - /> + {passwordChangeUnavailable ? ( +
+

{t('security.unavailableTitle')}

+

{t('security.unavailableDescription')}

-
- - setNewPassword(event.target.value)} - /> -
- {errorMessage ?

{errorMessage}

: null} - - + ) : ( +
+
+ + setCurrentPassword(event.target.value)} + /> +
+
+ + setNewPassword(event.target.value)} + /> +
+ {errorMessage ?

{errorMessage}

: null} + +
+ )}
diff --git a/web/src/shared/components/user-menu.test.ts b/web/src/shared/components/user-menu.test.ts deleted file mode 100644 index a1877caf..00000000 --- a/web/src/shared/components/user-menu.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import * as mod from './user-menu' - -/** - * UserMenu is a React component that renders a hover/click dropdown menu with - * role-based navigation links (dashboard, reviews, admin, etc.) and logout. - * Internal helpers (hasRole, closeMenu, handleMouseEnter/Leave) and the - * menuItemClassName constant are scoped inside the component function. - * There are no exported pure helpers or constants to test here. - * - * We verify the module shape so downstream consumers break fast - * if the export contract changes. - */ -describe('user-menu module exports', () => { - it('exports the UserMenu component', () => { - expect(mod.UserMenu).toBeTypeOf('function') - }) -}) diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx new file mode 100644 index 00000000..3487bd8b --- /dev/null +++ b/web/src/shared/components/user-menu.test.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import * as mod from './user-menu' +import { UserMenu } from './user-menu' + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useState: (initialValue: unknown) => [ + typeof initialValue === 'boolean' ? true : initialValue, + vi.fn(), + ], + } +}) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ + children, + className, + onClick, + to, + }: { + children: ReactNode + className?: string + onClick?: () => void + to: string + }) => ( + { + event.preventDefault() + onClick?.() + }} + > + {children} + + ), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + setQueryData: vi.fn(), + }), +})) + +vi.mock('@/api/client', () => ({ + authApi: { + logout: vi.fn(), + }, +})) + +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespaces: () => ({ data: [] }), +})) + +/** + * UserMenu is a React component that renders a hover/click dropdown menu with + * role-based navigation links (dashboard, reviews, admin, etc.) and logout. + */ +describe('user-menu module exports', () => { + it('exports the UserMenu component', () => { + expect(mod.UserMenu).toBeTypeOf('function') + }) +}) + +describe('UserMenu security settings visibility', () => { + it('shows security settings when password changes are allowed, independent of OAuth provider', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('user.menu.security') + }) + + it('hides security settings when password changes are not allowed, even for a local-looking account', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).not.toContain('user.menu.security') + }) +}) diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 1cb10a31..d3eb3d39 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -14,6 +14,7 @@ interface User { avatarUrl?: string platformRoles?: string[] oauthProvider?: string + canChangePassword?: boolean } interface UserMenuProps { @@ -37,7 +38,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') const isSuperAdmin = hasRole('SUPER_ADMIN') const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces) - const isLocalAccount = !user.oauthProvider + const canChangePassword = user.canChangePassword === true const open = isHovered || isClickOpen const clearCloseTimer = () => { @@ -200,7 +201,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.notifications')} - {isLocalAccount ? ( + {canChangePassword ? ( {t('user.menu.security')} From 9f927c12b05a24a49de410bee5363c56b6484723 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 22 Jun 2026 14:22:25 +0800 Subject: [PATCH 60/78] fix(PR): default deny security password changes Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/pages/settings/security.test.tsx | 10 +++++++++ web/src/pages/settings/security.tsx | 27 ++++++++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/web/src/pages/settings/security.test.tsx b/web/src/pages/settings/security.test.tsx index 7f965912..9e2955a7 100644 --- a/web/src/pages/settings/security.test.tsx +++ b/web/src/pages/settings/security.test.tsx @@ -116,4 +116,14 @@ describe('SecuritySettingsPage', () => { expect(html).not.toContain('security.currentPassword') expect(html).not.toContain('security.submit') }) + + it('defaults to the unavailable state while the user capability is unknown', () => { + useAuthMock.mockReturnValue({ user: null }) + + const html = renderToStaticMarkup() + + expect(html).toContain('security.unavailableTitle') + expect(html).not.toContain('security.currentPassword') + expect(html).not.toContain('security.submit') + }) }) diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx index 37e85d56..3726ad7d 100644 --- a/web/src/pages/settings/security.tsx +++ b/web/src/pages/settings/security.tsx @@ -11,6 +11,14 @@ import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' +interface PasswordChangeCapabilityUser { + canChangePassword?: boolean +} + +function canUsePasswordChangeForm(user?: PasswordChangeCapabilityUser | null) { + return user?.canChangePassword === true +} + /** * Security settings page for password changes. After a successful change the * user is logged out so all existing authenticated state is re-established with @@ -25,7 +33,7 @@ export function SecuritySettingsPage() { const [newPassword, setNewPassword] = useState('') const [errorMessage, setErrorMessage] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) - const passwordChangeUnavailable = user?.canChangePassword === false + const canChangePassword = canUsePasswordChangeForm(user) /** * Submits the password change request and clears local auth state afterward, @@ -35,6 +43,11 @@ export function SecuritySettingsPage() { event.preventDefault() setErrorMessage('') + if (!canChangePassword) { + setErrorMessage(t('security.unavailableTitle')) + return + } + if (!currentPassword.trim()) { setErrorMessage(t('security.currentPasswordRequired')) return @@ -81,12 +94,7 @@ export function SecuritySettingsPage() { {t('security.subtitle')} - {passwordChangeUnavailable ? ( -
-

{t('security.unavailableTitle')}

-

{t('security.unavailableDescription')}

-
- ) : ( + {canChangePassword ? (
@@ -113,6 +121,11 @@ export function SecuritySettingsPage() { {isSubmitting ? t('security.submitting') : t('security.submit')} + ) : ( +
+

{t('security.unavailableTitle')}

+

{t('security.unavailableDescription')}

+
)} From f61ce71daa998f5169e4bbe015cadead41f7f0f6 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 22 Jun 2026 17:22:19 +0800 Subject: [PATCH 61/78] test(web): ISSUE-61 cover security settings real requests Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/settings-pages.spec.ts | 6 +- web/e2e/settings-security-capability.spec.ts | 102 +++++++------------ 2 files changed, 38 insertions(+), 70 deletions(-) diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts index de2abd6e..38f28760 100644 --- a/web/e2e/settings-pages.spec.ts +++ b/web/e2e/settings-pages.spec.ts @@ -1,11 +1,13 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' +import { createFreshSession } from './helpers/session' test.describe('Settings Pages (Real API)', () => { + test.use({ baseURL: 'http://127.0.0.1:3000' }) + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) - await registerSession(page, testInfo) + await createFreshSession(page, testInfo) }) test('opens profile settings page', async ({ page }) => { diff --git a/web/e2e/settings-security-capability.spec.ts b/web/e2e/settings-security-capability.spec.ts index 30d94ea8..b2d45910 100644 --- a/web/e2e/settings-security-capability.spec.ts +++ b/web/e2e/settings-security-capability.spec.ts @@ -1,93 +1,50 @@ import { expect, test, type Page } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' +import { csrfHeaders } from './helpers/csrf' +import { loginWithCredentials } from './helpers/session' -interface MockSessionUser { - userId: string - displayName: string - email: string - avatarUrl: string - oauthProvider: string - canChangePassword: boolean - platformRoles: string[] +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined } -function apiEnvelope(data: unknown) { +function adminCredentials() { return { - code: 0, - msg: 'OK', - data, - timestamp: new Date().toISOString(), - requestId: 'e2e-security-capability', + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', } } -async function mockSession(page: Page, user: MockSessionUser) { - await page.route('**/api/v1/auth/me', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(apiEnvelope(user)), - }) - }) - - await page.route('**/api/web/me/namespaces', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(apiEnvelope([])), - }) - }) - - await page.route('**/api/web/notifications/unread-count', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(apiEnvelope({ count: 0 })), - }) - }) - - await page.route('**/api/web/notifications/sse', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: ': ok\n\n', - }) - }) +async function currentDisplayName(page: Page, headers?: Record): Promise { + const response = await page.context().request.get('/api/v1/auth/me', { headers }) + expect(response.ok()).toBeTruthy() + const body = await response.json() as { data: { displayName: string } } + return body.data.displayName } -test.describe('Security Settings capability', () => { - test('shows the security menu entry and password form for local admin accounts', async ({ page }) => { +test.describe('Security Settings capability (Real API)', () => { + test.use({ baseURL: 'http://127.0.0.1:3000' }) + + test('shows the security menu entry and password form for local admin accounts', async ({ page }, testInfo) => { await setEnglishLocale(page) - await mockSession(page, { - userId: 'local-admin', - displayName: 'Local Admin', - email: 'local-admin@example.test', - avatarUrl: '', - oauthProvider: '', - canChangePassword: true, - platformRoles: ['USER', 'SUPER_ADMIN'], - }) + await loginWithCredentials(page, adminCredentials(), testInfo) + const displayName = await currentDisplayName(page) await page.goto('/settings/security') await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() await expect(page.getByLabel('Current Password')).toBeVisible() await expect(page.getByLabel('New Password')).toBeVisible() - await page.getByRole('button', { name: 'Local Admin' }).click() + await page.getByRole('button', { name: displayName }).click() await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible() }) - test('hides the security menu entry and form when password changes are unavailable', async ({ page }) => { + test('hides the security menu entry and rejects password changes without a local credential', async ({ page }) => { await setEnglishLocale(page) - await mockSession(page, { - userId: 'oauth-only-user', - displayName: 'OAuth Only User', - email: 'oauth-only@example.test', - avatarUrl: '', - oauthProvider: 'github', - canChangePassword: false, - platformRoles: ['USER'], + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-user', }) + const displayName = await currentDisplayName(page, { 'X-Mock-User-Id': 'local-user' }) await page.goto('/settings/security') @@ -96,7 +53,16 @@ test.describe('Security Settings capability', () => { await expect(page.getByLabel('Current Password')).toHaveCount(0) await expect(page.getByRole('button', { name: 'Update Password' })).toHaveCount(0) - await page.getByRole('button', { name: 'OAuth Only User' }).click() + await page.getByRole('button', { name: displayName }).click() await expect(page.getByRole('link', { name: 'Security Settings' })).toHaveCount(0) + + const response = await page.context().request.post('/api/v1/auth/local/change-password', { + data: { + currentPassword: 'Passw0rd!123', + newPassword: 'N3wPassw0rd!123', + }, + headers: await csrfHeaders(page, { 'X-Mock-User-Id': 'local-user' }), + }) + expect(response.status()).toBe(400) }) }) From 636f1edac23d8c457805739b51541e17d9e9fa9b Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 23 Jun 2026 13:54:33 +0800 Subject: [PATCH 62/78] docs(auth): align auth me example with #541 Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 7a7e46d6..b4fb0cd1 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -485,23 +485,20 @@ Session 中存储以下字段: "code": 0, "msg": "获取成功", "data": { - "userId": 42, + "userId": "usr_42", "displayName": "zhangsan", "email": "zhangsan@company.com", "avatarUrl": "https://...", - "oauthProvider": "github", - "platformRoles": ["SKILL_ADMIN", "AUDITOR"], - "namespaces": [ - { "slug": "ai-team", "role": "ADMIN" }, - { "slug": "global", "role": "MEMBER" } - ] + "oauthProvider": "local", + "canChangePassword": true, + "platformRoles": ["SKILL_ADMIN", "AUDITOR"] }, "timestamp": "2026-03-12T06:00:00Z", "requestId": "req-123" } ``` -前端权限判定基于 `platformRoles` + `namespaces[].role`,后端通过 `role_permission` 表查询权限码。 +前端平台级权限判定基于 `platformRoles`;是否展示修改密码入口和表单基于后端返回的 `canChangePassword`。后端通过 `role_permission` 表查询权限码。 统一约束: - `/api/v1/auth/me`、`/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。 From e501be9cf865f6750eab33382ddf7a044c290e41 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 23 Jun 2026 17:55:24 +0800 Subject: [PATCH 63/78] feat(promotion): improve promotion review dashboard Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/PromotionController.java | 14 +- .../skillhub/dto/PromotionResponseDto.java | 6 + .../JpaGovernanceQueryRepository.java | 6 + .../service/GovernanceWorkflowAppService.java | 9 +- .../service/PromotionPortalAppService.java | 87 ++++- .../src/main/resources/messages.properties | 4 + .../src/main/resources/messages_zh.properties | 4 + .../PromotionPortalControllerTest.java | 212 ++++++++++ .../PromotionApprovalFlowIntegrationTest.java | 85 ++++ .../PromotionPortalAppServiceTest.java | 6 + .../review/PromotionRequestRepository.java | 2 + .../jpa/PromotionRequestJpaRepository.java | 30 +- web/e2e/promotions-review.spec.ts | 365 ++++++++++++++++++ web/playwright.config.ts | 14 +- web/src/api/client.ts | 11 +- web/src/api/generated/schema.d.ts | 18 +- web/src/api/types.ts | 24 +- .../promotion/use-promotion-list.test.ts | 138 +++++-- .../features/promotion/use-promotion-list.ts | 27 +- web/src/i18n/locales/en.json | 18 +- web/src/i18n/locales/zh.json | 18 +- web/src/pages/dashboard/promotions.test.ts | 53 --- web/src/pages/dashboard/promotions.test.tsx | 225 +++++++++++ web/src/pages/dashboard/promotions.tsx | 295 +++++++++++--- 24 files changed, 1517 insertions(+), 154 deletions(-) create mode 100644 web/e2e/promotions-review.spec.ts delete mode 100644 web/src/pages/dashboard/promotions.test.ts create mode 100644 web/src/pages/dashboard/promotions.test.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index 1fb9ba5a..7b4b1a44 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -10,6 +10,8 @@ import com.iflytek.skillhub.dto.PromotionRequestDto; import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletRequest; import java.util.Map; import org.springframework.web.bind.annotation.GetMapping; @@ -79,11 +81,19 @@ public class PromotionController extends BaseApiController { } @GetMapping - public ApiResponse> listPromotions(@RequestParam(defaultValue = "PENDING") String status, + public ApiResponse> listPromotions(@Parameter(schema = @Schema(allowableValues = {"PENDING", "APPROVED", "REJECTED"}, defaultValue = "PENDING")) + @RequestParam(required = false) String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, + @Parameter(schema = @Schema(allowableValues = {"reviewedAt"})) + @RequestParam(required = false) String sortBy, + @Parameter(schema = @Schema(allowableValues = {"ASC", "DESC"}, defaultValue = "DESC")) + @RequestParam(required = false) String sortDirection, @RequestAttribute("userId") String userId) { - return ok("response.success.read", governanceWorkflowAppService.listPromotions(status, page, size, userId)); + return ok( + "response.success.read", + governanceWorkflowAppService.listPromotions(status, page, size, sortBy, sortDirection, userId) + ); } @GetMapping("/pending") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java index 888f447f..62c0d535 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java @@ -5,9 +5,15 @@ import java.time.Instant; public record PromotionResponseDto( Long id, Long sourceSkillId, + String sourceSkillDisplayName, + String sourceSkillSummary, String sourceNamespace, String sourceSkillSlug, String sourceVersion, + Integer sourceVersionFileCount, + Long sourceVersionTotalSize, + Long sourceSkillDownloadCount, + Integer sourceSkillStarCount, String targetNamespace, Long targetSkillId, String status, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index 7fb2aec1..646f032e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -200,9 +200,15 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { return new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(), + skill.getSummary(), sourceNamespace.getSlug(), skill.getSlug(), version.getVersion(), + version.getFileCount(), + version.getTotalSize(), + skill.getDownloadCount(), + skill.getStarCount(), targetNamespace.getSlug(), request.getTargetSkillId(), request.getStatus().name(), 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 6cca3952..9aac59d9 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 @@ -165,8 +165,13 @@ public class GovernanceWorkflowAppService { return promotionPortalAppService.rejectPromotion(promotionId, comment, userId, auditContext); } - public PageResponse listPromotions(String status, int page, int size, String userId) { - return promotionPortalAppService.listPromotions(status, page, size, userId); + public PageResponse listPromotions(String status, + int page, + int size, + String sortBy, + String sortDirection, + String userId) { + return promotionPortalAppService.listPromotions(status, page, size, sortBy, sortDirection, userId); } public PageResponse listPendingPromotions(int page, int size, String userId) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index ef4b1888..aab28382 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -7,17 +7,21 @@ import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import java.util.Locale; import java.util.Map; import java.util.Set; import org.slf4j.MDC; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; @Service @@ -98,10 +102,12 @@ public class PromotionPortalAppService { public PageResponse listPromotions(String status, int page, int size, + String sortBy, + String sortDirection, String userId) { requirePromotionAdmin(userId); - ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); - Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + ReviewTaskStatus reviewStatus = parsePromotionStatus(status); + Page requests = findPromotionRequests(reviewStatus, page, size, sortBy, sortDirection); return PageResponse.from(new PageImpl<>( governanceQueryRepository.getPromotionResponses(requests.getContent()), requests.getPageable(), @@ -112,7 +118,16 @@ public class PromotionPortalAppService { public PageResponse listPendingPromotions(int page, int size, String userId) { requirePromotionAdmin(userId); Page requests = promotionRequestRepository.findByStatus( - ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + ReviewTaskStatus.PENDING, + PageRequest.of( + page, + size, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ) + ); return PageResponse.from(new PageImpl<>( governanceQueryRepository.getPromotionResponses(requests.getContent()), requests.getPageable(), @@ -129,6 +144,72 @@ public class PromotionPortalAppService { return governanceQueryRepository.getPromotionResponse(promotion); } + private ReviewTaskStatus parsePromotionStatus(String status) { + if (status == null) { + return ReviewTaskStatus.PENDING; + } + if (status.isBlank()) { + throw new DomainBadRequestException("promotion.status.invalid", status); + } + try { + ReviewTaskStatus parsed = ReviewTaskStatus.valueOf(status.toUpperCase(Locale.ROOT)); + return switch (parsed) { + case PENDING, APPROVED, REJECTED -> parsed; + default -> throw new DomainBadRequestException("promotion.status.invalid", status); + }; + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("promotion.status.invalid", status); + } + } + + private Page findPromotionRequests(ReviewTaskStatus status, + int page, + int size, + String sortBy, + String sortDirection) { + if (status == ReviewTaskStatus.PENDING) { + if (sortBy != null || sortDirection != null) { + throw new DomainBadRequestException("promotion.sort.pending_unsupported"); + } + return promotionRequestRepository.findByStatus( + status, + PageRequest.of( + page, + size, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ) + ); + } + + if (sortBy != null && (sortBy.isBlank() || !"reviewedAt".equals(sortBy))) { + throw new DomainBadRequestException("promotion.sort.field.invalid", sortBy); + } + + Sort.Direction direction = parsePromotionSortDirection(sortDirection); + Pageable pageable = PageRequest.of(page, size); + if (direction == Sort.Direction.ASC) { + return promotionRequestRepository.findHistoryByStatusOrderByReviewedAtAsc(status, pageable); + } + return promotionRequestRepository.findHistoryByStatusOrderByReviewedAtDesc(status, pageable); + } + + private Sort.Direction parsePromotionSortDirection(String sortDirection) { + if (sortDirection == null) { + return Sort.Direction.DESC; + } + if (sortDirection.isBlank()) { + throw new DomainBadRequestException("promotion.sort.direction.invalid", sortDirection); + } + try { + return Sort.Direction.valueOf(sortDirection.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("promotion.sort.direction.invalid", sortDirection); + } + } + private void requirePromotionAdmin(String userId) { Set platformRoles = platformRoles(userId); if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8f7cf1df..25a63127 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -174,3 +174,7 @@ validation.auth.password.reset.code.notBlank=Verification code cannot be blank validation.auth.password.reset.code.invalid=Verification code must be 6 digits validation.auth.password.reset.newPassword.notBlank=New password cannot be blank promotion.target_skill_conflict=The target global skill "{0}" already exists +promotion.status.invalid=Unsupported promotion status: {0} +promotion.sort.field.invalid=Unsupported promotion sort field: {0} +promotion.sort.direction.invalid=Unsupported promotion sort direction: {0} +promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index e608d247..99183ae5 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -174,3 +174,7 @@ validation.auth.password.reset.code.notBlank=验证码不能为空 validation.auth.password.reset.code.invalid=验证码必须为 6 位数字 validation.auth.password.reset.newPassword.notBlank=新密码不能为空 promotion.target_skill_conflict=目标全局技能“{0}”已存在 +promotion.status.invalid=不支持的提升审核状态:{0} +promotion.sort.field.invalid=不支持的提升审核排序字段:{0} +promotion.sort.direction.invalid=不支持的提升审核排序方向:{0} +promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java index 9369b562..05aeecf0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java @@ -20,6 +20,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -108,6 +111,179 @@ class PromotionPortalControllerTest { verify(promotionRequestRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } + @Test + void listPromotions_defaultsToPendingWithStableSubmittedSort() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + PageRequest pageable = PageRequest.of( + 0, + 20, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ); + given(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/v1/promotions").with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].id").value(1L)) + .andExpect(jsonPath("$.data.total").value(1)); + + verify(promotionRequestRepository).findByStatus(ReviewTaskStatus.PENDING, pageable); + } + + @Test + void listPromotions_sortsApprovedHistoryByReviewedAtDescendingByDefault() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + PageRequest pageable = PageRequest.of(1, 5); + given(promotionRequestRepository.findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus.APPROVED, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("page", "1") + .param("size", "5") + .param("sortBy", "reviewedAt") + .with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(promotionRequestRepository).findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus.APPROVED, pageable); + } + + @Test + void listPromotions_sortsRejectedHistoryByReviewedAtAscending() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SUPER_ADMIN")); + PageRequest pageable = PageRequest.of(0, 10); + given(promotionRequestRepository.findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus.REJECTED, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "REJECTED") + .param("page", "0") + .param("size", "10") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(promotionRequestRepository).findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus.REJECTED, pageable); + } + + @Test + void listPromotions_rejectsInvalidStatus() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "DONE") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("DONE"))); + } + + @Test + void listPromotions_rejectsBlankStatus() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsPendingSortFieldEvenWhenBlank() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "PENDING") + .param("sortBy", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsPendingSortDirectionEvenWhenBlank() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "PENDING") + .param("sortDirection", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsInvalidHistorySortField() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "submittedAt") + .param("sortDirection", "DESC") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("submittedAt"))); + } + + @Test + void listPromotions_rejectsInvalidHistorySortDirection() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "reviewedAt") + .param("sortDirection", "SIDEWAYS") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("SIDEWAYS"))); + } + + @Test + void listPromotions_rejectsBlankHistorySortDirection() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "reviewedAt") + .param("sortDirection", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + @Test void getPromotionDetail_allowsSubmitter() throws Exception { PromotionRequest request = createPromotionRequest(1L, "user-1"); @@ -140,9 +316,15 @@ class PromotionPortalControllerTest { given(governanceQueryRepository.getPromotionResponse(request)).willReturn(new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + "Skill A", + "Skill A summary", "team-a", "skill-a", "1.0.0", + 3, + 2048L, + 7L, + 2, "global", request.getTargetSkillId(), request.getStatus().name(), @@ -156,6 +338,36 @@ class PromotionPortalControllerTest { )); } + private void stubPromotionListResponse(List requests) { + given(governanceQueryRepository.getPromotionResponses(requests)).willReturn( + requests.stream() + .map(request -> new PromotionResponseDto( + request.getId(), + request.getSourceSkillId(), + "Skill A", + "Skill A summary", + "team-a", + "skill-a", + "1.0.0", + 3, + 2048L, + 7L, + 2, + "global", + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), + "Submitter", + request.getReviewedBy(), + null, + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() + )) + .toList() + ); + } + private void stubNamespaceRoles(String userId, List members) { given(namespaceMemberRepository.findByUserId(userId)).willReturn(members); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java index a2268a67..aaa74655 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java @@ -36,6 +36,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -44,6 +45,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -106,6 +108,12 @@ class PromotionApprovalFlowIntegrationTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").value(graph.request().getId())) + .andExpect(jsonPath("$.data.sourceSkillDisplayName").value(org.hamcrest.Matchers.startsWith("Promote Skill"))) + .andExpect(jsonPath("$.data.sourceSkillSummary").value("Used to verify promotion approval flow.")) + .andExpect(jsonPath("$.data.sourceVersionFileCount").value(0)) + .andExpect(jsonPath("$.data.sourceVersionTotalSize").value(0)) + .andExpect(jsonPath("$.data.sourceSkillDownloadCount").value(0)) + .andExpect(jsonPath("$.data.sourceSkillStarCount").value(0)) .andExpect(jsonPath("$.data.status").value("APPROVED")) .andExpect(jsonPath("$.data.reviewedBy").value(REVIEWER_ID)) .andExpect(jsonPath("$.data.reviewComment").value("ship it")); @@ -187,6 +195,75 @@ class PromotionApprovalFlowIntegrationTest { assertThat(savedRequest.getTargetSkillId()).isNull(); } + @Test + @Transactional + void listPromotions_sortsApprovedAndRejectedHistoryByReviewedAtWithNullsLastAndTieBreaker() throws Exception { + when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SUPER_ADMIN")); + + assertHistorySortForStatus(ReviewTaskStatus.APPROVED, "APPROVED"); + assertHistorySortForStatus(ReviewTaskStatus.REJECTED, "REJECTED"); + } + + private void assertHistorySortForStatus(ReviewTaskStatus reviewStatus, String statusParam) throws Exception { + promotionRequestRepository.deleteAll(); + promotionRequestRepository.flush(); + + PromotionGraph latest = createPromotionGraph(); + PromotionGraph sameTimeOlderId = createPromotionGraph(); + PromotionGraph sameTimeNewerId = createPromotionGraph(); + PromotionGraph legacyNullReviewedAt = createPromotionGraph(); + + Instant sameReviewedAt = Instant.parse("2026-06-18T08:00:00Z"); + markPromotionHistory(latest.request(), reviewStatus, Instant.parse("2026-06-18T09:00:00Z")); + markPromotionHistory(sameTimeOlderId.request(), reviewStatus, sameReviewedAt); + markPromotionHistory(sameTimeNewerId.request(), reviewStatus, sameReviewedAt); + markPromotionHistory(legacyNullReviewedAt.request(), reviewStatus, null); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "0") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "DESC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(latest.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(sameTimeNewerId.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "1") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "DESC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(sameTimeOlderId.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(legacyNullReviewedAt.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "0") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(sameTimeOlderId.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(sameTimeNewerId.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "1") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(latest.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(legacyNullReviewedAt.request().getId())); + } + private PromotionGraph createPromotionGraph() { return createPromotionGraph(SUBMITTER_ID); } @@ -236,6 +313,14 @@ class PromotionApprovalFlowIntegrationTest { } } + private void markPromotionHistory(PromotionRequest request, ReviewTaskStatus status, Instant reviewedAt) { + request.setStatus(status); + request.setReviewedBy(REVIEWER_ID); + request.setReviewComment(status == ReviewTaskStatus.APPROVED ? "approved" : "rejected"); + request.setReviewedAt(reviewedAt); + promotionRequestRepository.saveAndFlush(request); + } + private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) { PlatformPrincipal principal = new PlatformPrincipal( userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java index abede8fc..bc824b89 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -136,9 +136,15 @@ class PromotionPortalAppServiceTest { return new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + "Skill A", + "Skill A summary", "team-a", "skill-a", "1.0.0", + 3, + 2048L, + 7L, + 2, "global", request.getTargetSkillId(), request.getStatus().name(), diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 05d07f04..8bfdf245 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -14,6 +14,8 @@ public interface PromotionRequestRepository { Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); + Page findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus status, Pageable pageable); + Page findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus status, Pageable pageable); boolean existsByTargetNamespaceId(Long namespaceId); void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java index c26f611e..93e61a9f 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,7 +11,6 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.Optional; /** * JPA-backed repository for promotion requests, including optimistic status updates. @@ -25,6 +25,34 @@ public interface PromotionRequestJpaRepository extends JpaRepository findByStatus(ReviewTaskStatus status, Pageable pageable); + @Query( + value = """ + SELECT p + FROM PromotionRequest p + WHERE p.status = :status + ORDER BY CASE WHEN p.reviewedAt IS NULL THEN 1 ELSE 0 END ASC, + p.reviewedAt ASC, + p.id ASC + """, + countQuery = "SELECT COUNT(p) FROM PromotionRequest p WHERE p.status = :status" + ) + Page findHistoryByStatusOrderByReviewedAtAsc(@Param("status") ReviewTaskStatus status, + Pageable pageable); + + @Query( + value = """ + SELECT p + FROM PromotionRequest p + WHERE p.status = :status + ORDER BY CASE WHEN p.reviewedAt IS NULL THEN 1 ELSE 0 END ASC, + p.reviewedAt DESC, + p.id DESC + """, + countQuery = "SELECT COUNT(p) FROM PromotionRequest p WHERE p.status = :status" + ) + Page findHistoryByStatusOrderByReviewedAtDesc(@Param("status") ReviewTaskStatus status, + Pageable pageable); + boolean existsByTargetNamespaceId(Long targetNamespaceId); void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId); diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts new file mode 100644 index 00000000..af7d98a2 --- /dev/null +++ b/web/e2e/promotions-review.spec.ts @@ -0,0 +1,365 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +type PromotionStatus = 'PENDING' | 'APPROVED' | 'REJECTED' + +function promotion(id: number, status: PromotionStatus, name: string, reviewedAt: string | null = null) { + return { + id, + sourceSkillId: id + 100, + sourceSkillDisplayName: name, + sourceSkillSummary: `Summary for ${name}`, + sourceNamespace: 'team-ai', + sourceSkillSlug: name.toLowerCase().replaceAll(' ', '-'), + sourceVersion: '1.3.0', + sourceVersionFileCount: 23, + sourceVersionTotalSize: 1_843_200, + sourceSkillDownloadCount: 18, + sourceSkillStarCount: 5, + targetNamespace: 'global', + targetSkillId: status === 'PENDING' ? undefined : id + 200, + status, + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: status === 'PENDING' ? undefined : 'admin-1', + reviewedByName: status === 'PENDING' ? undefined : 'Admin One', + reviewComment: status === 'REJECTED' ? 'Needs clearer documentation before promotion.' : 'Looks good.', + submittedAt: '2026-06-18T12:00:00Z', + reviewedAt, + } +} + +test.describe('Promotion review dashboard', () => { + let unexpectedPromotionRequests: string[] + let expectedPromotionRequests: string[] + + test.beforeEach(async ({ page }) => { + unexpectedPromotionRequests = [] + expectedPromotionRequests = [] + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { + userId: 'local-admin', + displayName: 'Local Admin', + email: 'local-admin@example.com', + avatarUrl: '', + oauthProvider: 'mock', + platformRoles: ['SUPER_ADMIN'], + }, + timestamp: new Date().toISOString(), + requestId: 'e2e-auth', + }), + }) + }) + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { count: 0 }, + timestamp: new Date().toISOString(), + requestId: 'e2e-notifications', + }), + }) + }) + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: [], + timestamp: new Date().toISOString(), + requestId: 'e2e-namespaces', + }), + }) + }) + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: '', + }) + }) + }) + + async function installPromotionRouteMock(page: Page, expectedSignatures: string[]) { + expectedPromotionRequests = [...expectedSignatures] + + await page.route('**/api/web/promotions**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + const allowedParams = new Set(['status', 'page', 'size', 'sortBy', 'sortDirection']) + const extraParams = Array.from(url.searchParams.keys()).filter((key) => !allowedParams.has(key)) + if (request.method() !== 'GET' || url.pathname !== '/api/web/promotions' || extraParams.length > 0) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'unexpected promotion request shape', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const statusParam = url.searchParams.get('status') + if (statusParam === null) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'promotion request must include explicit status', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const statusValues: PromotionStatus[] = ['PENDING', 'APPROVED', 'REJECTED'] + if (!statusValues.includes(statusParam as PromotionStatus)) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: `unexpected status ${statusParam}`, + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const status = statusParam as PromotionStatus + const sortBy = url.searchParams.get('sortBy') + const sortDirectionParam = url.searchParams.get('sortDirection') + const requestSignature = `${status}|${sortBy ?? 'none'}|${sortDirectionParam ?? 'none'}` + const expectedSignature = expectedPromotionRequests.shift() + if (requestSignature !== expectedSignature) { + unexpectedPromotionRequests.push(`${url.toString()} expected ${expectedSignature ?? 'no more requests'}`) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'unexpected promotion request order', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + if (status === 'PENDING' && (sortBy !== null || sortDirectionParam !== null)) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'pending request must not include history sort params', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + if (status !== 'PENDING' && (sortBy !== 'reviewedAt' || !['ASC', 'DESC'].includes(sortDirectionParam ?? ''))) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'history request must include reviewedAt sort params', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const dataByStatus: Record[]> = { + PENDING: [promotion(1, 'PENDING', 'Knowledge Helper')], + APPROVED: [ + promotion(2, 'APPROVED', 'Newest Approved', '2026-06-18T09:00:00Z'), + promotion(3, 'APPROVED', 'Oldest Approved', '2026-06-17T09:00:00Z'), + ], + REJECTED: [ + promotion(4, 'REJECTED', 'Newest Rejected', '2026-06-18T08:00:00Z'), + promotion(5, 'REJECTED', 'Oldest Rejected', '2026-06-16T08:00:00Z'), + ], + } + + const items = [...dataByStatus[status]] + if (status !== 'PENDING' && sortDirectionParam === 'ASC') { + items.reverse() + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { items, total: items.length, page: 0, size: 20 }, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions', + }), + }) + }) + } + + function expectPromotionRequestsSatisfied() { + expect(unexpectedPromotionRequests).toEqual([]) + expect(expectedPromotionRequests).toEqual([]) + } + + test('shows enhanced pending cards and sorts approved/rejected history by reviewed time', async ({ page }) => { + await installPromotionRouteMock(page, [ + 'PENDING|none|none', + 'APPROVED|reviewedAt|DESC', + 'APPROVED|reviewedAt|ASC', + 'REJECTED|reviewedAt|DESC', + 'REJECTED|reviewedAt|ASC', + ]) + + const pendingRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'PENDING' + && !url.searchParams.has('sortBy') + && !url.searchParams.has('sortDirection') + }) + + await page.goto('/dashboard/promotions') + await pendingRequest + + await expect(page.getByRole('heading', { name: 'Promotion Review' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Knowledge Helper' })).toBeVisible() + await expect(page.getByText('@team-ai/knowledge-helper -> @global')).toBeVisible() + await expect(page.getByText('Summary for Knowledge Helper')).toBeVisible() + await expect(page.getByText(/Jun 18, 2026/)).toBeVisible() + await expect(page.getByText('v1.3.0')).toBeVisible() + await expect(page.getByText('Submitter Owner One')).toBeVisible() + await expect(page.getByText('23 files')).toBeVisible() + await expect(page.getByText('1.8 MB')).toBeVisible() + await expect(page.getByText('18 downloads')).toBeVisible() + await expect(page.getByText('5 stars')).toBeVisible() + + const approvedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Approved' }).click() + await approvedDescRequest + const approvedTable = page.getByRole('table', { name: 'Promotion history' }) + await expect(approvedTable).toBeVisible() + await expect(approvedTable.getByRole('row').nth(1)).toContainText('Newest Approved') + await expect(approvedTable.getByRole('row').nth(2)).toContainText('Oldest Approved') + + const approvedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).click() + await approvedAscRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + await expect(approvedTable.getByRole('row').nth(1)).toContainText('Oldest Approved') + await expect(approvedTable.getByRole('row').nth(2)).toContainText('Newest Approved') + + const rejectedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'REJECTED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Rejected' }).click() + await rejectedDescRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time ascending' })).toBeVisible() + + const rejectedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'REJECTED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).click() + await rejectedAscRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + + await page.getByRole('tab', { name: 'Approved' }).click() + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + expectPromotionRequestsSatisfied() + }) + + test('sorter can be toggled from the keyboard', async ({ page }) => { + await installPromotionRouteMock(page, [ + 'PENDING|none|none', + 'APPROVED|reviewedAt|DESC', + 'APPROVED|reviewedAt|ASC', + ]) + + await page.goto('/dashboard/promotions') + + const approvedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Approved' }).click() + await approvedDescRequest + + const approvedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).focus() + await page.keyboard.press('Enter') + await approvedAscRequest + + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + expectPromotionRequestsSatisfied() + }) +}) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 00b74867..36a60ebf 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,5 +1,15 @@ import { defineConfig, devices } from '@playwright/test' +const localNoProxyHosts = ['localhost', '127.0.0.1', '::1'] +const mergedNoProxy = Array.from(new Set([ + ...(process.env.NO_PROXY?.split(',').filter(Boolean) ?? []), + ...(process.env.no_proxy?.split(',').filter(Boolean) ?? []), + ...localNoProxyHosts, +])).join(',') + +process.env.NO_PROXY = mergedNoProxy +process.env.no_proxy = mergedNoProxy + export default defineConfig({ testDir: './e2e', fullyParallel: false, @@ -9,7 +19,7 @@ export default defineConfig({ workers: Number(process.env.PLAYWRIGHT_WORKERS ?? 1), reporter: 'html', use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://127.0.0.1:3000', trace: 'on-first-retry', screenshot: 'on', }, @@ -21,7 +31,7 @@ export default defineConfig({ ], webServer: { command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort', - url: 'http://localhost:3000', + url: 'http://127.0.0.1:3000', reuseExistingServer: true, timeout: 120000, }, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 16d2e7fe..d701fd11 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,6 +15,9 @@ import type { MergeVerifyRequest, ReviewSkillDetail, ReviewTask, + PromotionSortBy, + PromotionSortDirection, + PromotionStatus, PromotionTask, AuditLogItem, SkillSummary, @@ -899,11 +902,17 @@ export const promotionApi = { }) }, - async list(params: { status?: string; page?: number; size?: number }) { + async list(params: { status?: PromotionStatus; page?: number; size?: number; sortBy?: PromotionSortBy; sortDirection?: PromotionSortDirection }) { const searchParams = new URLSearchParams() searchParams.set('status', params.status ?? 'PENDING') searchParams.set('page', String(params.page ?? 0)) searchParams.set('size', String(params.size ?? 20)) + if (params.sortBy) { + searchParams.set('sortBy', params.sortBy) + } + if (params.sortDirection) { + searchParams.set('sortDirection', params.sortDirection) + } return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>( `${WEB_API_PREFIX}/promotions?${searchParams.toString()}`, ) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a141fb20..fe5dc5a1 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3692,9 +3692,19 @@ export interface components { id?: number; /** Format: int64 */ sourceSkillId?: number; + sourceSkillDisplayName?: string; + sourceSkillSummary?: string; sourceNamespace?: string; sourceSkillSlug?: string; sourceVersion?: string; + /** Format: int32 */ + sourceVersionFileCount?: number; + /** Format: int64 */ + sourceVersionTotalSize?: number; + /** Format: int64 */ + sourceSkillDownloadCount?: number; + /** Format: int32 */ + sourceSkillStarCount?: number; targetNamespace?: string; /** Format: int64 */ targetSkillId?: number; @@ -6933,9 +6943,11 @@ export interface operations { listPromotions: { parameters: { query?: { - status?: string; + status?: "PENDING" | "APPROVED" | "REJECTED"; page?: number; size?: number; + sortBy?: "reviewedAt"; + sortDirection?: "ASC" | "DESC"; }; header?: never; path?: never; @@ -6981,9 +6993,11 @@ export interface operations { listPromotions_1: { parameters: { query?: { - status?: string; + status?: "PENDING" | "APPROVED" | "REJECTED"; page?: number; size?: number; + sortBy?: "reviewedAt"; + sortDirection?: "ASC" | "DESC"; }; header?: never; path?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41..60bef288 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -385,22 +385,32 @@ export interface ReviewSkillDetail { activeVersion: string } +export type PromotionStatus = 'PENDING' | 'APPROVED' | 'REJECTED' +export type PromotionSortDirection = 'ASC' | 'DESC' +export type PromotionSortBy = 'reviewedAt' + export interface PromotionTask { id: number sourceSkillId: number + sourceSkillDisplayName: string + sourceSkillSummary?: string | null sourceNamespace: string sourceSkillSlug: string sourceVersion: string + sourceVersionFileCount: number + sourceVersionTotalSize: number + sourceSkillDownloadCount: number + sourceSkillStarCount: number targetNamespace: string - targetSkillId?: number - status: 'PENDING' | 'APPROVED' | 'REJECTED' + targetSkillId?: number | null + status: PromotionStatus submittedBy: string - submittedByName?: string - reviewedBy?: string - reviewedByName?: string - reviewComment?: string + submittedByName?: string | null + reviewedBy?: string | null + reviewedByName?: string | null + reviewComment?: string | null submittedAt: string - reviewedAt?: string + reviewedAt?: string | null } export interface SkillReport { diff --git a/web/src/features/promotion/use-promotion-list.test.ts b/web/src/features/promotion/use-promotion-list.test.ts index 638658f1..580713f1 100644 --- a/web/src/features/promotion/use-promotion-list.test.ts +++ b/web/src/features/promotion/use-promotion-list.test.ts @@ -1,35 +1,121 @@ -import { describe, expect, it } from 'vitest' -import * as mod from './use-promotion-list' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PromotionTask } from '@/api/types' -/** - * use-promotion-list.ts exports four hooks (usePromotionList, - * usePromotionDetail, useApprovePromotion, useRejectPromotion) and - * re-exports the PromotionTask type. All hooks are thin wrappers around - * useQuery/useMutation with no exported pure helpers, query-key functions, - * or data transformations beyond unwrapping the backend page object - * (which cannot be tested without an API client mock). - * - * We verify the export contract so downstream consumers break fast if - * the module shape changes. - */ -describe('use-promotion-list module exports', () => { - it('exports usePromotionList as a function', () => { - expect(mod.usePromotionList).toBeDefined() - expect(typeof mod.usePromotionList).toBe('function') +const mocks = vi.hoisted(() => ({ + invalidateQueries: vi.fn(), + useMutation: vi.fn(), + useQuery: vi.fn(), + promotionList: vi.fn(), + promotionGet: vi.fn(), + promotionApprove: vi.fn(), + promotionReject: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: mocks.useMutation, + useQuery: (options: unknown) => mocks.useQuery(options), + useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), +})) + +vi.mock('@/api/client', () => ({ + promotionApi: { + list: (...args: unknown[]) => mocks.promotionList(...args), + get: (...args: unknown[]) => mocks.promotionGet(...args), + approve: (...args: unknown[]) => mocks.promotionApprove(...args), + reject: (...args: unknown[]) => mocks.promotionReject(...args), + }, +})) + +import { usePromotionList } from './use-promotion-list' + +const promotion = { + id: 1, + sourceSkillId: 10, + sourceSkillDisplayName: 'Code Review Bot', + sourceSkillSummary: 'Reviews code changes.', + sourceNamespace: 'team-ai', + sourceSkillSlug: 'code-review-bot', + sourceVersion: '1.0.0', + sourceVersionFileCount: 3, + sourceVersionTotalSize: 2048, + sourceSkillDownloadCount: 7, + sourceSkillStarCount: 2, + targetNamespace: 'global', + targetSkillId: null, + status: 'PENDING', + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: null, + reviewedByName: null, + reviewComment: null, + submittedAt: '2026-06-18T01:00:00Z', + reviewedAt: null, +} satisfies PromotionTask + +describe('usePromotionList', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useQuery.mockImplementation((options: unknown) => options) + mocks.promotionList.mockResolvedValue({ items: [promotion], total: 1, page: 0, size: 20 }) }) - it('exports usePromotionDetail as a function', () => { - expect(mod.usePromotionDetail).toBeDefined() - expect(typeof mod.usePromotionDetail).toBe('function') + it('defaults to the pending queue without history sort params', async () => { + usePromotionList() + const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise } + + expect(options.queryKey).toEqual(['promotions', { + status: 'PENDING', + page: 0, + size: 20, + sortBy: undefined, + sortDirection: undefined, + }]) + await expect(options.queryFn()).resolves.toEqual([promotion]) + expect(mocks.promotionList).toHaveBeenCalledWith({ + status: 'PENDING', + page: 0, + size: 20, + sortBy: undefined, + sortDirection: undefined, + }) }) - it('exports useApprovePromotion as a function', () => { - expect(mod.useApprovePromotion).toBeDefined() - expect(typeof mod.useApprovePromotion).toBe('function') + it('passes reviewed-time sort params for history queues', async () => { + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'ASC' }) + const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise } + + expect(options.queryKey).toEqual(['promotions', { + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'ASC', + }]) + await options.queryFn() + expect(mocks.promotionList).toHaveBeenCalledWith({ + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'ASC', + }) }) - it('exports useRejectPromotion as a function', () => { - expect(mod.useRejectPromotion).toBeDefined() - expect(typeof mod.useRejectPromotion).toBe('function') + it('uses different query keys for opposite history sort directions', () => { + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'ASC' }) + const ascKey = mocks.useQuery.mock.calls[0]?.[0].queryKey + + mocks.useQuery.mockClear() + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'DESC' }) + const descKey = mocks.useQuery.mock.calls[0]?.[0].queryKey + + expect(ascKey).not.toEqual(descKey) + expect(descKey).toEqual(['promotions', { + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'DESC', + }]) }) }) diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts index 74d88513..db0b1a14 100644 --- a/web/src/features/promotion/use-promotion-list.ts +++ b/web/src/features/promotion/use-promotion-list.ts @@ -1,18 +1,35 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { promotionApi } from '@/api/client' -import type { PromotionTask } from '@/api/types' +import type { PromotionSortBy, PromotionSortDirection, PromotionStatus, PromotionTask } from '@/api/types' + +export interface PromotionListParams { + status?: PromotionStatus + page?: number + size?: number + sortBy?: PromotionSortBy + sortDirection?: PromotionSortDirection +} /** * Returns the promotion queue for a given status. The hook unwraps the backend * page object because promotion screens currently consume the item list only. */ -export function usePromotionList(status = 'PENDING') { +export function usePromotionList(params: PromotionListParams = { status: 'PENDING' }) { + const normalizedParams = { + status: params.status ?? 'PENDING', + page: params.page ?? 0, + size: params.size ?? 20, + sortBy: params.sortBy, + sortDirection: params.sortDirection, + } + return useQuery({ - queryKey: ['promotions', status], + queryKey: ['promotions', normalizedParams], queryFn: async () => { - const page = await promotionApi.list({ status }) + const page = await promotionApi.list(normalizedParams) return page.items }, + staleTime: 30_000, }) } @@ -56,4 +73,4 @@ export function useRejectPromotion() { }) } -export type { PromotionTask } +export type { PromotionSortDirection, PromotionStatus, PromotionTask } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 56039107..b060534a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -554,7 +554,23 @@ "commentPlaceholder": "Review comment (optional)", "approve": "Approve", "reject": "Reject", - "empty": "No promotion requests" + "empty": "No promotion requests", + "historyTableLabel": "Promotion history", + "colSkill": "Skill", + "colVersion": "Version", + "colSubmitter": "Submitter", + "colReviewer": "Reviewer", + "colReviewedAt": "Reviewed At", + "colReviewComment": "Review Comment", + "sortReviewedTimeAsc": "Sort by reviewed time ascending", + "sortReviewedTimeDesc": "Sort by reviewed time descending", + "emptyValue": "-", + "versionTag": "v{{version}}", + "submitterTag": "Submitter {{user}}", + "fileCountTag": "{{count}} files", + "packageSizeTag": "{{size}}", + "downloadCountTag": "{{value}} downloads", + "starCountTag": "{{value}} stars" }, "adminUsers": { "title": "User Management", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 1920b158..d0d7c23a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -554,7 +554,23 @@ "commentPlaceholder": "审核意见(可选)", "approve": "通过", "reject": "拒绝", - "empty": "暂无提升申请" + "empty": "暂无提升申请", + "historyTableLabel": "提升审核历史", + "colSkill": "技能", + "colVersion": "版本", + "colSubmitter": "提交人", + "colReviewer": "审核人", + "colReviewedAt": "处理时间", + "colReviewComment": "审核意见", + "sortReviewedTimeAsc": "按处理时间正序排序", + "sortReviewedTimeDesc": "按处理时间倒序排序", + "emptyValue": "-", + "versionTag": "v{{version}}", + "submitterTag": "提交人 {{user}}", + "fileCountTag": "{{count}} 个文件", + "packageSizeTag": "{{size}}", + "downloadCountTag": "{{value}} 次下载", + "starCountTag": "{{value}} 个星标" }, "adminUsers": { "title": "用户管理", diff --git a/web/src/pages/dashboard/promotions.test.ts b/web/src/pages/dashboard/promotions.test.ts deleted file mode 100644 index 827cd686..00000000 --- a/web/src/pages/dashboard/promotions.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('react-i18next', async () => { - const actual = await vi.importActual('react-i18next') - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => key, - i18n: { language: 'en' }, - }), - } -}) - -vi.mock('@/features/promotion/use-promotion-list', () => ({ - useApprovePromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), - usePromotionList: () => ({ data: [], isLoading: false }), - useRejectPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), -})) - -vi.mock('@/shared/lib/date-time', () => ({ - formatLocalDateTime: (v: string) => v, -})) - -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/card', () => ({ - Card: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/input', () => ({ - Input: () => null, -})) - -vi.mock('@/shared/ui/tabs', () => ({ - Tabs: ({ children }: { children: unknown }) => children, - TabsContent: ({ children }: { children: unknown }) => children, - TabsList: ({ children }: { children: unknown }) => children, - TabsTrigger: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/components/dashboard-page-header', () => ({ - DashboardPageHeader: () => null, -})) - -import { PromotionsPage } from './promotions' - -describe('PromotionsPage', () => { - it('exports a named component function', () => { - expect(typeof PromotionsPage).toBe('function') - }) -}) diff --git a/web/src/pages/dashboard/promotions.test.tsx b/web/src/pages/dashboard/promotions.test.tsx new file mode 100644 index 00000000..a04bc219 --- /dev/null +++ b/web/src/pages/dashboard/promotions.test.tsx @@ -0,0 +1,225 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PromotionStatus, PromotionTask } from '@/api/types' + +const mocks = vi.hoisted(() => ({ + approveMutate: vi.fn(), + rejectMutate: vi.fn(), + usePromotionList: vi.fn(), + translations: { + 'promotions.approve': 'Approve', + 'promotions.colReviewComment': 'Review Comment', + 'promotions.colReviewedAt': 'Reviewed At', + 'promotions.colReviewer': 'Reviewer', + 'promotions.colSkill': 'Skill', + 'promotions.colSubmitter': 'Submitter', + 'promotions.colVersion': 'Version', + 'promotions.commentPlaceholder': 'Review comment (optional)', + 'promotions.downloadCountTag': '{{value}} downloads', + 'promotions.empty': 'No promotion requests', + 'promotions.emptyValue': '-', + 'promotions.fileCountTag': '{{count}} files', + 'promotions.historyTableLabel': 'Promotion history', + 'promotions.packageSizeTag': '{{size}}', + 'promotions.reject': 'Reject', + 'promotions.sortReviewedTimeAsc': 'Sort by reviewed time ascending', + 'promotions.sortReviewedTimeDesc': 'Sort by reviewed time descending', + 'promotions.starCountTag': '{{value}} stars', + 'promotions.submitterTag': 'Submitter {{user}}', + 'promotions.subtitle': 'Review promotion requests', + 'promotions.tabApproved': 'Approved', + 'promotions.tabPending': 'Pending', + 'promotions.tabRejected': 'Rejected', + 'promotions.title': 'Promotion Review', + 'promotions.versionTag': 'v{{version}}', + } as Record, +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + i18n: { language: 'en' }, + t: (key: string, values?: Record) => { + const template = mocks.translations[key] ?? key + return Object.entries(values ?? {}).reduce( + (result, [name, value]) => result.split(`{{${name}}}`).join(String(value)), + template, + ) + }, + }), + } +}) + +vi.mock('@/features/promotion/use-promotion-list', () => ({ + useApprovePromotion: () => ({ mutate: mocks.approveMutate, isPending: false }), + usePromotionList: (params: unknown) => mocks.usePromotionList(params), + useRejectPromotion: () => ({ mutate: mocks.rejectMutate, isPending: false }), +})) + +vi.mock('@/shared/components/dashboard-page-header', () => ({ + DashboardPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) => ( +
+

{title}

+

{subtitle}

+
+ ), +})) + +import { PromotionsPage } from './promotions' + +function createPromotion(overrides: Partial = {}): PromotionTask { + return { + id: 1, + sourceSkillId: 101, + sourceSkillDisplayName: 'Knowledge Helper', + sourceSkillSummary: 'Summary for Knowledge Helper', + sourceNamespace: 'team-ai', + sourceSkillSlug: 'knowledge-helper', + sourceVersion: '1.3.0', + sourceVersionFileCount: 23, + sourceVersionTotalSize: 1_843_200, + sourceSkillDownloadCount: 18, + sourceSkillStarCount: 5, + targetNamespace: 'global', + targetSkillId: null, + status: 'PENDING', + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: null, + reviewedByName: null, + reviewComment: null, + submittedAt: '2026-06-18T12:00:00Z', + reviewedAt: null, + ...overrides, + } +} + +function installPromotionListMock(overrides: { + pending?: PromotionTask[] + approvedDesc?: PromotionTask[] + approvedAsc?: PromotionTask[] + rejectedDesc?: PromotionTask[] + rejectedAsc?: PromotionTask[] +} = {}) { + const pending = overrides.pending ?? [createPromotion()] + const approvedDesc = overrides.approvedDesc ?? [ + createPromotion({ + id: 2, + status: 'APPROVED', + sourceSkillDisplayName: 'Newest Approved', + sourceSkillSlug: 'newest-approved', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Looks good.', + reviewedAt: '2026-06-18T09:00:00Z', + }), + createPromotion({ + id: 3, + status: 'APPROVED', + sourceSkillDisplayName: 'Oldest Approved', + sourceSkillSlug: 'oldest-approved', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Approved after review.', + reviewedAt: '2026-06-17T09:00:00Z', + }), + ] + const rejectedDesc = overrides.rejectedDesc ?? [ + createPromotion({ + id: 4, + status: 'REJECTED', + sourceSkillDisplayName: 'Newest Rejected', + sourceSkillSlug: 'newest-rejected', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Needs clearer docs before promotion.', + reviewedAt: '2026-06-18T08:00:00Z', + }), + createPromotion({ + id: 5, + status: 'REJECTED', + sourceSkillDisplayName: 'Oldest Rejected', + sourceSkillSlug: 'oldest-rejected', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: null, + reviewedAt: '2026-06-16T08:00:00Z', + }), + ] + const approvedAsc = overrides.approvedAsc ?? [...approvedDesc].reverse() + const rejectedAsc = overrides.rejectedAsc ?? [...rejectedDesc].reverse() + + mocks.usePromotionList.mockImplementation((params: { status?: PromotionStatus; sortDirection?: 'ASC' | 'DESC' } = {}) => { + if (params.status === 'APPROVED') { + return { data: params.sortDirection === 'ASC' ? approvedAsc : approvedDesc, isLoading: false } + } + if (params.status === 'REJECTED') { + return { data: params.sortDirection === 'ASC' ? rejectedAsc : rejectedDesc, isLoading: false } + } + return { data: pending, isLoading: false } + }) +} + +describe('PromotionsPage', () => { + beforeEach(() => { + vi.clearAllMocks() + installPromotionListMock() + }) + + afterEach(() => cleanup()) + + it('renders enhanced pending card review context', () => { + render() + + expect(screen.getByRole('heading', { name: 'Promotion Review' })).toBeTruthy() + expect(screen.getByText('Knowledge Helper')).toBeTruthy() + expect(screen.getByText('@team-ai/knowledge-helper -> @global')).toBeTruthy() + expect(screen.getByText('Summary for Knowledge Helper')).toBeTruthy() + expect(screen.getByText('v1.3.0')).toBeTruthy() + expect(screen.getByText('Submitter Owner One')).toBeTruthy() + expect(screen.getByText('23 files')).toBeTruthy() + expect(screen.getByText('1.8 MB')).toBeTruthy() + expect(screen.getByText('18 downloads')).toBeTruthy() + expect(screen.getByText('5 stars')).toBeTruthy() + }) + + it('renders approved history as a sortable table', () => { + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + const table = screen.getByRole('table', { name: 'Promotion history' }) + let rows = within(table).getAllByRole('row') + expect(rows[1]?.textContent).toContain('Newest Approved') + expect(rows[2]?.textContent).toContain('Oldest Approved') + + const ascendingButton = screen.getByRole('button', { name: 'Sort by reviewed time ascending' }) + expect(ascendingButton.closest('th')?.getAttribute('aria-sort')).toBe('descending') + expect(ascendingButton.querySelector('[aria-hidden="true"]')).toBeTruthy() + + fireEvent.click(ascendingButton) + rows = within(screen.getByRole('table', { name: 'Promotion history' })).getAllByRole('row') + expect(rows[1]?.textContent).toContain('Oldest Approved') + expect(rows[2]?.textContent).toContain('Newest Approved') + const descendingButton = screen.getByRole('button', { name: 'Sort by reviewed time descending' }) + expect(descendingButton.closest('th')?.getAttribute('aria-sort')).toBe('ascending') + }) + + it('keeps approved and rejected sort state independent', () => { + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + fireEvent.click(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: 'Rejected' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + }) +}) diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx index a342443e..7ebcf901 100644 --- a/web/src/pages/dashboard/promotions.tsx +++ b/web/src/pages/dashboard/promotions.tsx @@ -1,20 +1,131 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { formatLocalDateTime } from '@/shared/lib/date-time' +import { formatCompactCount } from '@/shared/lib/number-format' +import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/shared/ui/table' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' -import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import type { PromotionTask } from '@/api/types' +import type { PromotionSortDirection, PromotionStatus } from '@/features/promotion/use-promotion-list' -/** - * Renders one promotion queue lane. Pending items expose moderation actions, - * while historical lanes stay read-only and surface the review comment only. - */ -function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) { +type HistoryPromotionStatus = Extract + +function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B` + } + const units = ['KB', 'MB', 'GB'] + let value = bytes / 1024 + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}` +} + +function formatUserName(displayName: string | null | undefined, userId: string | null | undefined, fallback: string) { + return displayName || userId || fallback +} + +function sourceCoordinate(item: PromotionTask) { + return `@${item.sourceNamespace}/${item.sourceSkillSlug}` +} + +function promotionCoordinate(item: PromotionTask) { + return `${sourceCoordinate(item)} -> @${item.targetNamespace}` +} + +function SorterGlyph({ direction }: { direction: PromotionSortDirection }) { + return ( +
) From bf7c71ad2c3dfb795c40ba6d9901ea8b353a7268 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 29 Jun 2026 14:34:55 +0800 Subject: [PATCH 65/78] fix(scanner): backport local LLM base URL handling for #563 Also add Python CodeQL coverage in the security workflow so repository-level script regression checks stay green when Python source exists. Signed-off-by: dongmucat <1127093059@qq.com> --- .github/workflows/security.yml | 2 + deploy/k8s/README.md | 3 + deploy/k8s/base/scanner-deployment.yaml | 6 + deploy/k8s/base/secret.yaml.example | 1 + docs/security-scanning.md | 1 + docs/skillhub/en/guide/kubernetes.md | 2 + docs/skillhub/en/guide/scanner.md | 2 + docs/skillhub/guide/kubernetes.md | 2 + docs/skillhub/guide/scanner.md | 2 + scanner/Dockerfile | 10 +- .../apply_1_0_2_llm_base_url_backport.py | 62 +++++ scripts/tests/scanner-llm-base-url-test.sh | 232 ++++++++++++++++++ 12 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 scanner/backports/apply_1_0_2_llm_base_url_backport.py create mode 100755 scripts/tests/scanner-llm-base-url-test.sh diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9382d933..3329a267 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -50,6 +50,8 @@ jobs: build-mode: manual - language: javascript-typescript build-mode: none + - language: python + build-mode: none steps: - name: Check out repository diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index 681a1b40..a58d031d 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | +| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | +| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | ### 3. 选择部署方式 @@ -192,6 +194,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | +| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | LLM 模型名称 | 否 | ### 存储配置 diff --git a/deploy/k8s/base/scanner-deployment.yaml b/deploy/k8s/base/scanner-deployment.yaml index 9cff8b93..91c7f3e3 100644 --- a/deploy/k8s/base/scanner-deployment.yaml +++ b/deploy/k8s/base/scanner-deployment.yaml @@ -28,6 +28,12 @@ spec: name: skillhub-secret key: skill-scanner-llm-api-key optional: true + - name: SKILL_SCANNER_LLM_BASE_URL + valueFrom: + secretKeyRef: + name: skillhub-secret + key: skill-scanner-llm-base-url + optional: true - name: SKILL_SCANNER_LLM_MODEL valueFrom: secretKeyRef: diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index 41b9ea5c..5ff967cc 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -24,6 +24,7 @@ stringData: # LLM 配置(可选,用于技能扫描) skill-scanner-llm-api-key: "" + skill-scanner-llm-base-url: "" skill-scanner-llm-model: "" # S3 存储配置(可选,使用 S3/OSS 时配置) diff --git a/docs/security-scanning.md b/docs/security-scanning.md index 2fab9717..bd8d80fc 100644 --- a/docs/security-scanning.md +++ b/docs/security-scanning.md @@ -61,6 +61,7 @@ Important environment variables: Scanner-side optional environment variables: - `SKILL_SCANNER_LLM_API_KEY` +- `SKILL_SCANNER_LLM_BASE_URL` - `SKILL_SCANNER_LLM_MODEL` If the LLM variables are absent, the scanner should still run with non-LLM analyzers. diff --git a/docs/skillhub/en/guide/kubernetes.md b/docs/skillhub/en/guide/kubernetes.md index 73eb2cee..c56caf9a 100644 --- a/docs/skillhub/en/guide/kubernetes.md +++ b/docs/skillhub/en/guide/kubernetes.md @@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml | oauth2-github-client-id | GitHub OAuth ID | No | | oauth2-github-client-secret | GitHub OAuth secret | No | | skill-scanner-llm-api-key | LLM API key | No | +| skill-scanner-llm-base-url | Local/custom LLM service base URL | No | +| skill-scanner-llm-model | LLM model name used by the scanner | No | ### 3. Choose Deployment Method diff --git a/docs/skillhub/en/guide/scanner.md b/docs/skillhub/en/guide/scanner.md index 20f48139..5c11902d 100644 --- a/docs/skillhub/en/guide/scanner.md +++ b/docs/skillhub/en/guide/scanner.md @@ -79,6 +79,8 @@ Enabling the LLM analysis engine can improve the accuracy of security detection: | `SKILLHUB_SCANNER_USE_LLM` | Enable LLM analysis | `false` | | `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM provider (anthropic / openai / azure) | `anthropic` | | `SKILL_SCANNER_LLM_API_KEY` | LLM API key | - | +| `SKILL_SCANNER_LLM_BASE_URL` | Local/custom LLM service base URL | - | +| `SKILL_SCANNER_LLM_MODEL` | LLM model name | - | ### Deployment Notes diff --git a/docs/skillhub/guide/kubernetes.md b/docs/skillhub/guide/kubernetes.md index de8f505e..9a4b3a8d 100644 --- a/docs/skillhub/guide/kubernetes.md +++ b/docs/skillhub/guide/kubernetes.md @@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | +| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | +| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | ### 3. 选择部署方式 diff --git a/docs/skillhub/guide/scanner.md b/docs/skillhub/guide/scanner.md index 8cbdc134..33837cbb 100644 --- a/docs/skillhub/guide/scanner.md +++ b/docs/skillhub/guide/scanner.md @@ -79,6 +79,8 @@ Skill Scanner 执行多引擎分析 | `SKILLHUB_SCANNER_USE_LLM` | 启用 LLM 分析 | `false` | | `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM 提供商(anthropic / openai / azure) | `anthropic` | | `SKILL_SCANNER_LLM_API_KEY` | LLM API 密钥 | - | +| `SKILL_SCANNER_LLM_BASE_URL` | 本地/自定义 LLM 服务地址 | - | +| `SKILL_SCANNER_LLM_MODEL` | LLM 模型名称 | - | ### 部署说明 diff --git a/scanner/Dockerfile b/scanner/Dockerfile index cb0c82c8..90eac126 100644 --- a/scanner/Dockerfile +++ b/scanner/Dockerfile @@ -1,10 +1,14 @@ FROM python:3.11-alpine +ARG SKILL_SCANNER_VERSION=1.0.2 + WORKDIR /app -RUN apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev && \ - pip install --no-cache-dir cisco-ai-skill-scanner && \ - apk del .build-deps && \ +COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py + +RUN pip install --no-cache-dir "cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" && \ + python /tmp/apply_1_0_2_llm_base_url_backport.py /usr/local/lib/python3.11/site-packages && \ + rm /tmp/apply_1_0_2_llm_base_url_backport.py && \ addgroup -S app && \ adduser -S app -G app && \ mkdir -p /tmp/skillhub-scans && \ diff --git a/scanner/backports/apply_1_0_2_llm_base_url_backport.py b/scanner/backports/apply_1_0_2_llm_base_url_backport.py new file mode 100644 index 00000000..7ea6bbd6 --- /dev/null +++ b/scanner/backports/apply_1_0_2_llm_base_url_backport.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Backport SKILL_SCANNER_LLM_BASE_URL support into cisco-ai-skill-scanner 1.0.2.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +EXPECTED_DIST_INFO = "cisco_ai_skill_scanner-1.0.2.dist-info" +ROUTER_RELATIVE_PATH = Path("skill_scanner/api/router.py") + +def replace_exact(content: str, old: str, new: str, expected_count: int, label: str) -> str: + actual_count = content.count(old) + if actual_count != expected_count: + raise SystemExit(f"Expected {expected_count} occurrences of {label}, found {actual_count}.") + return content.replace(old, new, expected_count) + + +def replace_regex(content: str, pattern: str, replacement: str, expected_count: int, label: str) -> str: + updated, actual_count = re.subn(pattern, replacement, content, count=expected_count, flags=re.MULTILINE) + if actual_count != expected_count: + raise SystemExit(f"Expected {expected_count} regex replacements for {label}, found {actual_count}.") + return updated + + +def main() -> int: + site_packages = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/usr/local/lib/python3.11/site-packages") + dist_info = site_packages / EXPECTED_DIST_INFO + if not dist_info.exists(): + raise SystemExit(f"Expected {EXPECTED_DIST_INFO} under {site_packages}, but it was not found.") + + router_path = site_packages / ROUTER_RELATIVE_PATH + content = router_path.read_text(encoding="utf-8") + content = replace_regex( + content, + r'^(?P\s*)llm_model = os.getenv\("SKILL_SCANNER_LLM_MODEL"\)$', + r'\g<0>\n\gllm_base_url = os.getenv("SKILL_SCANNER_LLM_BASE_URL")', + 2, + "llm_model environment lookup", + ) + content = replace_exact( + content, + "LLMAnalyzer(model=llm_model)", + "LLMAnalyzer(model=llm_model, base_url=llm_base_url)", + 2, + "LLMAnalyzer model constructor", + ) + content = replace_exact( + content, + "LLMAnalyzer(provider=provider_str)", + "LLMAnalyzer(provider=provider_str, base_url=llm_base_url)", + 2, + "LLMAnalyzer provider constructor", + ) + + router_path.write_text(content, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/scanner-llm-base-url-test.sh b/scripts/tests/scanner-llm-base-url-test.sh new file mode 100755 index 00000000..79378b61 --- /dev/null +++ b/scripts/tests/scanner-llm-base-url-test.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCANNER_DIR="$REPO_ROOT/scanner" +TMP_DIRS=() + +cleanup() { + local status=$? + local d + for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do + rm -rf "$d" + done + exit "$status" +} +trap cleanup EXIT + +new_tmp() { + local d + d="$(mktemp -d)" + TMP_DIRS+=("$d") + echo "$d" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +tmp="$(new_tmp)" +skill_dir="$tmp/skill" +mkdir -p "$skill_dir/demo-skill" + +cat >"$skill_dir/demo-skill/SKILL.md" <<'EOF' +--- +name: demo-skill +description: Minimal valid skill used for scanner integration coverage. +license: Apache-2.0 +--- + +This is a harmless demo skill used for scanner integration testing. +EOF + +cat >"$skill_dir/demo-skill/run.sh" <<'EOF' +#!/usr/bin/env sh +echo "demo" +EOF +chmod +x "$skill_dir/demo-skill/run.sh" + +IMAGE_TAG="skillhub-scanner-llm-base-url-test:$(date +%s)" +docker build --no-cache -t "$IMAGE_TAG" "$SCANNER_DIR" >/dev/null + +docker run --rm -i \ + -v "$skill_dir:/work/skill:ro" \ + --entrypoint python \ + "$IMAGE_TAG" - <<'PY' +import asyncio +from datetime import datetime, timezone +import http.server +import io +import inspect +import json +import os +from pathlib import Path +import threading +import urllib.request +import zipfile + +from fastapi.params import Query +from skill_scanner.core.models import ScanResult +import skill_scanner.api.router as router + +signature = inspect.signature(router.scan_uploaded_skill) +if not isinstance(signature.parameters["use_llm"].default, Query): + raise SystemExit("scan-upload use_llm should remain a Query parameter") +if not isinstance(signature.parameters["llm_provider"].default, Query): + raise SystemExit("scan-upload llm_provider should remain a Query parameter") + +state = {"base_urls": [], "paths": []} + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): # noqa: A003 + return + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("content-length", "0")) + self.rfile.read(length) + state["paths"].append(self.path) + + payload = json.dumps( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": int(datetime.now(timezone.utc).timestamp()), + "model": "local-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "No findings."}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +server = http.server.HTTPServer(("127.0.0.1", 0), Handler) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() + +target_base_url = f"http://127.0.0.1:{server.server_port}/v1" +os.environ["SKILL_SCANNER_LLM_BASE_URL"] = target_base_url +os.environ["SKILL_SCANNER_LLM_MODEL"] = "test-model" + + +class FakeStaticAnalyzer: + pass + + +class FakeLLMAnalyzer: + def __init__(self, model=None, provider=None, base_url=None): + self.model = model + self.provider = provider + self.base_url = base_url + state["base_urls"].append(base_url) + + def analyze(self, skill_path): + request = urllib.request.Request( + self.base_url + "/chat/completions", + data=b"{}", + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + response.read() + + +class FakeSkillScanner: + def __init__(self, analyzers): + self.analyzers = analyzers + + def scan_skill(self, skill_path): + for analyzer in self.analyzers: + analyze = getattr(analyzer, "analyze", None) + if callable(analyze): + analyze(skill_path) + + return ScanResult( + skill_name="demo-skill", + skill_directory=str(skill_path), + findings=[], + scan_duration_seconds=0.05, + analyzers_used=["fake-llm"], + timestamp=datetime.now(timezone.utc), + ) + + +router.StaticAnalyzer = FakeStaticAnalyzer +router.LLMAnalyzer = FakeLLMAnalyzer +router.SkillScanner = FakeSkillScanner +router.LLM_AVAILABLE = True + +request = router.ScanRequest( + skill_directory="/work/skill/demo-skill", + use_llm=True, + llm_provider="openai", + use_behavioral=False, + use_aidefense=False, + aidefense_api_key=None, +) + +def build_skill_archive_bytes(skill_root: str) -> bytes: + skill_path = Path(skill_root) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in skill_path.rglob("*"): + if path.is_file(): + archive.writestr(str(path.relative_to(skill_path.parent)), path.read_bytes()) + return buffer.getvalue() + +class FakeUploadFile: + def __init__(self, filename: str, payload: bytes): + self.filename = filename + self._payload = payload + + async def read(self) -> bytes: + return self._payload + +try: + direct_response = asyncio.run(router.scan_skill(request)) + + upload_response = asyncio.run( + router.scan_uploaded_skill( + file=FakeUploadFile("demo-skill.zip", build_skill_archive_bytes("/work/skill/demo-skill")), + use_llm=True, + llm_provider="openai", + use_behavioral=False, + use_aidefense=False, + aidefense_api_key=None, + ) + ) +finally: + server.shutdown() + thread.join(timeout=5) + +if not getattr(direct_response, "scan_id", None): + raise SystemExit("scan_skill should still return a scan response") +if not getattr(upload_response, "scan_id", None): + raise SystemExit("scan_uploaded_skill should still return a scan response") +if len(state["base_urls"]) != 2: + raise SystemExit(f"expected two LLM analyzer constructions, got {len(state['base_urls'])}") +if any(base_url != target_base_url for base_url in state["base_urls"]): + raise SystemExit(f"expected every base_url to be {target_base_url}, got {state['base_urls']}") +if len(state["paths"]) != 2: + raise SystemExit(f"expected two LLM requests, got {state['paths']}") +if not all(path.startswith("/v1/") for path in state["paths"]): + raise SystemExit(f"expected every request path to start with /v1/, got {state['paths']}") +PY + +grep -Fq "name: SKILL_SCANNER_LLM_BASE_URL" "$REPO_ROOT/deploy/k8s/base/scanner-deployment.yaml" \ + || fail "Kubernetes scanner deployment must expose SKILL_SCANNER_LLM_BASE_URL" +grep -Fq "skill-scanner-llm-base-url" "$REPO_ROOT/deploy/k8s/base/secret.yaml.example" \ + || fail "Kubernetes secret example must document skill-scanner-llm-base-url" + +echo "scanner-llm-base-url-test passed" From db8aa36f897293398c5bf7d0acc4735507050df3 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 30 Jun 2026 10:56:00 +0800 Subject: [PATCH 66/78] fix(frontend): patch undici alerts and harden staging web Signed-off-by: dongmucat <1127093059@qq.com> --- Makefile | 6 ++++-- docker-compose.staging.yml | 9 +++------ web/package.json | 1 + web/pnpm-lock.yaml | 11 ++++++----- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index bf3e7439..f90f8ef5 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ DEV_SCANNER_URL := http://localhost:8000 STAGING_API_URL := http://localhost:8080 STAGING_WEB_URL := http://localhost STAGING_SERVER_IMAGE := skillhub-server:staging +STAGING_WEB_IMAGE := skillhub-web:staging DEV_PROCESS := bash scripts/dev-process.sh DEV_SERVER_PREPARE := true DEV_SERVER_CMD := ./scripts/run-dev-app.sh @@ -296,12 +297,13 @@ db-reset: ## 重置数据库 validate-release-config: ## 校验发布环境变量文件(默认 .env.release) ./scripts/validate-release-config.sh .env.release -staging: ## 构建并启动 staging 环境,运行 smoke test(混合模式:后端镜像 + 前端静态文件) +staging: ## 构建并启动 staging 环境,运行 smoke test(后端/前端均走本地构建镜像) @echo "=== [1/5] Building backend JAR and Docker image ===" cd server && ./mvnw package -DskipTests -B -q docker build -t $(STAGING_SERVER_IMAGE) -f server/Dockerfile.dev server - @echo "=== [2/5] Building frontend static files ===" + @echo "=== [2/5] Building frontend static files and Docker image ===" cd web && pnpm run build + docker build -t $(STAGING_WEB_IMAGE) -f web/Dockerfile web @echo "=== [3/5] Starting dependency services ===" $(STAGING_BASE_COMPOSE) up -d --wait @echo "=== [4/5] Starting staging services ===" diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 48d1fd1a..b619a0ac 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -1,6 +1,6 @@ -# Staging environment: hybrid mode +# Staging environment # - Backend: locally built Docker image -# - Frontend: locally built static files mounted into Nginx +# - Frontend: locally built Docker image # - Dependencies: reuses docker-compose.yml (postgres, redis, minio) # # Usage: make staging @@ -61,12 +61,9 @@ services: start_period: 60s web: - image: nginx:alpine + image: skillhub-web:staging ports: - "80:80" - volumes: - - ./web/dist:/usr/share/nginx/html:ro - - ./web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro environment: SKILLHUB_API_UPSTREAM: http://server:8080 SKILLHUB_WEB_API_BASE_URL: "" diff --git a/web/package.json b/web/package.json index 4b969147..a39046a8 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "vite@<6.4.3": "^6.4.3", "esbuild@<0.28.1": "^0.28.1", "js-yaml@<4.2.0": "^4.2.0", + "undici@<7.28.0": "^7.28.0", "@babel/core@<7.29.6": "^7.29.6", "postcss@<8.5.10": "^8.5.10", "picomatch@<2.3.2": "^2.3.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 389b1abb..8ebbf05f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: vite@<6.4.3: ^6.4.3 esbuild@<0.28.1: ^0.28.1 js-yaml@<4.2.0: ^4.2.0 + undici@<7.28.0: ^7.28.0 '@babel/core@<7.29.6': ^7.29.6 postcss@<8.5.10: ^8.5.10 picomatch@<2.3.2: ^2.3.2 @@ -2685,8 +2686,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici@7.25.0: - resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} unified@11.0.5: @@ -3810,7 +3811,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.28.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -4662,7 +4663,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.25.0 + undici: 7.28.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -5680,7 +5681,7 @@ snapshots: typescript@5.9.3: {} - undici@7.25.0: {} + undici@7.28.0: {} unified@11.0.5: dependencies: From 85332a22375cd016c6624d374e8c0dc4780d0852 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 30 Jun 2026 14:04:08 +0800 Subject: [PATCH 67/78] chore(staging): keep dependabot fix scoped Signed-off-by: dongmucat <1127093059@qq.com> --- Makefile | 6 ++---- docker-compose.staging.yml | 9 ++++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index f90f8ef5..bf3e7439 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,6 @@ DEV_SCANNER_URL := http://localhost:8000 STAGING_API_URL := http://localhost:8080 STAGING_WEB_URL := http://localhost STAGING_SERVER_IMAGE := skillhub-server:staging -STAGING_WEB_IMAGE := skillhub-web:staging DEV_PROCESS := bash scripts/dev-process.sh DEV_SERVER_PREPARE := true DEV_SERVER_CMD := ./scripts/run-dev-app.sh @@ -297,13 +296,12 @@ db-reset: ## 重置数据库 validate-release-config: ## 校验发布环境变量文件(默认 .env.release) ./scripts/validate-release-config.sh .env.release -staging: ## 构建并启动 staging 环境,运行 smoke test(后端/前端均走本地构建镜像) +staging: ## 构建并启动 staging 环境,运行 smoke test(混合模式:后端镜像 + 前端静态文件) @echo "=== [1/5] Building backend JAR and Docker image ===" cd server && ./mvnw package -DskipTests -B -q docker build -t $(STAGING_SERVER_IMAGE) -f server/Dockerfile.dev server - @echo "=== [2/5] Building frontend static files and Docker image ===" + @echo "=== [2/5] Building frontend static files ===" cd web && pnpm run build - docker build -t $(STAGING_WEB_IMAGE) -f web/Dockerfile web @echo "=== [3/5] Starting dependency services ===" $(STAGING_BASE_COMPOSE) up -d --wait @echo "=== [4/5] Starting staging services ===" diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index b619a0ac..48d1fd1a 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -1,6 +1,6 @@ -# Staging environment +# Staging environment: hybrid mode # - Backend: locally built Docker image -# - Frontend: locally built Docker image +# - Frontend: locally built static files mounted into Nginx # - Dependencies: reuses docker-compose.yml (postgres, redis, minio) # # Usage: make staging @@ -61,9 +61,12 @@ services: start_period: 60s web: - image: skillhub-web:staging + image: nginx:alpine ports: - "80:80" + volumes: + - ./web/dist:/usr/share/nginx/html:ro + - ./web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro environment: SKILLHUB_API_UPSTREAM: http://server:8080 SKILLHUB_WEB_API_BASE_URL: "" From 3a254d752477b3c8e7f9486cfbdc15d531fd7e4d Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 1 Jul 2026 09:39:55 +0800 Subject: [PATCH 68/78] fix(auth): guard SUPER_ADMIN role mutations Signed-off-by: dongmucat <1127093059@qq.com> --- docs/06-api-design.md | 2 +- .../skillhub/service/AdminUserAppService.java | 10 +++++++--- .../src/main/resources/messages.properties | 2 +- .../src/main/resources/messages_zh.properties | 2 +- .../skillhub/service/AdminUserAppServiceTest.java | 14 ++++++++++++++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 673eb950..56cb5541 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -319,7 +319,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: |------|------|------| | GET | `/api/v1/admin/users` | 用户列表 | | GET | `/api/v1/admin/users/{id}` | 用户详情 | -| PUT | `/api/v1/admin/users/{id}/roles` | 修改用户角色(USER_ADMIN 不可分配 SUPER_ADMIN) | +| PUT | `/api/v1/admin/users/{id}/role` | 修改用户角色(USER_ADMIN 不可分配 SUPER_ADMIN,也不可修改已有 SUPER_ADMIN 的角色状态) | | POST | `/api/v1/admin/users/{id}/approve` | 审批待准入用户 | | POST | `/api/v1/admin/users/{id}/disable` | 封禁用户 | | POST | `/api/v1/admin/users/{id}/enable` | 解封用户 | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index b684c744..b3e1bdc7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -37,6 +37,8 @@ import java.util.stream.Collectors; public class AdminUserAppService { private static final Set MANAGEABLE_STATUSES = Set.of(UserStatus.ACTIVE, UserStatus.DISABLED); + private static final String SUPER_ADMIN_ROLE = "SUPER_ADMIN"; + private static final String USER_ROLE = "USER"; private final AdminUserSearchRepository adminUserSearchRepository; private final UserAccountRepository userAccountRepository; @@ -83,15 +85,17 @@ public class AdminUserAppService { UserAccount user = loadUser(userId); rejectSystemAccountMutation(user); String normalizedRoleCode = normalizeRoleCode(roleCode); + boolean targetHasSuperAdminRole = userRoleBindingRepository.findByUserId(user.getId()).stream() + .anyMatch(binding -> SUPER_ADMIN_ROLE.equals(binding.getRole().getCode())); - if ("SUPER_ADMIN".equals(normalizedRoleCode) - && (actorPlatformRoles == null || !actorPlatformRoles.contains("SUPER_ADMIN"))) { + if ((SUPER_ADMIN_ROLE.equals(normalizedRoleCode) || targetHasSuperAdminRole) + && (actorPlatformRoles == null || !actorPlatformRoles.contains(SUPER_ADMIN_ROLE))) { throw new DomainForbiddenException("error.admin.user.role.superAdmin.assignDenied"); } userRoleBindingRepository.deleteByUserId(user.getId()); - if (!"USER".equals(normalizedRoleCode)) { + if (!USER_ROLE.equals(normalizedRoleCode)) { Role role = roleRepository.findByCode(normalizedRoleCode) .orElseThrow(() -> new DomainBadRequestException("error.admin.user.role.invalid", roleCode)); userRoleBindingRepository.save(new UserRoleBinding(user.getId(), role)); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 25a63127..19189122 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -135,7 +135,7 @@ error.deviceAuth.deviceCode.invalid=Device code expired or invalid error.deviceAuth.deviceCode.used=Device code has already been used error.admin.user.notFound=User not found: {0} 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.role.superAdmin.assignDenied=Only SUPER_ADMIN can mutate SUPER_ADMIN role state error.admin.user.systemAccount.immutable=System accounts cannot be modified from user management error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 99183ae5..6885dfd3 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -135,7 +135,7 @@ error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 error.deviceAuth.deviceCode.used=设备验证码已被使用 error.admin.user.notFound=用户不存在:{0} error.admin.user.role.invalid=无效的角色:{0} -error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色 +error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以修改 SUPER_ADMIN 角色状态 error.admin.user.systemAccount.immutable=系统账号不能在用户管理中修改 error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 7f2cfc22..8296f940 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -89,6 +89,20 @@ class AdminUserAppServiceTest { () -> service.updateUserRole("user-1", "SUPER_ADMIN", Set.of("USER_ADMIN"))); } + @Test + void updateUserRole_nonSuperAdminCannotReplaceExistingSuperAdminRole() { + when(userAccountRepository.findById("user-1")) + .thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE))); + when(userRoleBindingRepository.findByUserId("user-1")) + .thenReturn(List.of(new UserRoleBinding("user-1", role("SUPER_ADMIN")))); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserRole("user-1", "USER", Set.of("USER_ADMIN"))); + + verify(userRoleBindingRepository, never()).deleteByUserId(any()); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + } + @Test void updateUserRole_rejectsSystemAccount() { when(userAccountRepository.findById("builtin-skill-publisher")) From 8b8420151626b773f33987eb9f092281da396ec5 Mon Sep 17 00:00:00 2001 From: betterlmy Date: Fri, 17 Jul 2026 11:26:20 +0800 Subject: [PATCH 69/78] feat: add generic user-level agent install target Signed-off-by: betterlmy --- cli/src/agents/resolver.ts | 12 +++++++++++ .../unit/agents/resolver-interactive.test.ts | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 2290ee09..04ed5c63 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -68,6 +68,18 @@ async function resolveScopedTargets( } candidates = dedupeByRoot(candidates) + if (scope === 'user' && agentList.length === 0 && options.interactive && !options.json) { + candidates = dedupeByRoot([ + ...candidates, + { + agent: 'generic', + rootDir: `${scopedHome}/.agents/skills`, + scope: 'user', + source: 'fallback' + } + ]) + } + if (candidates.length === 0) { const fallbackRoot = scope === 'user' ? `${scopedHome}/.agents/skills` diff --git a/cli/test/unit/agents/resolver-interactive.test.ts b/cli/test/unit/agents/resolver-interactive.test.ts index 55bc99ac..8de156da 100644 --- a/cli/test/unit/agents/resolver-interactive.test.ts +++ b/cli/test/unit/agents/resolver-interactive.test.ts @@ -33,4 +33,25 @@ describe('resolveInstallTargets interactive prompt', () => { expect(targets).toEqual([highlighted]) }) + + test('offers the generic user target alongside detected agent targets', async () => { + const targets = await resolveInstallTargets({ + cwd: '/repo', + home: '/home/u', + agents: [], + scope: 'user', + json: false, + interactive: true, + detected: [ + { agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'detected' } + ] + }) + + expect(targets).toEqual([{ + agent: 'generic', + rootDir: '/home/u/.agents/skills', + scope: 'user', + source: 'fallback' + }]) + }) }) From f519b08a7313a8461ca308378c7398b7e608bb36 Mon Sep 17 00:00:00 2001 From: betterlmy Date: Fri, 17 Jul 2026 17:20:22 +0800 Subject: [PATCH 70/78] fix(cli): preflight canonical install targets Signed-off-by: betterlmy --- cli/README.md | 4 +- cli/src/agents/resolver.ts | 23 +++++--- cli/src/platform/paths.ts | 9 +++ cli/src/services/install-service.ts | 46 +++++++++++---- .../unit/agents/resolver-interactive.test.ts | 43 ++++++++++---- cli/test/unit/agents/resolver.test.ts | 36 ++++++++++++ .../unit/services/install-service.test.ts | 58 ++++++++++++++++++- docs/skillhub/en/guide/cli.md | 4 +- docs/skillhub/guide/cli.md | 4 +- 9 files changed, 188 insertions(+), 39 deletions(-) diff --git a/cli/README.md b/cli/README.md index 6d98df80..b2a8cdf3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -160,7 +160,7 @@ The CLI determines the installation location using the following logic: 1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`. 2. If `--scope user|project` is specified: Limit detection to the chosen scope. - With `--agent `: Install to that profile's user or project skills directory directly. - - Without `--agent`: Detect existing skills directories within the chosen scope only. + - Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`/.agents/skills/`) is always also offered and can be selected alone or together with detected targets. - No detected directory in the chosen scope → Fallback to `/.agents/skills/` for `--scope user` or `/.agents/skills/` for `--scope project`. 3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged). 4. If none of the above is specified: @@ -191,7 +191,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. +For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. ### File Structure After Installation diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 04ed5c63..190e1a8d 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' -import { pathExists } from '../platform/paths' +import { canonicalizeExistingPath, pathExists } from '../platform/paths' import type { AgentCandidate } from './types' import { allProfiles, profileMap } from './detector' @@ -66,10 +66,10 @@ async function resolveScopedTargets( } else { candidates = await generateScopedCandidates(scope, options.cwd, scopedHome) } - candidates = dedupeByRoot(candidates) + candidates = await dedupeByRoot(candidates) if (scope === 'user' && agentList.length === 0 && options.interactive && !options.json) { - candidates = dedupeByRoot([ + candidates = await dedupeByRoot([ ...candidates, { agent: 'generic', @@ -161,13 +161,18 @@ async function resolveExplicitAgents( return results } -function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] { +async function dedupeByRoot(candidates: AgentCandidate[]): Promise { const seen = new Set() - return candidates.filter(c => { - if (seen.has(c.rootDir)) return false - seen.add(c.rootDir) - return true - }) + const deduped: AgentCandidate[] = [] + + for (const candidate of candidates) { + const canonicalRootDir = await canonicalizeExistingPath(candidate.rootDir) + if (seen.has(canonicalRootDir)) continue + seen.add(canonicalRootDir) + deduped.push(candidate) + } + + return deduped } async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise { diff --git a/cli/src/platform/paths.ts b/cli/src/platform/paths.ts index e139b2cc..7811a766 100644 --- a/cli/src/platform/paths.ts +++ b/cli/src/platform/paths.ts @@ -24,6 +24,15 @@ export async function pathExists(path: string): Promise { } } +export async function canonicalizeExistingPath(path: string): Promise { + const { realpath } = await import('node:fs/promises') + try { + return await realpath(path) + } catch { + return path + } +} + export async function applyCredentialPermissions(path: string): Promise { if (process.platform === 'win32') return const { chmod } = await import('node:fs/promises') diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index 4293ff6e..bba71345 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -6,7 +6,7 @@ import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' import { extractZip } from '../platform/archive' import { readBoundedResponseBody } from '../platform/download' -import { pathExists } from '../platform/paths' +import { canonicalizeExistingPath, pathExists } from '../platform/paths' import type { AgentCandidate } from '../agents/types' export interface InstallOptions { @@ -20,7 +20,40 @@ export interface InstallOptions { home?: string | undefined } +async function preflightInstallTargets( + targets: AgentCandidate[], + slug: string, + force: boolean +): Promise> { + const seenSkillDirs = new Set() + const preparedTargets: Array<{ target: AgentCandidate; skillDir: string }> = [] + + for (const target of targets) { + const canonicalRootDir = await canonicalizeExistingPath(target.rootDir) + const canonicalSkillDir = join(canonicalRootDir, slug) + if (seenSkillDirs.has(canonicalSkillDir)) { + throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, { + path: canonicalSkillDir, + next: 'select only one target for this directory' + }) + } + seenSkillDirs.add(canonicalSkillDir) + + const skillDir = join(target.rootDir, slug) + if (await pathExists(skillDir) && !force) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } + preparedTargets.push({ target, skillDir }) + } + + return preparedTargets +} + export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> { + const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force) const client = new SkillHubClient(options.registry, options.token) const resolved = await client.resolve(options.namespace, options.slug, options.version) const response = await client.download(options.namespace, options.slug, resolved.version) @@ -29,16 +62,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed const installed: Array<{ agent: string; dir: string }> = [] const store = new InventoryStore(options.home) - for (const target of options.targets) { - const skillDir = join(target.rootDir, options.slug) - - if (await pathExists(skillDir) && !options.force) { - throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { - path: skillDir, - next: 'pass --force to overwrite' - }) - } - + for (const { target, skillDir } of preparedTargets) { await mkdir(target.rootDir, { recursive: true }) const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`)) let movedIntoPlace = false diff --git a/cli/test/unit/agents/resolver-interactive.test.ts b/cli/test/unit/agents/resolver-interactive.test.ts index 8de156da..f669f80d 100644 --- a/cli/test/unit/agents/resolver-interactive.test.ts +++ b/cli/test/unit/agents/resolver-interactive.test.ts @@ -1,18 +1,30 @@ -import { describe, expect, mock, test } from 'bun:test' +import { afterEach, describe, expect, mock, test } from 'bun:test' import type { AgentCandidate } from '../../../src/agents/types' +interface PromptChoice { + value: AgentCandidate +} + interface PromptOptions { + choices?: PromptChoice[] onRender?: (this: { cursor?: number }) => void format?: (selectedTargets: AgentCandidate[]) => AgentCandidate[] } +const defaultSelectedTargets = (options: PromptOptions): AgentCandidate[] => options.format?.([]) ?? [] +let selectPromptTargets = defaultSelectedTargets + mock.module('prompts', () => ({ default: (options: PromptOptions) => { options.onRender?.call({ cursor: 1 }) - return { selected: options.format?.([]) ?? [] } + return { selected: selectPromptTargets(options) } } })) +afterEach(() => { + selectPromptTargets = defaultSelectedTargets +}) + const { resolveInstallTargets } = await import('../../../src/agents/resolver') describe('resolveInstallTargets interactive prompt', () => { @@ -34,7 +46,21 @@ describe('resolveInstallTargets interactive prompt', () => { expect(targets).toEqual([highlighted]) }) - test('offers the generic user target alongside detected agent targets', async () => { + test('allows selecting generic alongside detected user targets', async () => { + selectPromptTargets = options => options.choices?.map(choice => choice.value) ?? [] + const codex: AgentCandidate = { + agent: 'codex', + rootDir: '/home/u/.codex/skills', + scope: 'user', + source: 'detected' + } + const generic: AgentCandidate = { + agent: 'generic', + rootDir: '/home/u/.agents/skills', + scope: 'user', + source: 'fallback' + } + const targets = await resolveInstallTargets({ cwd: '/repo', home: '/home/u', @@ -42,16 +68,9 @@ describe('resolveInstallTargets interactive prompt', () => { scope: 'user', json: false, interactive: true, - detected: [ - { agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'detected' } - ] + detected: [codex] }) - expect(targets).toEqual([{ - agent: 'generic', - rootDir: '/home/u/.agents/skills', - scope: 'user', - source: 'fallback' - }]) + expect(targets).toEqual([codex, generic]) }) }) diff --git a/cli/test/unit/agents/resolver.test.ts b/cli/test/unit/agents/resolver.test.ts index 36e8170b..96eda770 100644 --- a/cli/test/unit/agents/resolver.test.ts +++ b/cli/test/unit/agents/resolver.test.ts @@ -1,5 +1,9 @@ +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, test } from 'bun:test' import { resolveInstallTargets } from '../../../src/agents/resolver' +import type { AgentCandidate } from '../../../src/agents/types' describe('resolveInstallTargets', () => { test('rejects dir and agent together before filesystem writes', async () => { @@ -216,4 +220,36 @@ describe('resolveInstallTargets', () => { expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills') expect(targets[0]!.scope).toBe('user') }) + + test('deduplicates a symlinked detected target and the generic user target', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-resolver-home-')) + const genericRoot = join(home, '.agents', 'skills') + const codexRoot = join(home, '.codex', 'skills') + const codex: AgentCandidate = { + agent: 'codex', + rootDir: codexRoot, + scope: 'user', + source: 'detected' + } + + try { + await mkdir(genericRoot, { recursive: true }) + await mkdir(join(home, '.codex'), { recursive: true }) + await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir') + + const targets = await resolveInstallTargets({ + cwd: '/repo', + home, + agents: [], + scope: 'user', + json: false, + interactive: true, + detected: [codex] + }) + + expect(targets).toEqual([codex]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) }) diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index fd08e1b6..5d3b0ec5 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' @@ -72,6 +72,62 @@ describe('installSkill', () => { })).rejects.toThrow('skill already installed') }) + test('preflights all targets before writing when a later target is occupied', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' }) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-first-root-')) + const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-second-root-')) + const firstSkillDir = join(firstRoot, 'demo') + const secondSkillDir = join(secondRoot, 'demo') + await mkdir(secondSkillDir, { recursive: true }) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [ + { agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }, + { agent: 'claude-code', rootDir: secondRoot, scope: 'project', source: 'explicit' } + ], + force: false, + home + })).rejects.toThrow(`skill already installed at ${secondSkillDir}`) + + expect(await exists(firstSkillDir)).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('rejects canonical target aliases before writing any installation', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' }) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const targetParent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-')) + const genericRoot = join(targetParent, 'generic') + const codexRoot = join(targetParent, 'codex') + const skillDir = join(genericRoot, 'demo') + try { + await mkdir(genericRoot, { recursive: true }) + await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir') + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [ + { agent: 'codex', rootDir: codexRoot, scope: 'user', source: 'detected' }, + { agent: 'generic', rootDir: genericRoot, scope: 'user', source: 'fallback' } + ], + force: false, + home + })).rejects.toThrow('multiple install targets resolve to') + + expect(await exists(skillDir)).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + } finally { + await rm(home, { recursive: true, force: true }) + await rm(targetParent, { recursive: true, force: true }) + } + }) + test('force replaces the old skill directory instead of overlaying files', async () => { globalThis.fetch = installFetch({ 'SKILL.md': '# New' }) const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 1a3069b9..6cbc779c 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -157,7 +157,7 @@ The CLI determines the installation location using the following logic: 1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`. 2. If `--scope user|project` is specified: Limit detection to the chosen scope. - With `--agent `: Install to that profile's user or project skills directory directly. - - Without `--agent`: Detect existing skills directories within the chosen scope only. + - Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`/.agents/skills/`) is always also offered and can be selected alone or together with detected targets. - No detected directory in the chosen scope → Fallback to `/.agents/skills/` for `--scope user` or `/.agents/skills/` for `--scope project`. 3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged). 4. If none of the above is specified: @@ -188,7 +188,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. +For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. ### File Structure After Installation diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 910d9cf5..22162d56 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -157,7 +157,7 @@ CLI 按以下逻辑确定安装位置: 1. 指定 `--dir`:安装到该目录,agent 标记为 `custom`。`--dir` 与 `--scope`、`--agent` 互斥。 2. 指定 `--scope user|project`:探测限定在该 scope 内。 - 同时指定 `--agent `:直接安装到该 profile 对应 scope 的 skills 目录。 - - 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。 + - 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。在交互式 user scope 下,始终额外提供 `generic` 目标(`/.agents/skills/`),可单独选择或与已探测目标同时选择。 - 该 scope 下未探测到 → fallback:`--scope user` 回退到 `/.agents/skills/`,`--scope project` 回退到 `/.agents/skills/`。 3. 指定 `--agent`(无 `--scope`):安装到对应 Agent 的 skills 目录(沿用现有行为,不变)。 4. 三者均未指定: @@ -188,7 +188,7 @@ CLI 按以下逻辑确定安装位置: | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -对于不在列表中的 Agent,使用 `--dir` 指定安装路径。当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。 +对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。 ### 安装后的文件结构 From d8486cfb58e1c5d62ba24c7d04ac907954d6607a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 21 Jul 2026 15:24:49 +0800 Subject: [PATCH 71/78] fix(scanner): pin LiteLLM for Alpine builds Signed-off-by: dongmucat <1127093059@qq.com> --- scanner/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scanner/Dockerfile b/scanner/Dockerfile index 90eac126..f341893c 100644 --- a/scanner/Dockerfile +++ b/scanner/Dockerfile @@ -6,7 +6,9 @@ WORKDIR /app COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py -RUN pip install --no-cache-dir "cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" && \ +RUN pip install --no-cache-dir \ + "cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" \ + "litellm==1.90.2" && \ python /tmp/apply_1_0_2_llm_base_url_backport.py /usr/local/lib/python3.11/site-packages && \ rm /tmp/apply_1_0_2_llm_base_url_backport.py && \ addgroup -S app && \ From 9af4d391f342a447bffe4b750d10c38036db9d84 Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:01:50 +0800 Subject: [PATCH 72/78] docs(integrations): add Hermes Agent skill guide (#584) * docs(integrations): add Hermes skill guide Signed-off-by: dongmucat <1127093059@qq.com> * docs(integrations): clarify Hermes skill collision handling Signed-off-by: dongmucat <1127093059@qq.com> --------- Signed-off-by: dongmucat <1127093059@qq.com> --- README.md | 6 + README_zh.md | 6 + docs/hermes-integration-en.md | 278 ++++++++++++++++++++++++++++++++++ docs/hermes-integration.md | 278 ++++++++++++++++++++++++++++++++++ 4 files changed, 568 insertions(+) create mode 100644 docs/hermes-integration-en.md create mode 100644 docs/hermes-integration.md diff --git a/README.md b/README.md index d47ef5cc..2b068ab9 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,12 @@ namespace `my-space` plus skill slug `my-skill`. 📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)** +### [Hermes Agent](https://github.com/NousResearch/hermes-agent) + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) uses the standard `SKILL.md` format and recursively discovers skills under `$HERMES_HOME/skills/`. Use SkillHub CLI's explicit `--dir` option to install a complete SkillHub package into Hermes without a registry adapter, then verify it with `hermes skills list`. + +📖 **[Complete Hermes Agent Integration Guide →](./docs/hermes-integration-en.md)** + ### [AstronClaw](https://agent.xfyun.cn/astron-claw) [AstronClaw](https://agent.xfyun.cn/astron-claw) is a cloud AI assistant built on OpenClaw's core capabilities, providing 24/7 online service through enterprise platforms like WeChat Work, DingTalk, and Feishu. It features a built-in skill system with over 130 official skills. You can connect it to a self-hosted SkillHub registry to enable one-click skill installation, search repository, dialogue-based automatic installation, and even custom skills management within your organization. diff --git a/README_zh.md b/README_zh.md index 80e65b75..42fb8225 100644 --- a/README_zh.md +++ b/README_zh.md @@ -370,6 +370,12 @@ namespace `my-space` 和 skill slug `my-skill`。 📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)** +### [Hermes Agent](https://github.com/NousResearch/hermes-agent) + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) 使用标准 `SKILL.md` 格式,并会递归发现 `$HERMES_HOME/skills/` 中的技能。通过 SkillHub CLI 的 `--dir` 参数即可把完整技能包安装到 Hermes,无需新增 registry 适配器;安装后可使用 `hermes skills list` 验证。 + +📖 **[完整 Hermes Agent 集成指南 →](./docs/hermes-integration.md)** + ### [AstronClaw](https://agent.xfyun.cn/astron-claw) [AstronClaw](https://agent.xfyun.cn/astron-claw) 是基于 OpenClaw 核心能力打造的云端 AI 助手,提供全天候在线服务,随时随地通过企业微信、钉钉、飞书等渠道提供服务。它内置了丰富的技能系统,您可以将其连接到自托管的 SkillHub 注册中心,支持技能市场一键安装、仓库搜索、对话自动安装,甚至管理和分发组织内部的自定义私有技能。 diff --git a/docs/hermes-integration-en.md b/docs/hermes-integration-en.md new file mode 100644 index 00000000..5c4031d8 --- /dev/null +++ b/docs/hermes-integration-en.md @@ -0,0 +1,278 @@ +# Hermes Agent Integration Guide + +This guide explains how to install skills from SkillHub into [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent), then discover, load, update, and remove those skills in Hermes. + +“Hermes” in this guide means `NousResearch/hermes-agent`; it does not cover other projects with the same name. + +## Validated scope + +| Component | Validated version | Notes | +|-----------|-------------------|-------| +| SkillHub Server | `v0.2.13` | Public or self-hosted registry | +| SkillHub CLI | `0.1.8` | npm package `@astron-team/skillhub` | +| Hermes Agent | `0.18.2` | Upstream tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) | + +Validation date: 2026-07-17. + +Hermes 0.18.2 uses an [Agent Skills](https://agentskills.io/)-compatible `SKILL.md` format and recursively scans `$HERMES_HOME/skills/`. SkillHub CLI can extract a complete skill package into any explicit `--dir` target. The current integration therefore needs no format conversion, Hermes-specific CLI profile, or server adapter: + +```text +SkillHub registry + -> skillhub install --dir + -> //SKILL.md + -> Hermes discovers and loads the skill on demand +``` + +> Hermes 0.18.2 has no native SkillHub registry source. This guide uses SkillHub CLI for search, download, and local installation, while Hermes handles discovery and execution. + +## Prerequisites + +1. Install and initialize Hermes Agent. +2. Install SkillHub CLI: + +```bash +npm install -g @astron-team/skillhub + +skillhub version +hermes version +``` + +3. Ensure the skill package has a valid root `SKILL.md` with at least `name` and `description` frontmatter. + +The examples below use Bash/zsh. On Windows, use the same directory structure, replace the default Hermes home with `$HOME\.hermes`, and set variables using PowerShell syntax. + +## Quick start + +### 1. Configure the SkillHub registry + +Set the public or self-hosted SkillHub URL: + +```bash +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +``` + +You can skip login for public skills that allow anonymous downloads. For team namespaces, restricted skills, or private deployments, save an API token first: + +```bash +skillhub login \ + --registry "$SKILLHUB_REGISTRY" \ + --token YOUR_API_TOKEN + +skillhub whoami --registry "$SKILLHUB_REGISTRY" +``` + +Use placeholder tokens in examples. Never write a real token into `SKILL.md`, scripts, or version control. + +### 2. Search for a skill + +```bash +skillhub search "pdf" --registry "$SKILLHUB_REGISTRY" +``` + +Record the namespace, slug, and required version. The following examples use `my-team/my-skill`: + +```bash +export SKILLHUB_NAMESPACE=my-team +export SKILLHUB_SKILL=my-skill +``` + +### 3. Install into the primary Hermes skills directory + +Set the home of the active Hermes profile. The default profile normally uses `~/.hermes`; if you use a custom `HERMES_HOME` or a named profile, point it at the actual profile directory: + +```bash +export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE" +``` + +Install the skill: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" +``` + +SkillHub CLI preserves `SKILL.md`, `references/`, `scripts/`, `templates/`, `assets/`, and other package files. It also writes `.skillhub/metadata.json` to record the installation source. The resulting layout looks like this: + +```text +$HERMES_HOME/skills/ +└── skillhub/ + └── my-team/ + └── my-skill/ + ├── SKILL.md + ├── references/ # optional + ├── scripts/ # optional + └── .skillhub/ + └── metadata.json +``` + +Separating target directories by namespace reduces filesystem collisions between skills with the same slug. Hermes recursively scans these levels. + +### 4. Verify and load the skill in Hermes + +First, confirm that Hermes discovers the skill: + +```bash +hermes skills list --source local --enabled-only +``` + +Then start Hermes and invoke the slash command normalized from the skill `name`: + +```bash +hermes +``` + +```text +/my-skill +``` + +You can also ask Hermes in natural language to use the skill. Hermes lists the raw `SKILL.md` frontmatter `name`, but its slash command lowercases that name, replaces spaces and underscores with hyphens, removes other characters outside `a-z0-9-`, and collapses repeated hyphens. For example, `PDF_Tools` becomes `/pdf-tools`. The command may therefore differ from the SkillHub slug. + +If a running session does not immediately show a new skill, run `/reload-skills` or restart the session. + +## Update a skill + +SkillHub CLI 0.1.8 overwrites a local skill by repeating the install command with `--force`. Omitting `--version` resolves the latest published version; you can also pin one explicitly: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force + +# Pinned version example +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --version 1.2.0 \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force +``` + +Review the new version before overwriting because `--force` replaces the existing skill directory. Afterward, run: + +```bash +skillhub list \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" + +hermes skills list --source local --enabled-only +``` + +> `skillhub update` updates SkillHub CLI itself; it does not update installed skills. Refresh an installed skill with `skillhub install ... --force`. + +## Remove a skill + +First, list every installation from the same registry and confirm that there are no other same-slug skills you need to keep: + +```bash +skillhub list \ + --registry "$SKILLHUB_REGISTRY" +``` + +Then remove the local installation: + +```bash +skillhub remove "$SKILLHUB_SKILL" \ + --registry "$SKILLHUB_REGISTRY" +``` + +SkillHub CLI deletes both the skill directory and the local inventory record. Local `remove` in this version matches only registry and slug; it does not filter by namespace or directory. Every same-slug target from that registry, across all namespaces and installation directories, is removed. If the unfiltered `skillhub list` shows a match you need to keep, do not run the command; namespace- or directory-scoped removal requires a future CLI capability. + +After removal, run `/reload-skills`, restart the Hermes session, or confirm that the skill is gone with: + +```bash +hermes skills list --source local --enabled-only +``` + +## Optional: use a shared external skills directory + +When several agents share `~/.agents/skills`, install SkillHub skills into that shared tree instead of the primary Hermes directory: + +```bash +export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE" + +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$SHARED_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" +``` + +Merge the shared root into `$HERMES_HOME/config.yaml` without replacing existing `skills` settings: + +```yaml +skills: + external_dirs: + - ~/.agents/skills +``` + +Hermes lists and loads external skills alongside local skills. Do not rely on local shadowing: Hermes 0.18.2 refuses ambiguous `skill_view` matches across the local skills directory and `external_dirs`. Rename or remove a colliding copy instead. + +> `external_dirs` is not a read-only boundary. If the Hermes process can write to an external directory, Hermes skill-management tools can modify its files. Use filesystem permissions or an isolated Hermes profile when shared skills must remain read-only. + +## Compatibility and security boundaries + +- **Format compatibility is not complete runtime compatibility.** Hermes can read `SKILL.md` and supporting files, but agent-specific tools, MCP servers, commands, environment variables, and platform capabilities referenced by a skill still need individual verification. +- **Hermes treats this path as local.** A skill copied by SkillHub CLI does not run through the Hermes Skills Hub community-install scanner. Review the SkillHub security report and the skill contents before installation, and use Hermes terminal isolation where appropriate. +- **Keep multi-file packages intact.** Do not replace SkillHub CLI with the Hermes 0.18.2 direct-URL source for multi-file skills. That release guarantees a single `SKILL.md` for URL installs, whereas SkillHub CLI extracts the complete package. +- **Avoid name collisions.** Namespace-separated filesystem paths do not resolve slash-command collisions. Keep normalized command names unique within one Hermes profile. For example, `PDF Tools` and `pdf_tools` both become `/pdf-tools`. +- **Protect credentials.** A registry token is only for SkillHub access and does not belong in a skill package. Skills that need runtime secrets should use Hermes environment-variable and security settings. + +## Troubleshooting + +### The new skill is missing from the Hermes list + +Check these items in order: + +1. The current session uses the same `HERMES_HOME` used during installation. +2. The final path contains `/SKILL.md`. +3. `SKILL.md` contains valid `name` and `description` fields. +4. `platforms` or other frontmatter does not exclude the current operating system. +5. The skill appears after `/reload-skills` or in a new session. + +```bash +skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY" +hermes skills list --source local +``` + +### Installation reports `skill already installed` + +Existing directories are not overwritten by default. Review the target version, then add `--force`: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force +``` + +### The CLI reports `registry unreachable` or a download failure + +- Confirm that `SKILLHUB_REGISTRY` is the SkillHub root URL. +- Run `skillhub search` against the same registry to distinguish registry reachability from a download failure. +- Check proxy, DNS, certificate, and self-hosted service status. +- Retry a transient network error only after confirming the service is healthy; do not bypass certificate failures by disabling TLS verification. + +### The skill is listed but fails during execution + +Check tool names, shell commands, script runtimes, packages, MCP servers, environment variables, and operating-system restrictions referenced by that skill. Those are skill-specific runtime compatibility concerns, not failures of `SKILL.md` discovery. + +### Can `hermes skills install` consume a SkillHub coordinate directly? + +Hermes 0.18.2 has no SkillHub registry source and cannot resolve a SkillHub namespace/slug directly. Use `skillhub install --dir ...` as shown in this guide. Native search, installation, updates, and security scanning inside Hermes would require a separately designed Hermes source adapter with its own protocol and acceptance scope. + +## Regression checks after upgrades + +After upgrading SkillHub CLI or Hermes, verify at least the following: + +1. `skillhub install --dir` still creates `/SKILL.md` and preserves support files. +2. `hermes skills list --source local --enabled-only` discovers the skill. +3. `/skill-name` loads `SKILL.md` and exposes support-file paths; then read one referenced file with `skill_view(name, file_path)` or exercise the script/asset the skill actually uses. +4. `skillhub install --force` overwrites the skill while keeping a healthy inventory. +5. Hermes no longer discovers the skill after `skillhub remove`. + +Upstream reference: [Hermes Skills System at v0.18.2](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md). diff --git a/docs/hermes-integration.md b/docs/hermes-integration.md new file mode 100644 index 00000000..25743bfd --- /dev/null +++ b/docs/hermes-integration.md @@ -0,0 +1,278 @@ +# Hermes Agent 集成指南 + +本文档说明如何把 SkillHub 中的技能安装到 [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent),并在 Hermes 中发现、加载、更新和移除这些技能。 + +本文中的 “Hermes” 特指 `NousResearch/hermes-agent`,不适用于其他同名项目。 + +## 已验证范围 + +| 组件 | 已验证版本 | 说明 | +|------|------------|------| +| SkillHub Server | `v0.2.13` | 公开或自托管 registry | +| SkillHub CLI | `0.1.8` | npm 包 `@astron-team/skillhub` | +| Hermes Agent | `0.18.2` | 上游 tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) | + +验证日期:2026-07-17。 + +Hermes 0.18.2 使用兼容 [Agent Skills](https://agentskills.io/) 的 `SKILL.md` 格式,并递归扫描 `$HERMES_HOME/skills/`。SkillHub CLI 可以通过 `--dir` 把完整技能包解压到指定目录。因此,当前兼容链路不需要格式转换、Hermes 专用 CLI profile 或服务端适配: + +```text +SkillHub registry + -> skillhub install --dir + -> //SKILL.md + -> Hermes 发现并按需加载 +``` + +> Hermes 0.18.2 没有原生 SkillHub registry source。本指南使用 SkillHub CLI 负责搜索、下载和本地安装,Hermes 负责发现和执行技能。 + +## 前置条件 + +1. 已安装并初始化 Hermes Agent。 +2. 已安装 SkillHub CLI: + +```bash +npm install -g @astron-team/skillhub + +skillhub version +hermes version +``` + +3. 技能包根目录包含有效的 `SKILL.md`,其中至少有 `name` 和 `description` frontmatter。 + +以下示例使用 Bash/zsh。Windows 用户可使用同一目录结构,将默认 Hermes 主目录替换为 `$HOME\.hermes`,并按 PowerShell 语法设置变量。 + +## 快速开始 + +### 1. 配置 SkillHub registry + +设置公开或自托管 SkillHub 地址: + +```bash +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +``` + +公开且允许匿名下载的技能可以跳过登录。访问团队命名空间、受限技能或私有部署时,先保存 API Token: + +```bash +skillhub login \ + --registry "$SKILLHUB_REGISTRY" \ + --token YOUR_API_TOKEN + +skillhub whoami --registry "$SKILLHUB_REGISTRY" +``` + +请使用占位 Token 演示,不要把真实 Token 写入 `SKILL.md`、脚本或版本库。 + +### 2. 搜索技能 + +```bash +skillhub search "pdf" --registry "$SKILLHUB_REGISTRY" +``` + +记录结果中的 namespace、slug 和所需版本。下面以 `my-team/my-skill` 为例: + +```bash +export SKILLHUB_NAMESPACE=my-team +export SKILLHUB_SKILL=my-skill +``` + +### 3. 安装到 Hermes 主技能目录 + +设置当前 Hermes profile 的主目录。默认 profile 通常是 `~/.hermes`;如果使用自定义 `HERMES_HOME` 或命名 profile,请指向实际 profile 目录: + +```bash +export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE" +``` + +安装技能: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" +``` + +SkillHub CLI 会保留技能包中的 `SKILL.md`、`references/`、`scripts/`、`templates/`、`assets/` 等文件,并额外写入 `.skillhub/metadata.json` 记录安装来源。目录结构类似: + +```text +$HERMES_HOME/skills/ +└── skillhub/ + └── my-team/ + └── my-skill/ + ├── SKILL.md + ├── references/ # 可选 + ├── scripts/ # 可选 + └── .skillhub/ + └── metadata.json +``` + +按 namespace 分目录可以减少不同命名空间中同 slug 技能的文件路径冲突。Hermes 会递归扫描这些层级。 + +### 4. 在 Hermes 中验证和加载 + +先确认 Hermes 发现了技能: + +```bash +hermes skills list --source local --enabled-only +``` + +然后启动 Hermes,在会话中使用由技能 `name` 规范化得到的斜杠命令: + +```bash +hermes +``` + +```text +/my-skill +``` + +也可以在自然语言请求中明确要求 Hermes 使用该技能。Hermes 列表显示 `SKILL.md` frontmatter 中的原始 `name`,斜杠命令会把它转为小写、把空格和下划线替换为连字符、移除其他非 `a-z0-9-` 字符,并合并重复连字符。例如 `PDF_Tools` 对应 `/pdf-tools`。该命令不一定与 SkillHub slug 相同。 + +已经运行的会话未立即显示新技能时,执行 `/reload-skills` 或重新启动会话。 + +## 更新技能 + +SkillHub CLI 0.1.8 使用同一安装命令加 `--force` 覆盖本地技能。省略 `--version` 会解析最新已发布版本;也可以显式固定版本: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force + +# 固定版本示例 +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --version 1.2.0 \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force +``` + +覆盖前请先审查新版本,因为 `--force` 会替换现有技能目录。更新后重新运行: + +```bash +skillhub list \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" + +hermes skills list --source local --enabled-only +``` + +> `skillhub update` 更新的是 SkillHub CLI 自身,不会更新已安装技能。已安装技能使用 `skillhub install ... --force` 刷新。 + +## 移除技能 + +先列出同一 registry 中的全部安装,确认没有其他需要保留的同 slug 技能: + +```bash +skillhub list \ + --registry "$SKILLHUB_REGISTRY" +``` + +再移除本地安装: + +```bash +skillhub remove "$SKILLHUB_SKILL" \ + --registry "$SKILLHUB_REGISTRY" +``` + +SkillHub CLI 会同时删除技能目录和本地 inventory 记录。当前版本的本地 `remove` 仅按 registry 和 slug 匹配,不按 namespace 或目录过滤;同一 registry 下所有 namespace、所有安装目录中的相同 slug 都会被移除。如果未过滤的 `skillhub list` 中存在需要保留的匹配项,请不要执行该命令;按 namespace 或目录精确移除需要后续 CLI 能力支持。 + +移除后,使用 `/reload-skills`、重启 Hermes 会话,或运行以下命令确认技能已消失: + +```bash +hermes skills list --source local --enabled-only +``` + +## 可选:使用共享的 external skill 目录 + +如果多个 Agent 共用 `~/.agents/skills`,可以把 SkillHub 技能安装到共享目录,而不是 Hermes 主目录: + +```bash +export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE" + +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$SHARED_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" +``` + +然后把共享根目录合并到 `$HERMES_HOME/config.yaml`,不要覆盖已有的 `skills` 配置: + +```yaml +skills: + external_dirs: + - ~/.agents/skills +``` + +Hermes 会把 external skill 与本地技能一起列出和加载。不要依赖本地技能覆盖 external skill:Hermes 0.18.2 会拒绝加载本地技能目录与 `external_dirs` 之间存在歧义的 `skill_view` 匹配;请改名或移除其中一个冲突副本。 + +> `external_dirs` 不是只读边界。只要 Hermes 进程拥有写权限,Hermes 的技能管理工具就可能修改其中的文件。共享目录需要只读保护时,请使用文件系统权限或隔离的 Hermes profile。 + +## 兼容性与安全边界 + +- **格式兼容不等于运行时完全兼容。** Hermes 能读取 `SKILL.md` 和配套文件,但技能引用的 Agent 专用工具、MCP server、命令、环境变量或平台能力仍需逐项验证。 +- **Hermes 将此路径识别为 local skill。** 通过 SkillHub CLI 复制到本地的技能不会经过 Hermes Skills Hub 的 community 安装扫描。安装前应查看 SkillHub 安全报告并审查技能内容,必要时使用 Hermes 的终端隔离能力。 +- **保留多文件包。** 不要把多文件 SkillHub 技能改成 Hermes 0.18.2 的直接 URL 安装;该版本的 URL source 只保证单个 `SKILL.md`,而 SkillHub CLI 会解压完整包。 +- **避免名称冲突。** 文件路径按 namespace 隔离仍不能解决斜杠命令冲突;同一 Hermes profile 内应保持规范化后的命令名唯一。例如 `PDF Tools` 和 `pdf_tools` 都会变成 `/pdf-tools`。 +- **保护凭证。** Token 只用于 SkillHub registry 访问,不应写进技能包。需要运行时 secret 的技能应遵循 Hermes 的环境变量和安全设置方式。 + +## 常见问题 + +### Hermes 列表中没有新技能 + +依次检查: + +1. 当前会话的 `HERMES_HOME` 是否与安装时一致。 +2. 最终路径下是否存在 `/SKILL.md`。 +3. `SKILL.md` 是否包含有效的 `name` 和 `description`。 +4. `platforms` 等 frontmatter 是否排除了当前操作系统。 +5. 执行 `/reload-skills` 或启动新会话后是否出现。 + +```bash +skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY" +hermes skills list --source local +``` + +### 安装提示 `skill already installed` + +已有目录默认不会被覆盖。先审查目标版本,再增加 `--force`: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force +``` + +### 提示 `registry unreachable` 或下载失败 + +- 核对 `SKILLHUB_REGISTRY` 是否是 SkillHub 根地址。 +- 先运行同一 registry 的 `skillhub search` 判断 registry 是否可达。 +- 检查代理、DNS、证书和自托管服务状态。 +- 短暂网络错误可以在确认服务正常后重试;不要通过关闭 TLS 校验绕过证书问题。 + +### 技能已列出但执行失败 + +检查技能引用的工具名称、shell 命令、脚本解释器、依赖包、MCP server、环境变量和操作系统限制。此类问题属于具体技能的运行时兼容性,不代表 `SKILL.md` 发现链路失败。 + +### 能否直接运行 `hermes skills install` 安装 SkillHub 坐标? + +Hermes 0.18.2 没有 SkillHub registry source,不能直接解析 SkillHub 的 namespace/slug。请使用本指南中的 `skillhub install --dir ...`。如果未来需要 Hermes 内原生搜索、安装、更新和安全扫描,应单独设计 Hermes source adapter,并重新定义协议和验收范围。 + +## 升级后的回归检查 + +升级 SkillHub CLI 或 Hermes 后,至少重新验证: + +1. `skillhub install --dir` 仍生成 `/SKILL.md` 并保留配套文件。 +2. `hermes skills list --source local --enabled-only` 能发现技能。 +3. `/skill-name` 能加载 `SKILL.md` 并暴露配套文件路径;再通过 `skill_view(name, file_path)` 读取一个实际引用文件,或执行技能使用的脚本/资产验证其运行时路径。 +4. `skillhub install --force` 能覆盖更新且 inventory 正常。 +5. `skillhub remove` 后 Hermes 不再发现该技能。 + +上游参考:[Hermes Skills System(v0.18.2)](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md)。 From 6ee746d371a82731c6e72f2117b47a2596bc9f92 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 22 Jul 2026 17:33:26 +0800 Subject: [PATCH 73/78] chore(cli): bump version to 0.1.9 Signed-off-by: dongmucat <1127093059@qq.com> --- cli/package.json | 2 +- cli/src/generated/pkg-info.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/package.json b/cli/package.json index 3845975b..a7b94f7d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@astron-team/skillhub", - "version": "0.1.8", + "version": "0.1.9", "description": "Manage and install skills for AI coding agents", "keywords": [ "skillhub", diff --git a/cli/src/generated/pkg-info.ts b/cli/src/generated/pkg-info.ts index bc445c51..9445834b 100644 --- a/cli/src/generated/pkg-info.ts +++ b/cli/src/generated/pkg-info.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-pkg-info.ts - do not edit by hand. export const PKG_NAME = "@astron-team/skillhub" -export const PKG_VERSION = "0.1.8" +export const PKG_VERSION = "0.1.9" From e5f0cc140a94484ee07ecd7eb728a1a84a81af35 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:49:38 +0800 Subject: [PATCH 74/78] docs(faq): add community-sourced deployment and operations Q&A (#593) * docs(faq): add community-sourced deployment and operations Q&A Adds entries collected from real user-support threads to the reference FAQ (both zh and en): - 502 on auth APIs while the page loads, traced to server startup failure on the SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET placeholder guard - config changes not taking effect (.env.release.example vs .env.release, restart vs recreate) - built-in skill sync failure in offline environments - upgrade path with Flyway auto-migration and volume retention - external dependencies and the lack of MySQL support - granting SUPER_ADMIN to an OAuth account via the bootstrap admin - telling CLI and server versions apart - installing skills into a target directory on an intranet Signed-off-by: FenjuFu * docs(faq): move entries to the published docs source and fix inaccuracies Move the new FAQ entries from document/ (a generated tree that the docs build does not read) to docs/skillhub/, which is what make docs-build and the Pages deploy actually publish. Also address review feedback: - drop the SKILLHUB_BUILTIN_SKILLS_ENABLED tip; compose.release.yml does not pass that variable through, so setting it has no effect - correct the dependency list: object storage defaults to local, S3 is recommended for production - soften the 502 wording, since upstream/DNS/network can also cause it - state the 32-character minimum for the cookie secret - give a real bulk-install example and qualify v0.2.12 as a server version - drop entries already covered by existing upgrade/MySQL/version questions Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> * docs(faq): correct deployment and admin guidance Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * docs(faq): fix remaining recreate guidance Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * docs(faq): clarify bulk install paths Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: FenjuFu Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/skillhub/en/faq.md | 83 +++++++++++++++++++++++++++++++++++++++-- docs/skillhub/faq.md | 83 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 9cf6d556..7cc4500f 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -156,10 +156,18 @@ A: This is most commonly seen with **manual deployment** (caused by API errors o ## Q: How do I change the admin password? Why don't my config changes take effect? -A: Environment variables are read at container startup, so you must restart the containers after changing them. +A: Environment variables are injected when a container is created, so you must recreate the containers after changing them; `restart` alone does not re-inject environment variables. 1. Edit `/tmp/skillhub-runtime/.env.release` in the runtime directory (refer to [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example)). -2. Restart the relevant containers. +2. Recreate the relevant containers: + + ```bash + docker compose \ + --env-file /tmp/skillhub-runtime/.env.release \ + -f /tmp/skillhub-runtime/compose.release.yml \ + up -d --force-recreate + ``` + 3. If the password was already persisted to the database and the change still doesn't take effect, you may need to clear the corresponding data and re-initialize. ## Q: Is an email verification code required to change / reset a password? @@ -219,7 +227,7 @@ A: The default limit is **100 files** (this is separate from the 100MB size limi SKILLHUB_PUBLISH_MAX_FILE_COUNT=500 ``` -Restart the containers for the change to take effect. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended. +Recreate the containers for the change to take effect; `restart` alone does not re-inject environment variables. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended. ## Q: Is there a server version requirement for using the CLI (publish / download, etc.)? @@ -246,6 +254,75 @@ docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .C - Check the CLI version: `skillhub version`. - For customization (e.g. changing the logo), it is recommended to fork the latest code, modify it, and build your own Docker image. +## Q: The page loads, but the login / register APIs return 502? + +A: The page is served by the `web` container, while login, register and other APIs are proxied by `web` to `server` (default `SKILLHUB_API_UPSTREAM=http://server:8080`). When the page works but the API returns 502, check whether `server` started correctly first; a wrong upstream, DNS, or container-network problem can also produce a 502. + +Troubleshooting order: + +```bash +# 1. Check whether server is running +docker compose --env-file .env.release -f compose.release.yml ps + +# 2. Look at the first error in the server startup log +docker compose --env-file .env.release -f compose.release.yml logs server | head -50 +``` + +One common startup failure is: + +``` +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder +``` + +This means `server` still reads the placeholder from the template. Replace it in `.env.release` with your own random string (**at least 32 characters**) and recreate the containers: + +```bash +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET= +``` + +Running `make validate-release-config` before startup validates `.env.release` and surfaces placeholders and missing values early. + +## Q: Why doesn't my configuration change take effect? + +A: Two common causes: + +1. **Edited the wrong file**: `.env.release.example` is only a template; Compose reads the file passed via `--env-file`, i.e. `.env.release`. Run `cp .env.release.example .env.release` first, then edit `.env.release`. +2. **Restarted instead of recreated**: environment variables are injected when the container is created, and `restart` does not re-inject them. Recreate the containers after a config change: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate +``` + +## Q: What external dependencies does SkillHub require at runtime? + +A: PostgreSQL and Redis are required. Object storage supports both `local` and S3, controlled by `SKILLHUB_STORAGE_PROVIDER`. `.env.release.example` explicitly selects `local`, but if the variable is completely unset when using `compose.release.yml`, the Compose fallback is `s3`. Set it explicitly; S3 is recommended for production (configured via `SKILLHUB_STORAGE_S3_*`). Only PostgreSQL is supported as the database — MySQL is not. + +The release Compose file already bundles PostgreSQL and Redis, bound to `127.0.0.1` by default. + +## Q: How does an account created through OAuth (GitHub / GitLab, etc.) get admin rights? + +A: The first OAuth login creates a regular user. An existing `SUPER_ADMIN` (for example the bootstrap admin created during initialization) has to promote it from the admin console. + +A `USER_ADMIN` can manage user status and assign platform roles other than `SUPER_ADMIN`, but cannot grant `SUPER_ADMIN` to any account or change the role of an existing `SUPER_ADMIN`. Only a `SUPER_ADMIN` can perform those two operations. + +## Q: How do I install multiple skills in bulk? + +A: The CLI `install` command handles one skill at a time. Both examples below use `--dir` to install the skills under the same target root; each skill is placed in `$target_dir//`: + +```bash +target_dir=/opt/skillhub-skills + +# install one by one +for skill in skill-a skill-b skill-c; do + skillhub install "$skill" --dir "$target_dir" +done + +# or read from a manifest file (one skill name per line) +xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir" +``` + +Since **SkillHub Server v0.2.12**, public skills support anonymous search and install. Note that an invalid bearer token now fails the command instead of falling back to anonymous access — update or remove the stale credential in that case. + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index 2aac0a54..b16ecd81 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -156,10 +156,18 @@ A: 该现象多见于「手动部署」场景(接口异常或初始化未完 ## Q: 如何修改 admin 密码?修改配置后不生效? -A: 环境变量在容器启动时读取,修改后必须重启容器才会生效。 +A: 环境变量在容器创建时注入,修改后必须重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。 1. 修改运行时目录下的 `/tmp/skillhub-runtime/.env.release`(参考仓库 [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example))。 -2. 重启相关容器。 +2. 重新创建相关容器: + + ```bash + docker compose \ + --env-file /tmp/skillhub-runtime/.env.release \ + -f /tmp/skillhub-runtime/compose.release.yml \ + up -d --force-recreate + ``` + 3. 若此前密码已写入数据库导致仍不生效,可能需要清理对应数据后重新初始化。 ## Q: 修改 / 找回密码必须使用邮箱验证码吗? @@ -219,7 +227,7 @@ A: 默认上限为 **100 个文件**(这与 100MB 的大小限制是两回事 SKILLHUB_PUBLISH_MAX_FILE_COUNT=500 ``` -修改后需重启容器生效。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。 +修改后需重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。 ## Q: 使用 CLI(发布 / 下载等)对服务端版本有要求吗? @@ -246,6 +254,75 @@ docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .C - 查看 CLI 版本:`skillhub version`。 - 如需定制(如修改 logo 等),建议基于最新代码进行二次开发并自行构建 docker 镜像。 +## Q: 页面能打开,但登录 / 注册接口返回 502? + +A: 页面由 `web` 容器提供,登录、注册等接口由 `web` 转发给 `server`(默认 `SKILLHUB_API_UPSTREAM=http://server:8080`)。出现「页面正常但 API 502」时,通常先检查 `server` 是否正常启动;upstream 配置、DNS 或容器网络异常也可能返回 502。 + +排查顺序: + +```bash +# 1. 看 server 是否处于运行状态 +docker compose --env-file .env.release -f compose.release.yml ps + +# 2. 看 server 启动日志中的第一条错误 +docker compose --env-file .env.release -f compose.release.yml logs server | head -50 +``` + +一条常见的启动失败日志是: + +``` +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder +``` + +说明 `server` 读到的仍是模板里的占位值。在 `.env.release` 中改成自己的随机字符串(**至少 32 个字符**)后重建容器即可: + +```bash +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<替换成你自己的随机字符串,至少 32 个字符> +``` + +启动前可以先执行 `make validate-release-config`,它会校验 `.env.release`,提前暴露这类占位值和缺失项。 + +## Q: 改了配置为什么不生效? + +A: 两个高频原因: + +1. **改错了文件**:`.env.release.example` 只是模板,Compose 实际读取的是 `--env-file` 指定的 `.env.release`。请先 `cp .env.release.example .env.release`,然后修改 `.env.release`。 +2. **只重启没重建**:环境变量在容器创建时注入,`restart` 不会重新注入。改完配置需要重建容器: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate +``` + +## Q: SkillHub 运行时需要哪些外部依赖? + +A: 必需 PostgreSQL 和 Redis;对象存储支持 `local` 与 S3 两种模式,由 `SKILLHUB_STORAGE_PROVIDER` 控制。`.env.release.example` 显式配置为 `local`,但如果使用 `compose.release.yml` 时完全没有设置该变量,Compose 的回退值是 `s3`。建议始终显式设置;生产环境推荐使用 S3(通过 `SKILLHUB_STORAGE_S3_*` 配置)。数据库仅支持 PostgreSQL,暂不支持 MySQL。 + +发布版 Compose 已内置 PostgreSQL 与 Redis,默认只绑定在 `127.0.0.1`。 + +## Q: 通过 OAuth(GitHub / GitLab 等)登录的账号,如何取得管理员权限? + +A: OAuth 首次登录创建的是普通用户。需要由已有的 `SUPER_ADMIN`(例如初始化时的 bootstrap admin)在后台将其提升为管理员。 + +`USER_ADMIN` 可以管理用户状态,并分配除 `SUPER_ADMIN` 之外的平台角色;但不能向任何账号授予 `SUPER_ADMIN`,也不能修改已有 `SUPER_ADMIN` 账号的角色。这两类操作只有 `SUPER_ADMIN` 可以执行。 + +## Q: 如何批量安装多个技能包? + +A: CLI 的 `install` 一次处理一个技能包。下面两个示例都通过 `--dir` 将技能批量安装到同一个目标根目录;每个技能实际位于 `$target_dir//`: + +```bash +target_dir=/opt/skillhub-skills + +# 逐个安装 +for skill in skill-a skill-b skill-c; do + skillhub install "$skill" --dir "$target_dir" +done + +# 或从清单文件读取(每行一个技能名) +xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir" +``` + +自 **SkillHub Server v0.2.12** 起,公开技能支持匿名搜索与安装;如果配置了无效的 Bearer Token,命令会直接失败而不再回退匿名访问,遇到这种情况请更新凭据或先移除无效 Token。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: From 4fdc7e3dc5c63b786d45ed1004b79c0450c5add1 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:57:35 +0800 Subject: [PATCH 75/78] fix(publish): delete review tasks of any status when replacing a version (#601) * fix(publish): delete review tasks of any status when replacing a version Re-uploading a rejected version under the same version number returned HTTP 500. deleteReplaceableVersionArtifacts only removed a PENDING review task, but a rejected version owns a REJECTED one; that row kept a foreign key on the skill_version, so the subsequent delete hit a constraint violation that surfaced as a 500. Delete every review task attached to the version instead. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> * test(publish): drop the spring-test dependency from the new test skillhub-domain has no spring-test on its test classpath, so ReflectionTestUtils does not resolve there. Use plain JDK reflection for setting the generated id and invoking the private method. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> * fix(publish): constrain rejected version replacement Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * test(publish): verify replaced review is deleted Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * test(e2e): use generated API response types Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skill/service/SkillPublishService.java | 14 ++- .../service/SkillPublishServiceTest.java | 62 ++++++++++++-- web/e2e/helpers/test-data-builder.ts | 61 ++++++++++--- web/e2e/rejected-version-republish.spec.ts | 85 +++++++++++++++++++ 4 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 web/e2e/rejected-version-republish.spec.ts 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 c204306a..610c8203 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 @@ -62,6 +62,12 @@ public class SkillPublishService { private static final DateTimeFormatter AUTO_VERSION_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss").withZone(ZoneId.systemDefault()); + private static final Set REPLACEABLE_VERSION_STATUSES = Set.of( + SkillVersionStatus.DRAFT, + SkillVersionStatus.SCAN_FAILED, + SkillVersionStatus.UPLOADED, + SkillVersionStatus.REJECTED + ); private static final Logger log = LoggerFactory.getLogger(SkillPublishService.class); public record PublishResult( @@ -566,7 +572,7 @@ public class SkillPublishService { } private void deleteReplaceableVersionArtifacts(Skill skill, SkillVersion version, String namespaceSlug) { - if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + if (!REPLACEABLE_VERSION_STATUSES.contains(version.getStatus())) { throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); } @@ -577,8 +583,10 @@ public class SkillPublishService { skillRepository.flush(); } - reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) - .ifPresent(reviewTaskRepository::delete); + // Every review task referencing this version has to go, not just a PENDING one: + // a rejected version still owns a REJECTED task whose foreign key blocks the + // skill_version delete below, which surfaces to the caller as an HTTP 500. + reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId())); List files = skillFileRepository.findByVersionId(version.getId()); List storageKeys = new ArrayList<>(); 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 73f118fd..a75f971f 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 @@ -260,7 +260,7 @@ class SkillPublishServiceTest { } @Test - void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception { + void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception { String namespaceSlug = "test-ns"; String publisherId = "user-100"; String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; @@ -275,9 +275,9 @@ class SkillPublishServiceTest { Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 1L); - SkillVersion draftVersion = new SkillVersion(1L, "1.0.0", publisherId); - draftVersion.setStatus(SkillVersionStatus.DRAFT); - setId(draftVersion, 8L); + SkillVersion rejectedVersion = new SkillVersion(1L, "1.0.0", publisherId); + rejectedVersion.setStatus(SkillVersionStatus.REJECTED); + setId(rejectedVersion, 8L); SkillFile oldFile = new SkillFile(8L, "SKILL.md", (long) skillMdContent.length(), "text/markdown", "abc", "skills/1/8/SKILL.md"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -288,7 +288,7 @@ class SkillPublishServiceTest { 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()); - when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(draftVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(rejectedVersion)); when(skillFileRepository.findByVersionId(8L)).thenReturn(List.of(oldFile)); when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { SkillVersion saved = invocation.getArgument(0); @@ -309,10 +309,60 @@ class SkillPublishServiceTest { assertEquals("1.0.0", result.version().getVersion()); assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L)); verify(skillFileRepository).deleteByVersionId(8L); - verify(skillVersionRepository).delete(draftVersion); + verify(skillVersionRepository).delete(rejectedVersion); verify(skillVersionRepository).flush(); verify(objectStorageService).deleteObjects(List.of("skills/1/8/SKILL.md", "packages/1/8/bundle.zip")); + + ArgumentCaptor reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class); + verify(reviewTaskRepository).save(reviewTaskCaptor.capture()); + assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId()); + assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy()); + } + + @Test + void testPublishFromEntries_ShouldRejectReplacementOfYankedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + 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 skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillVersion yankedVersion = new SkillVersion(1L, "1.0.0", publisherId); + yankedVersion.setStatus(SkillVersionStatus.YANKED); + setId(yankedVersion, 8L); + + 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.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(yankedVersion)); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> + service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + + assertEquals("error.skill.version.exists", exception.messageCode()); + verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList()); + verify(skillVersionRepository, never()).delete(any()); + verify(skillFileRepository, never()).deleteByVersionId(any()); } @Test diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index f255ea4a..71cd9ebd 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' import path from 'node:path' import type { APIRequestContext, Page, TestInfo } from '@playwright/test' +import type { components } from '../../src/api/generated/schema' import { csrfHeaders } from './csrf' type CleanupTask = () => Promise @@ -32,14 +33,9 @@ export interface SeededReviewData { skill: SeededSkill } -interface ReviewTaskSummary { - id: number - namespace: string - skillSlug: string - status: string - submittedBy: string - version: string -} +type ReviewTaskResponse = components['schemas']['ReviewTaskResponse'] +type SkillVersionResponse = components['schemas']['SkillVersionResponse'] +type SkillVersionStatus = NonNullable interface NamespaceCandidate { userId: string @@ -463,19 +459,17 @@ export class E2eTestDataBuilder { async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise { for (let attempt = 0; attempt < 20; attempt += 1) { try { - const page = await parseEnvelope<{ - items: ReviewTaskSummary[] - }>( + const page = await parseEnvelope( await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'), ) - const matched = page.items.find((item) => + const matched = page.items?.find((item) => item.namespace === namespaceSlug && item.skillSlug === skillSlug && item.version === version && item.status === 'PENDING', ) - if (matched) { + if (matched?.id != null) { return matched.id } } catch { @@ -488,6 +482,38 @@ export class E2eTestDataBuilder { throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`) } + async waitForVersionStatus( + namespaceSlug: string, + skillSlug: string, + version: string, + expectedStatus: SkillVersionStatus, + ): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const page = await parseEnvelope( + await this.request.get( + `/api/web/skills/${encodeURIComponent(namespaceSlug)}/${encodeURIComponent(skillSlug)}/versions?page=0&size=100`, + ), + ) + + const matched = page.items?.find((item) => + item.version === version && item.status === expectedStatus, + ) + if (matched?.id != null) { + return matched.id + } + } catch { + // Security scanning and version projection can complete asynchronously. + } + + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + + throw new Error( + `Timed out waiting for ${namespaceSlug}/${skillSlug}@${version} to reach ${expectedStatus}`, + ) + } + async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { let lastError: unknown for (let attempt = 0; attempt < 60; attempt += 1) { @@ -512,6 +538,15 @@ export class E2eTestDataBuilder { throw lastError instanceof Error ? lastError : new Error('approveReview timed out') } + async rejectReview(reviewTaskId: number, comment = 'Rejected by Playwright E2E'): Promise { + await parseEnvelope( + await this.request.post(`/api/web/reviews/${reviewTaskId}/reject`, { + data: { comment }, + headers: await csrfHeaders(this.page), + }), + ) + } + async searchNamespaceMemberCandidates(slug: string, search: string): Promise { const query = new URLSearchParams({ search }) return parseEnvelope( diff --git a/web/e2e/rejected-version-republish.spec.ts b/web/e2e/rejected-version-republish.spec.ts new file mode 100644 index 00000000..cbbcada4 --- /dev/null +++ b/web/e2e/rejected-version-republish.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { loginWithCredentials, registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function adminCredentials() { + return { + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', + } +} + +test.describe('Rejected version replacement (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => { + const publisherBuilder = new E2eTestDataBuilder(page, testInfo) + await publisherBuilder.init() + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + try { + const namespace = await publisherBuilder.ensureWritableNamespace() + const skillName = `replace-rejected-${Date.now().toString(36)}` + const firstPublish = await publisherBuilder.publishSkill(namespace.slug, { + name: skillName, + version: '1.0.0', + }) + const rejectedReviewId = await adminBuilder.waitForPendingReview( + namespace.slug, + firstPublish.slug, + firstPublish.version, + ) + await publisherBuilder.waitForVersionStatus( + namespace.slug, + firstPublish.slug, + firstPublish.version, + 'PENDING_REVIEW', + ) + await adminBuilder.rejectReview(rejectedReviewId) + + const replacement = await publisherBuilder.publishSkill(namespace.slug, { + name: skillName, + description: 'Replacement after review rejection', + version: '1.0.0', + }) + const replacementReviewId = await adminBuilder.waitForPendingReview( + namespace.slug, + replacement.slug, + replacement.version, + ) + await publisherBuilder.waitForVersionStatus( + namespace.slug, + replacement.slug, + replacement.version, + 'PENDING_REVIEW', + ) + + expect(replacement.skillId).toBe(firstPublish.skillId) + expect(replacement.version).toBe(firstPublish.version) + expect(replacementReviewId).not.toBe(rejectedReviewId) + + const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`) + expect(replacedReviewResponse.status()).toBe(404) + } finally { + await adminBuilder.cleanup() + await adminContext.close() + await publisherBuilder.cleanup() + } + }) +}) From 1d679c526ab498cba1d56a761d20d7028195e8d2 Mon Sep 17 00:00:00 2001 From: "1664940968@qq.com" <1664940968@qq.com> Date: Tue, 28 Jul 2026 16:36:47 +0800 Subject: [PATCH 76/78] fix(auth): recover login page from stale lazy-loaded chunks after logout (#560) * fix(auth): recover from stale login chunks after logout * fix(auth): prevent repeated stale chunk reloads Signed-off-by: ylhu16 --------- Signed-off-by: ylhu16 Co-authored-by: ylhu16 --- web/src/app/router.tsx | 11 ++- .../lib/dynamic-import-recovery.test.ts | 87 +++++++++++++++++++ web/src/shared/lib/dynamic-import-recovery.ts | 48 ++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 web/src/shared/lib/dynamic-import-recovery.test.ts create mode 100644 web/src/shared/lib/dynamic-import-recovery.ts diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index c9b3bac8..ec9749a5 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -4,6 +4,7 @@ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' import { createRequireAuth } from '@/shared/lib/auth-route' +import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery' import { normalizeSearchQuery } from '@/shared/lib/search-query' /** @@ -25,7 +26,15 @@ function createLazyRouteComponent>( // Lazy route modules are wrapped in a uniform suspense fallback so route transitions behave // consistently across public and dashboard pages. const LazyComponent = lazy(async () => { - const module = await importer() + const module = await importer().catch((error) => { + if (recoverFromDynamicImportError(error)) { + return new Promise(() => {}) + } + throw error + }) + // Router resolution can finish before React.lazy imports the route module. Only clear the + // one-time reload guard after the chunk itself has loaded successfully. + clearDynamicImportReloadGuard() return { default: module[exportName] as ComponentType> } }) diff --git a/web/src/shared/lib/dynamic-import-recovery.test.ts b/web/src/shared/lib/dynamic-import-recovery.test.ts new file mode 100644 index 00000000..8cba7774 --- /dev/null +++ b/web/src/shared/lib/dynamic-import-recovery.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearDynamicImportReloadGuard, + isDynamicImportFetchError, + recoverFromDynamicImportError, +} from './dynamic-import-recovery' + +const values = new Map() +const reload = vi.fn() +const sessionStorage = { + get length() { + return values.size + }, + clear: vi.fn(() => values.clear()), + getItem: vi.fn((key: string) => values.get(key) ?? null), + key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), + removeItem: vi.fn((key: string) => values.delete(key)), + setItem: vi.fn((key: string, value: string) => values.set(key, value)), +} satisfies Storage + +describe('dynamic import recovery', () => { + beforeEach(() => { + values.clear() + reload.mockClear() + vi.stubGlobal('window', { + location: { reload }, + sessionStorage, + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each([ + 'Failed to fetch dynamically imported module: /assets/login.js', + 'error loading dynamically imported module: /assets/login.js', + 'Importing a module script failed', + 'ChunkLoadError: Loading chunk 42 failed', + ])('recognizes a stale dynamic import error: %s', (message) => { + expect(isDynamicImportFetchError(new Error(message))).toBe(true) + }) + + it('ignores unrelated errors', () => { + expect(isDynamicImportFetchError(new Error('Request failed with status 500'))).toBe(false) + }) + + it('recognizes errors whose name is ChunkLoadError', () => { + const error = new Error('Loading chunk 42 failed') + error.name = 'ChunkLoadError' + + expect(isDynamicImportFetchError(error)).toBe(true) + }) + + it('reloads only once while the recovery guard is active', () => { + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(true) + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('allows recovery again after a dynamic import succeeds', () => { + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(true) + clearDynamicImportReloadGuard() + expect(recoverFromDynamicImportError(error)).toBe(true) + expect(reload).toHaveBeenCalledTimes(2) + }) + + it('does not mask the original import error when session storage is unavailable', () => { + vi.stubGlobal('window', { + location: { reload }, + get sessionStorage() { + throw new DOMException('Access denied', 'SecurityError') + }, + }) + + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(() => clearDynamicImportReloadGuard()).not.toThrow() + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/shared/lib/dynamic-import-recovery.ts b/web/src/shared/lib/dynamic-import-recovery.ts new file mode 100644 index 00000000..27c04264 --- /dev/null +++ b/web/src/shared/lib/dynamic-import-recovery.ts @@ -0,0 +1,48 @@ +const RELOAD_GUARD_KEY = 'skillhub:dynamic-import-reload' + +function resolveErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + return String(error ?? '') +} + +export function isDynamicImportFetchError(error: unknown): boolean { + const message = resolveErrorMessage(error) + return (error instanceof Error && error.name === 'ChunkLoadError') + || message.includes('Failed to fetch dynamically imported module') + || message.includes('error loading dynamically imported module') + || message.includes('Importing a module script failed') + || message.includes('ChunkLoadError') +} + +export function recoverFromDynamicImportError(error: unknown): boolean { + if (typeof window === 'undefined' || !isDynamicImportFetchError(error)) { + return false + } + + let sessionStorage: Storage + try { + sessionStorage = window.sessionStorage + if (sessionStorage.getItem(RELOAD_GUARD_KEY) === '1') { + return false + } + sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + } catch { + return false + } + + window.location.reload() + return true +} + +export function clearDynamicImportReloadGuard(): void { + if (typeof window === 'undefined') { + return + } + try { + window.sessionStorage.removeItem(RELOAD_GUARD_KEY) + } catch { + // Session storage can be unavailable in restricted browsing contexts. + } +} From d977ea9dc41cd226985fee3374a39e669a32fc42 Mon Sep 17 00:00:00 2001 From: gale-popai Date: Tue, 28 Jul 2026 12:42:20 +0300 Subject: [PATCH 77/78] fix(api): tell callers why a request was forbidden (#610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): tell callers why a request was forbidden The scope filter already computes an exact reason ("Missing API token scope: skill:delete", "API token cannot access endpoint: /x") and the access-denied handler discarded it, returning a bare "Forbidden" for every case: missing scope, endpoint closed to API tokens, and paths that simply don't exist. Clients cannot tell those apart, so they guess — the published CLI reports every 403 as "token may lack required scope", which sent us debugging token scopes for an hour when the real causes were a revoked token and a mistyped namespace path. The reason now rides in the response via a new error.forbidden.detail message (en + zh), and is logged alongside the exception type. Signed-off-by: Gal Eyal Co-Authored-By: Claude Fable 5 * fix(api): safely expose API token denial reasons Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: Claude Fable 5 Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- cli/src/clients/skillhub-client.ts | 32 +++- cli/src/shared/output.ts | 3 + cli/test/helpers/fake-registry.ts | 8 +- cli/test/integration/publish-dry-run.test.ts | 1 + cli/test/unit/clients/skillhub-client.test.ts | 48 +++++- cli/test/unit/shared/output.test.ts | 2 + docs/03-authentication-design.md | 1 + docs/skillhub/en/guide/cli.md | 2 + docs/skillhub/guide/cli.md | 2 + .../security/ApiAccessDeniedHandler.java | 18 ++- .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../security/ApiAccessDeniedHandlerTest.java | 137 ++++++++++++++++++ .../token/ApiTokenAccessDeniedException.java | 42 ++++++ .../auth/token/ApiTokenScopeFilter.java | 10 +- .../auth/token/ApiTokenScopeFilterTest.java | 10 ++ 16 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index 14e14ec3..8d008483 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -52,6 +52,11 @@ export interface DryRunResponse { resolvedVersion: string | null } +interface ErrorEnvelope { + msg?: unknown + requestId?: unknown +} + export class SkillHubClient { constructor( readonly registry: string, @@ -88,9 +93,12 @@ export class SkillHubClient { } catch { throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) } - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' }) } + if (response.status === 403) { + throw await this.createAccessDeniedError(response) + } if (response.status === 404) { throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry }) } @@ -155,7 +163,7 @@ export class SkillHubClient { throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' }) } if (response.status === 403) { - throw new CliError('access denied — token may lack required scope', EXIT.auth, { registry: this.registry, next: 'regenerate token with required scopes or run `skillhub login`' }) + throw await this.createAccessDeniedError(response) } if (response.status === 404) { throw new CliError('resource not found', EXIT.generic, { registry: this.registry }) @@ -172,6 +180,26 @@ export class SkillHubClient { return body.data as T } + private async createAccessDeniedError(response: Response): Promise { + const error = await this.readErrorEnvelope(response) + return new CliError(error.message ?? 'access denied', EXIT.auth, { + registry: this.registry, + ...(error.requestId ? { requestId: error.requestId } : {}) + }) + } + + private async readErrorEnvelope(response: Response): Promise<{ message?: string; requestId?: string }> { + try { + const body = await response.json() as ErrorEnvelope + return { + ...(typeof body.msg === 'string' && body.msg.trim() ? { message: body.msg } : {}), + ...(typeof body.requestId === 'string' && body.requestId.trim() ? { requestId: body.requestId } : {}) + } + } catch { + return {} + } + } + private headers(): HeadersInit { return this.token ? { Authorization: `Bearer ${this.token}` } : {} } diff --git a/cli/src/shared/output.ts b/cli/src/shared/output.ts index 9b2eafd9..977116bc 100644 --- a/cli/src/shared/output.ts +++ b/cli/src/shared/output.ts @@ -30,6 +30,9 @@ export function renderError(error: unknown, json: boolean): string { if (typeof cliError.details.path === 'string') { lines.push(`Context: path ${cliError.details.path}`) } + if (typeof cliError.details.requestId === 'string') { + lines.push(`Request ID: ${cliError.details.requestId}`) + } if (typeof cliError.details.next === 'string') { lines.push(`Next: ${cliError.details.next}`) } diff --git a/cli/test/helpers/fake-registry.ts b/cli/test/helpers/fake-registry.ts index a3ea9ef8..4fde3a95 100644 --- a/cli/test/helpers/fake-registry.ts +++ b/cli/test/helpers/fake-registry.ts @@ -22,7 +22,7 @@ export function createFakeRegistry(handlers: Record) { /** * Controls how a specific endpoint behaves when a failure is injected: * 'auth' => 401 { code: 401, message: 'unauthorized' } - * 'forbidden' => 403 { code: 403, message: 'forbidden' } + * 'forbidden' => 403 with a standard SkillHub error envelope * 'not_found' => 404 { code: 404, message: 'not found' } * 'server_error' => 500 { code: 500, message: 'internal error' } * 'network' => handler throws, causing fetch() to reject with a TypeError @@ -34,7 +34,11 @@ function failureResponse(mode: FailureMode): Response { case 'auth': return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) case 'forbidden': - return Response.json({ code: 403, message: 'forbidden' }, { status: 403 }) + return Response.json({ + code: 403, + msg: 'API token is missing required scope: skill:publish', + requestId: 'req-test-forbidden' + }, { status: 403 }) case 'not_found': return Response.json({ code: 404, message: 'not found' }, { status: 404 }) case 'server_error': diff --git a/cli/test/integration/publish-dry-run.test.ts b/cli/test/integration/publish-dry-run.test.ts index deabb7b2..456572d1 100644 --- a/cli/test/integration/publish-dry-run.test.ts +++ b/cli/test/integration/publish-dry-run.test.ts @@ -172,5 +172,6 @@ describe('publish --dry-run', () => { expect(result.exitCode).toBe(2) expect(result.stderr).toContain('scope') + expect(result.stderr).toContain('Request ID: req-test-forbidden') }) }) diff --git a/cli/test/unit/clients/skillhub-client.test.ts b/cli/test/unit/clients/skillhub-client.test.ts index c07083c2..e545e99f 100644 --- a/cli/test/unit/clients/skillhub-client.test.ts +++ b/cli/test/unit/clients/skillhub-client.test.ts @@ -37,12 +37,21 @@ describe('SkillHubClient', () => { }) test('download() throws auth error on 403', async () => { - const fetchImpl = (async () => new Response(null, { status: 403 })) as unknown as typeof fetch + const fetchImpl = (async () => Response.json({ + code: 403, + msg: 'API token is missing required scope: skill:read', + requestId: 'req-download' + }, { status: 403 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) - const err = expect(client.download('ns', 'slug')).rejects - await err.toBeInstanceOf(CliError) - await err.toHaveProperty('message', 'authentication failed') - await err.toHaveProperty('exitCode', EXIT.auth) + + await expect(client.download('ns', 'slug')).rejects.toMatchObject({ + message: 'API token is missing required scope: skill:read', + exitCode: EXIT.auth, + details: { + registry: 'http://registry.test', + requestId: 'req-download' + } + }) }) test('download() throws not-found error on 404', async () => { @@ -159,6 +168,35 @@ describe('SkillHubClient', () => { // --- handleJsonResponse() non-2xx classification --- + test('whoami() surfaces server reason and request ID on 403', async () => { + const fetchImpl = (async () => Response.json({ + code: 403, + msg: 'API token cannot access endpoint: /api/cli/v1/whoami', + requestId: 'req-610' + }, { status: 403 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'API token cannot access endpoint: /api/cli/v1/whoami', + exitCode: EXIT.auth, + details: { + registry: 'http://registry.test', + requestId: 'req-610' + } + }) + }) + + test('whoami() falls back to generic access denied when 403 body is invalid', async () => { + const fetchImpl = (async () => new Response('not-json', { status: 403 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'access denied', + exitCode: EXIT.auth, + details: { registry: 'http://registry.test' } + }) + }) + test('whoami() throws generic error on 500', async () => { const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) diff --git a/cli/test/unit/shared/output.test.ts b/cli/test/unit/shared/output.test.ts index 8d051172..d71bcce0 100644 --- a/cli/test/unit/shared/output.test.ts +++ b/cli/test/unit/shared/output.test.ts @@ -16,11 +16,13 @@ describe('renderError', () => { test('renders human error without stack trace', () => { const error = new CliError('registry unreachable', 3, { registry: 'https://registry.example.com', + requestId: 'req-610', next: 'check network or pass --registry' }) expect(renderError(error, false)).toBe([ 'Error: registry unreachable', 'Context: registry https://registry.example.com', + 'Request ID: req-610', 'Next: check network or pass --registry' ].join('\n')) }) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index b4fb0cd1..2be902e8 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -379,6 +379,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 - 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` +- 拒绝原因:API Token 缺少作用域或不能访问某个接口时,403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常 > **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 6cbc779c..e4fa2842 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com `login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`. +When an API-token request is denied, the CLI shows the safe reason returned by the server and its `Request ID`. Use that ID to correlate the failure with server logs. Other authorization failures continue to use a generic message. + ### Check Current Identity ```bash diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 22162d56..dfadf6d3 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com `login` 会验证 token 有效性,然后将 token 存储到 `~/.skillhub/credentials.json`,同时将 registry 写入 `~/.skillhub/config.json`。 +API Token 请求被拒绝时,CLI 会显示服务端返回的具体原因和 `Request ID`。排查问题时可使用该 ID 对照服务端日志;非 API Token 的授权失败仍只显示通用信息。 + ### 查看当前身份 ```bash diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 81cebbde..2c930aa6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.security; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; @@ -38,14 +39,25 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler { public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + ApiTokenAccessDeniedException apiTokenException = + accessDeniedException instanceof ApiTokenAccessDeniedException typedException + ? typedException + : null; logger.info( - "Forbidden API request [requestId={}, method={}, path={}, reason={}]", + "Forbidden API request [requestId={}, method={}, path={}, reason={}, detail={}]", MDC.get("requestId"), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), - accessDeniedException.getClass().getSimpleName() + accessDeniedException.getClass().getSimpleName(), + apiTokenException != null ? apiTokenException.getMessage() : null ); - ApiResponse body = apiResponseFactory.error(403, "error.forbidden"); + ApiResponse body = apiTokenException != null + ? apiResponseFactory.error( + 403, + apiTokenException.getMessageCode(), + apiTokenException.getMessageArgs() + ) + : apiResponseFactory.error(403, "error.forbidden"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); objectMapper.writeValue(response.getOutputStream(), body); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 19189122..79195af7 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -48,6 +48,8 @@ error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap pr error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found error.badRequest=Invalid request error.forbidden=Forbidden +error.apiToken.scope.missing=API token is missing required scope: {0} +error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0} error.request.timeout=Request timed out error.rateLimit.exceeded=Rate limit exceeded error.storage.unavailable=Object storage is temporarily unavailable. Please try again later. diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 6885dfd3..d7b6b11b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -48,6 +48,8 @@ error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供 error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 error.badRequest=请求参数不合法 error.forbidden=没有权限执行该操作 +error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0} +error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0} error.request.timeout=请求超时 error.rateLimit.exceeded=请求过于频繁,请稍后再试 error.storage.unavailable=对象存储暂时不可用,请稍后再试 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java new file mode 100644 index 00000000..db8982b7 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java @@ -0,0 +1,137 @@ +package com.iflytek.skillhub.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; +import com.iflytek.skillhub.auth.token.ApiTokenScopeService; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import jakarta.servlet.FilterChain; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +class ApiAccessDeniedHandlerTest { + + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private ApiAccessDeniedHandler handler; + + @BeforeEach + void setUp() { + ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); + messageSource.setBasename("messages"); + messageSource.setDefaultEncoding("UTF-8"); + ApiResponseFactory responseFactory = new ApiResponseFactory( + messageSource, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC) + ); + handler = new ApiAccessDeniedHandler( + objectMapper, + responseFactory, + new SensitiveLogSanitizer() + ); + MDC.put("requestId", "req-610"); + LocaleContextHolder.setLocale(Locale.ENGLISH); + } + + @AfterEach + void tearDown() { + MDC.clear(); + LocaleContextHolder.resetLocaleContext(); + SecurityContextHolder.clearContext(); + } + + @Test + void shouldExposeLocalizedApiTokenScopeReasonAndRequestId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/publish"); + MockHttpServletResponse response = new MockHttpServletResponse(); + ApiTokenScopeService scopeService = + new ApiTokenScopeService(objectMapper, new RouteSecurityPolicyRegistry()); + ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "Alice", + "alice@example.com", + "", + "api_token", + Set.of("USER") + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("SCOPE_skill:read")) + ) + ); + FilterChain chain = (servletRequest, servletResponse) -> { + throw new AssertionError("Denied request must not continue"); + }; + + filter.doFilter(request, response, chain); + + JsonNode body = objectMapper.readTree(response.getContentAsByteArray()); + assertThat(response.getStatus()).isEqualTo(403); + assertThat(body.path("msg").asText()) + .isEqualTo("API token is missing required scope: skill:publish"); + assertThat(body.path("requestId").asText()).isEqualTo("req-610"); + } + + @Test + void shouldTranslateSafeApiTokenReason() throws Exception { + LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/whoami"); + MockHttpServletResponse response = new MockHttpServletResponse(); + ApiTokenScopeService scopeService = + new ApiTokenScopeService(objectMapper, new RouteSecurityPolicyRegistry()); + ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "Alice", + "alice@example.com", + "", + "api_token", + Set.of("USER") + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + throw new AssertionError("Denied request must not continue"); + }); + + JsonNode body = objectMapper.readTree(response.getContentAsByteArray()); + assertThat(body.path("msg").asText()) + .isEqualTo("API 令牌无法访问接口:/api/cli/v1/whoami"); + } + + @Test + void shouldHideGenericAccessDeniedExceptionMessage() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/admin"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.handle(request, response, new AccessDeniedException("internal authorization detail")); + + JsonNode body = objectMapper.readTree(response.getContentAsByteArray()); + assertThat(body.path("msg").asText()).isEqualTo("Forbidden"); + assertThat(response.getContentAsString()).doesNotContain("internal authorization detail"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java new file mode 100644 index 00000000..62646a53 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.auth.token; + +import org.springframework.security.access.AccessDeniedException; + +/** + * Marks an API-token authorization failure whose structured reason is safe to expose to clients. + */ +public final class ApiTokenAccessDeniedException extends AccessDeniedException { + + private final String messageCode; + private final Object[] messageArgs; + + private ApiTokenAccessDeniedException(String logMessage, String messageCode, Object... messageArgs) { + super(logMessage); + this.messageCode = messageCode; + this.messageArgs = messageArgs.clone(); + } + + static ApiTokenAccessDeniedException missingScope(String requiredScope) { + return new ApiTokenAccessDeniedException( + "Missing API token scope: " + requiredScope, + "error.apiToken.scope.missing", + requiredScope + ); + } + + static ApiTokenAccessDeniedException unsupportedEndpoint(String path) { + return new ApiTokenAccessDeniedException( + "API token cannot access endpoint: " + path, + "error.apiToken.endpoint.unsupported", + path + ); + } + + public String getMessageCode() { + return messageCode; + } + + public Object[] getMessageArgs() { + return messageArgs.clone(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java index 97145f5d..5182ce7f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java @@ -5,7 +5,6 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -59,11 +58,10 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter { return; } - accessDeniedHandler.handle( - request, - response, - new AccessDeniedException(decision.message()) - ); + ApiTokenAccessDeniedException exception = decision.requiredScope() != null + ? ApiTokenAccessDeniedException.missingScope(decision.requiredScope()) + : ApiTokenAccessDeniedException.unsupportedEndpoint(request.getRequestURI()); + accessDeniedHandler.handle(request, response, exception); } @Override diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java index 788e0291..085016f4 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java @@ -17,8 +17,10 @@ import org.springframework.security.web.access.AccessDeniedHandler; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -38,7 +40,9 @@ class ApiTokenScopeFilterTest { @Test void shouldDenyApiTokenWithoutRequiredScope() throws Exception { + AtomicReference deniedException = new AtomicReference<>(); AccessDeniedHandler handler = (request, response, accessDeniedException) -> { + deniedException.set(accessDeniedException); response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage()); }; ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); @@ -69,6 +73,12 @@ class ApiTokenScopeFilterTest { assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish")); + ApiTokenAccessDeniedException exception = assertInstanceOf( + ApiTokenAccessDeniedException.class, + deniedException.get() + ); + assertEquals("error.apiToken.scope.missing", exception.getMessageCode()); + assertEquals("skill:publish", exception.getMessageArgs()[0]); verify(chain, never()).doFilter(request, response); } From e4fb26d4ba7067201ae6d76ea3392a902e8d175b Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:03:50 +0800 Subject: [PATCH 78/78] fix(nginx): trust forwarded proto only when configured Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.draft | 3 + .env.release.example | 3 + .github/workflows/pr-scripts.yml | 3 + compose.release.yml | 1 + docs/09-deployment.md | 3 + scripts/tests/nginx-forwarded-proto-test.sh | 101 ++++++++++++++++++ scripts/tests/validate-release-config-test.sh | 6 ++ scripts/tests/workflow-security-test.sh | 6 ++ scripts/validate-release-config.sh | 1 + web/Dockerfile | 1 + web/nginx.conf.template | 14 ++- 11 files changed, 140 insertions(+), 2 deletions(-) create mode 100755 scripts/tests/nginx-forwarded-proto-test.sh diff --git a/.env.release.draft b/.env.release.draft index c8f0872b..417aab30 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -18,6 +18,9 @@ SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com # Usually keep empty when web and api are served from the same domain. SKILLHUB_WEB_API_BASE_URL= SKILLHUB_API_UPSTREAM=http://server:8080 +# Enable only when a trusted TLS-terminating proxy replaces X-Forwarded-Proto +# and the web container cannot be reached directly. +SKILLHUB_TRUST_FORWARDED_PROTO=false # Keep database and redis local-only on the host unless you explicitly need remote access. POSTGRES_BIND_ADDRESS=127.0.0.1 diff --git a/.env.release.example b/.env.release.example index 2d30c7bc..d038d6e1 100644 --- a/.env.release.example +++ b/.env.release.example @@ -15,6 +15,9 @@ SKILLHUB_PUBLIC_BASE_URL=http://localhost # Frontend usually keeps this empty and proxies to the backend through nginx. SKILLHUB_WEB_API_BASE_URL= SKILLHUB_API_UPSTREAM=http://server:8080 +# Keep false for direct exposure. Enable only behind a trusted proxy that replaces +# X-Forwarded-Proto and blocks direct access to the web container. +SKILLHUB_TRUST_FORWARDED_PROTO=false POSTGRES_BIND_ADDRESS=127.0.0.1 POSTGRES_PORT=5432 diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index e521eb9e..082ce102 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -8,6 +8,8 @@ on: - '.env.release.draft' - 'compose.release.yml' - 'Makefile' + - 'web/Dockerfile' + - 'web/nginx.conf.template' - '.github/workflows/pr-cli.yml' - '.github/workflows/pr-e2e.yml' - '.github/workflows/pr-tests.yml' @@ -33,5 +35,6 @@ jobs: - run: bash scripts/tests/publish-cli-test.sh - run: bash scripts/tests/runtime-secret-test.sh - run: bash scripts/tests/validate-release-config-test.sh + - run: bash scripts/tests/nginx-forwarded-proto-test.sh - run: bash scripts/tests/dev-web-host-test.sh - run: bash scripts/tests/workflow-security-test.sh diff --git a/compose.release.yml b/compose.release.yml index 5ed7086e..69c07496 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -116,6 +116,7 @@ services: - "${WEB_PORT:-80}:80" environment: SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080} + SKILLHUB_TRUST_FORWARDED_PROTO: ${SKILLHUB_TRUST_FORWARDED_PROTO:-false} SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-} SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} SKILLHUB_WEB_AUTH_DIRECT_ENABLED: ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED:-false} diff --git a/docs/09-deployment.md b/docs/09-deployment.md index fe631d80..48c5d9d0 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -194,6 +194,9 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 推荐将敏感变量放入 CI/CD Secret 或主机上的受控 `.env.release` - 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入 - 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入 +- `SKILLHUB_TRUST_FORWARDED_PROTO` 默认保持 `false`。只有 Web 容器仅能经由可信 + TLS 终止代理访问,且该代理会覆盖客户端传入的 `X-Forwarded-Proto` 时才设为 + `true`;否则客户端可伪造协议并影响 OAuth 回调、重定向和安全 Cookie 判断 - 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` diff --git a/scripts/tests/nginx-forwarded-proto-test.sh b/scripts/tests/nginx-forwarded-proto-test.sh new file mode 100755 index 00000000..01be85c3 --- /dev/null +++ b/scripts/tests/nginx-forwarded-proto-test.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEMPLATE="$REPO_ROOT/web/nginx.conf.template" +NGINX_IMAGE="${NGINX_TEST_IMAGE:-nginx:alpine}" +TEST_ID="skillhub-nginx-forwarded-proto-$$" +NETWORK="${TEST_ID}-network" +BACKEND="${TEST_ID}-backend" +DEFAULT_PROXY="${TEST_ID}-default" +TRUSTED_PROXY="${TEST_ID}-trusted" +TMP_DIR="$(mktemp -d)" +CONTAINERS=() + +cleanup() { + if ((${#CONTAINERS[@]} > 0)); then + docker rm -f "${CONTAINERS[@]}" >/dev/null 2>&1 || true + fi + docker network rm "$NETWORK" >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +wait_for_nginx() { + local container="$1" + local attempt + for attempt in {1..30}; do + if docker exec "$container" wget -qO- http://127.0.0.1/nginx-health >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + docker logs "$container" >&2 || true + fail "$container did not become healthy" +} + +start_proxy() { + local container="$1" + local trust_forwarded_proto="$2" + docker run --detach \ + --name "$container" \ + --network "$NETWORK" \ + --env "SKILLHUB_API_UPSTREAM=http://$BACKEND:8080" \ + --env "SKILLHUB_TRUST_FORWARDED_PROTO=$trust_forwarded_proto" \ + --volume "$TEMPLATE:/etc/nginx/templates/default.conf.template:ro" \ + "$NGINX_IMAGE" >/dev/null + CONTAINERS+=("$container") + wait_for_nginx "$container" +} + +assert_proto() { + local container="$1" + local expected="$2" + local header="${3:-}" + local path="${4:-/api/proto}" + local actual + if [[ -n "$header" ]]; then + actual="$(docker exec "$container" wget -qO- \ + --header="X-Forwarded-Proto: $header" \ + "http://127.0.0.1$path")" + else + actual="$(docker exec "$container" wget -qO- "http://127.0.0.1$path")" + fi + [[ "$actual" == "$expected" ]] \ + || fail "$container forwarded proto '$actual', expected '$expected' for $path with header '${header:-}'" +} + +cat >"$TMP_DIR/backend.conf" <<'EOF' +server { + listen 8080; + location / { + default_type text/plain; + return 200 $http_x_forwarded_proto; + } +} +EOF + +docker network create "$NETWORK" >/dev/null +docker run --detach \ + --name "$BACKEND" \ + --network "$NETWORK" \ + --volume "$TMP_DIR/backend.conf:/etc/nginx/conf.d/default.conf:ro" \ + "$NGINX_IMAGE" >/dev/null +CONTAINERS+=("$BACKEND") + +start_proxy "$DEFAULT_PROXY" false +start_proxy "$TRUSTED_PROXY" true + +for path in /api/proto /oauth2/proto /login/oauth2/proto /.well-known/proto; do + assert_proto "$DEFAULT_PROXY" http https "$path" + assert_proto "$TRUSTED_PROXY" https https "$path" +done +assert_proto "$TRUSTED_PROXY" http +assert_proto "$TRUSTED_PROXY" http "https,http" + +echo "nginx-forwarded-proto-test passed" diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index fd76ddfc..d94ed62c 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -36,6 +36,7 @@ POSTGRES_USER=skillhub POSTGRES_PASSWORD=strong-postgres-password SESSION_COOKIE_SECURE=true BOOTSTRAP_ADMIN_ENABLED=false +SKILLHUB_TRUST_FORWARDED_PROTO=false SKILLHUB_STORAGE_PROVIDER=s3 SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com SKILLHUB_STORAGE_S3_BUCKET=skillhub @@ -80,6 +81,11 @@ short_env="$tmp/short.env" write_env "$short_env" "too-short" expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters" +invalid_forwarded_proto_env="$tmp/invalid-forwarded-proto.env" +write_env "$invalid_forwarded_proto_env" "release-download-secret-32-bytes-minimum" +printf '%s\n' "SKILLHUB_TRUST_FORWARDED_PROTO=yes" >>"$invalid_forwarded_proto_env" +expect_fail "$invalid_forwarded_proto_env" "SKILLHUB_TRUST_FORWARDED_PROTO must be true or false" + draft_env="$tmp/draft.env" while IFS= read -r line || [[ -n "$line" ]]; do case "$line" in diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index 9a1688ef..ec1f70ce 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -64,8 +64,14 @@ grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when release env draft changes" grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when release compose changes" +grep -Fq 'web/Dockerfile' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when the web image changes" +grep -Fq 'web/nginx.conf.template' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when the nginx template changes" grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run validate-release-config-test" +grep -Fq 'bash scripts/tests/nginx-forwarded-proto-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run nginx-forwarded-proto-test" grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run runtime-secret-test" grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \ diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 03e42bd8..27e9d042 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -151,6 +151,7 @@ reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*" validate_boolean SESSION_COOKIE_SECURE validate_boolean BOOTSTRAP_ADMIN_ENABLED +validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET diff --git a/web/Dockerfile b/web/Dockerfile index e301f7c8..2ed67ae0 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -7,6 +7,7 @@ COPY . . RUN pnpm build FROM nginx:alpine +ENV SKILLHUB_TRUST_FORWARDED_PROTO=false COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/src/docs/skill.md.template /usr/share/nginx/html/registry/skill.md.template COPY nginx.conf.template /etc/nginx/templates/default.conf.template diff --git a/web/nginx.conf.template b/web/nginx.conf.template index be2a51a2..25db2869 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -10,9 +10,15 @@ server { gzip_types text/plain text/css application/json application/javascript text/xml; gzip_min_length 1000; + # Ignore client-supplied forwarded proto by default. Operators may explicitly trust a + # sanitizing upstream proxy; only canonical http/https values are then accepted. set $proxy_x_forwarded_proto $scheme; - if ($http_x_forwarded_proto) { - set $proxy_x_forwarded_proto $http_x_forwarded_proto; + set $forwarded_proto_source "${SKILLHUB_TRUST_FORWARDED_PROTO}:$http_x_forwarded_proto"; + if ($forwarded_proto_source ~* "^true:https$") { + set $proxy_x_forwarded_proto https; + } + if ($forwarded_proto_source ~* "^true:http$") { + set $proxy_x_forwarded_proto http; } location / { @@ -31,6 +37,7 @@ server { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } @@ -38,12 +45,15 @@ server { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /.well-known/ { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; }