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 7ed8c00d0..268ce09a1 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 @@ -2,6 +2,7 @@ import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -16,6 +17,7 @@ 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.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; @@ -35,6 +37,7 @@ import java.security.MessageDigest; import java.util.Comparator; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; import java.util.Optional; import java.util.Set; @@ -64,6 +67,9 @@ public class BuiltinSkillInitializer { private final SkillVersionRepository skillVersionRepository; private final SkillFileRepository skillFileRepository; private final SkillPublishService skillPublishService; + private final AuditLogService auditLogService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public BuiltinSkillInitializer( BuiltinSkillProperties properties, @@ -77,7 +83,8 @@ public BuiltinSkillInitializer( SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillFileRepository skillFileRepository, - SkillPublishService skillPublishService) { + SkillPublishService skillPublishService, + AuditLogService auditLogService) { this.properties = properties; this.manifestLoader = manifestLoader; this.downloader = downloader; @@ -90,6 +97,7 @@ public BuiltinSkillInitializer( this.skillVersionRepository = skillVersionRepository; this.skillFileRepository = skillFileRepository; this.skillPublishService = skillPublishService; + this.auditLogService = auditLogService; } @EventListener(ApplicationReadyEvent.class) @@ -238,7 +246,7 @@ private SyncOutcome syncItem(Namespace namespace, ManifestItem item) throws Exce } try { - skillPublishService.publishFromEntries( + SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( GLOBAL_NAMESPACE, entries, SYSTEM_PUBLISHER_ID, @@ -246,6 +254,7 @@ private SyncOutcome syncItem(Namespace namespace, ManifestItem item) throws Exce SYSTEM_PUBLISHER_ROLES, CONFIRM_BUILTIN_PUBLISH_WARNINGS ); + recordBuiltInPublishAuditIfLatestPublished(publishResult); log.info("Published built-in skill slug={} version={} to @{}", item.slug(), item.version(), GLOBAL_NAMESPACE); return SyncOutcome.PUBLISHED; @@ -271,6 +280,26 @@ private Optional parsePackageUri(ManifestItem item) { } } + private void recordBuiltInPublishAuditIfLatestPublished(SkillPublishService.PublishResult publishResult) { + if (publishResult.version().getStatus() != SkillVersionStatus.PUBLISHED) { + return; + } + + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("namespace", GLOBAL_NAMESPACE); + extras.put("slug", publishResult.slug()); + auditLogService.record( + SYSTEM_PUBLISHER_ID, + "BUILTIN_PUBLISH", + "SKILL_VERSION", + publishResult.version().getId(), + null, + null, + null, + complianceAuditDetailFactory.latestPublishedEntered(publishResult.version(), extras) + ); + } + 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/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index b6fc5b1f6..f82b70f27 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -16,7 +16,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.social.SkillStarService; @@ -24,6 +26,7 @@ import com.iflytek.skillhub.service.SkillSearchAppService; import java.io.IOException; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.slf4j.MDC; @@ -49,6 +52,8 @@ public class ClawHubCompatAppService { private final AuditLogService auditLogService; private final CompatSkillLookupService compatSkillLookupService; private final SkillStarService skillStarService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public ClawHubCompatAppService(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService, @@ -302,8 +307,14 @@ public ClawHubPublishResponse publishSkill(String payloadJson, principal.platformRoles(), confirmWarnings ); - recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, - "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}"); + recordCompatPublishAudit( + principal.userId(), + result.version(), + namespace, + result.slug(), + clientIp, + userAgent + ); return new ClawHubPublishResponse(result.skillId().toString(), result.version().getId().toString()); } @@ -321,8 +332,14 @@ public ClawHubPublishResponse publish(MultipartFile file, principal.platformRoles(), confirmWarnings ); - recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, - "{\"namespace\":\"" + namespace + "\"}"); + recordCompatPublishAudit( + principal.userId(), + result.version(), + namespace, + result.slug(), + clientIp, + userAgent + ); return new ClawHubPublishResponse(result.skillId().toString(), result.version().getId().toString()); } @@ -421,20 +438,31 @@ private String normalizeNamespace(String namespace) { } private void recordCompatPublishAudit(String userId, - Long versionId, + SkillVersion version, + String namespace, + String slug, String clientIp, - String userAgent, - String detailJson) { + String userAgent) { auditLogService.record( userId, "COMPAT_PUBLISH", "SKILL_VERSION", - versionId, + version.getId(), MDC.get("requestId"), clientIp, userAgent, - detailJson + compatPublishAuditDetail(version, namespace, slug) ); } + private String compatPublishAuditDetail(SkillVersion version, String namespace, String slug) { + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("namespace", namespace); + extras.put("slug", slug); + return complianceAuditDetailFactory.latestPublishedEntered(version, extras); + } + return "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + slug + "\"}"; + } + } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java index 0752559ee..833d9c5f8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java @@ -125,7 +125,8 @@ public ApiResponse publish( @PathVariable String namespace, @RequestPart("file") MultipartFile file, @RequestPart(value = "visibility", required = false) String visibility, - @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) throws IOException { List entries; try { entries = archiveExtractor.extract(file); @@ -135,7 +136,8 @@ public ApiResponse publish( var result = cliSkillAppService.publish( namespace, entries, principal.userId(), SkillVisibility.valueOf((visibility != null ? visibility : "PUBLIC").toUpperCase()), - principal.platformRoles()); + principal.platformRoles(), + AuditRequestContext.from(request)); return ok("response.success.published", result); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 7c0df9c98..559ccd2f8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ResolveVersionResponse; +import com.iflytek.skillhub.dto.SkillComplianceMappingResponse; import com.iflytek.skillhub.dto.SkillDetailResponse; import com.iflytek.skillhub.dto.SkillFileResponse; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; @@ -173,7 +174,16 @@ public ApiResponse getVersionDetail( detail.totalSize(), detail.publishedAt(), detail.parsedMetadataJson(), - detail.manifestJson() + detail.manifestJson(), + detail.complianceMappings().stream() + .map(mapping -> new SkillComplianceMappingResponse( + mapping.standard(), + mapping.standardVersion(), + mapping.controlId(), + mapping.controlTitle(), + mapping.evidenceUrl() + )) + .toList() ); return ok("response.success.read", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index a061afc1e..38caaebb3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -1,15 +1,19 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; +import jakarta.servlet.http.HttpServletRequest; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -17,6 +21,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.LinkedHashMap; import java.util.List; /** @@ -32,15 +37,20 @@ public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; private final SkillHubMetrics skillHubMetrics; + private final AuditLogService auditLogService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public SkillPublishController(SkillPublishService skillPublishService, SkillPackageArchiveExtractor skillPackageArchiveExtractor, ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics) { + SkillHubMetrics skillHubMetrics, + AuditLogService auditLogService) { super(responseFactory); this.skillPublishService = skillPublishService; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; this.skillHubMetrics = skillHubMetrics; + this.auditLogService = auditLogService; } /** @@ -54,7 +64,8 @@ public ApiResponse publish( @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, - @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -93,8 +104,32 @@ public ApiResponse publish( publishResult.version().getFileCount(), publishResult.version().getTotalSize() ); + recordPublishAuditIfLatestPublished(principal.userId(), namespace, publishResult, request); skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); return ok("response.success.published", response); } + + private void recordPublishAuditIfLatestPublished(String userId, + String namespace, + SkillPublishService.PublishResult publishResult, + HttpServletRequest request) { + if (publishResult.version().getStatus() != SkillVersionStatus.PUBLISHED) { + return; + } + + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("namespace", namespace); + extras.put("slug", publishResult.slug()); + auditLogService.record( + userId, + "PUBLISH", + "SKILL_VERSION", + publishResult.version().getId(), + null, + request != null ? request.getRemoteAddr() : null, + request != null ? request.getHeader("User-Agent") : null, + complianceAuditDetailFactory.latestPublishedEntered(publishResult.version(), extras) + ); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index 36227b316..c4df4e715 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -2,6 +2,8 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.ratelimit.RateLimit; @@ -40,6 +42,11 @@ public ApiResponse search( @RequestParam(required = false) String q, @RequestParam(required = false) String namespace, @RequestParam(name = "label", required = false) java.util.List labels, + @Parameter( + name = "complianceStandard", + schema = @Schema(implementation = ComplianceStandard.class) + ) + @RequestParam(required = false) String complianceStandard, @Parameter(schema = @Schema(defaultValue = DEFAULT_SORT)) @RequestParam(required = false) String sort, @Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0")) @@ -56,6 +63,7 @@ public ApiResponse search( parseNonNegativeInt(page, DEFAULT_PAGE), parsePositiveInt(size, DEFAULT_SIZE), labels, + parseComplianceStandard(complianceStandard), userId, userNsRoles ); @@ -89,4 +97,12 @@ private int parsePositiveInt(String rawValue, int defaultValue) { int parsed = parseNonNegativeInt(rawValue, defaultValue); return parsed > 0 ? parsed : defaultValue; } + + private ComplianceStandard parseComplianceStandard(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + return null; + } + return ComplianceStandard.findByValue(rawValue) + .orElseThrow(() -> new DomainBadRequestException("error.search.complianceStandard.invalid", rawValue.trim())); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java new file mode 100644 index 000000000..2a71d6d26 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; + +public record SkillComplianceMappingResponse( + ComplianceStandard standard, + String standardVersion, + String controlId, + String controlTitle, + String evidenceUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java index 8c01be78e..457a8c672 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.dto; import java.time.Instant; +import java.util.List; public record SkillVersionDetailResponse( Long id, @@ -11,5 +12,6 @@ public record SkillVersionDetailResponse( long totalSize, Instant publishedAt, String parsedMetadataJson, - String manifestJson + String manifestJson, + List complianceMappings ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index 47d8eed5b..9c3af2b4f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -11,10 +11,14 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.repository.GovernanceQueryRepository; import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import org.slf4j.MDC; @@ -34,19 +38,24 @@ public class ReviewPortalAppService { private final GovernanceQueryRepository governanceQueryRepository; private final RbacService rbacService; private final AuditLogService auditLogService; + private final SkillVersionRepository skillVersionRepository; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public ReviewPortalAppService(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, NamespaceRepository namespaceRepository, GovernanceQueryRepository governanceQueryRepository, RbacService rbacService, - AuditLogService auditLogService) { + AuditLogService auditLogService, + SkillVersionRepository skillVersionRepository) { this.reviewService = reviewService; this.reviewTaskRepository = reviewTaskRepository; this.namespaceRepository = namespaceRepository; this.governanceQueryRepository = governanceQueryRepository; this.rbacService = rbacService; this.auditLogService = auditLogService; + this.skillVersionRepository = skillVersionRepository; } public ReviewTaskResponse submitReview(Long skillVersionId, @@ -75,7 +84,13 @@ public ReviewTaskResponse approveReview(Long reviewTaskId, normalizeRoles(userNsRoles), platformRoles(userId) ); - recordAudit("REVIEW_APPROVE", userId, task.getId(), auditContext, detailWithComment(comment)); + recordAudit( + "REVIEW_APPROVE", + userId, + task.getId(), + auditContext, + detailWithCommentAndSnapshot(comment, task.getSkillVersionId()) + ); return governanceQueryRepository.getReviewTaskResponse(task); } @@ -269,4 +284,17 @@ private String detailWithComment(String comment) { } return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; } + + private String detailWithCommentAndSnapshot(String comment, Long skillVersionId) { + SkillVersion version = skillVersionRepository.findById(skillVersionId).orElse(null); + if (version == null) { + return detailWithComment(comment); + } + + LinkedHashMap extras = new LinkedHashMap<>(); + if (comment != null && !comment.isBlank()) { + extras.put("comment", comment); + } + return complianceAuditDetailFactory.latestPublishedEntered(version, extras); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index e7f86d459..8f939c037 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.skill.Skill; 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.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService; @@ -16,6 +18,7 @@ import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; +import java.util.LinkedHashMap; import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +38,8 @@ public class SkillLifecycleAppService { private final SkillReviewSubmitService skillReviewSubmitService; private final AuditLogService auditLogService; private final SkillSlugResolutionService skillSlugResolutionService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public SkillLifecycleAppService(NamespaceRepository namespaceRepository, SkillVersionRepository skillVersionRepository, @@ -165,8 +170,7 @@ public SkillLifecycleMutationResponse rereleaseVersion(String namespace, null, auditContext.clientIp(), auditContext.userAgent(), - "{\"sourceVersion\":\"" + version.replace("\"", "\\\"") - + "\",\"targetVersion\":\"" + targetVersion.replace("\"", "\\\"") + "\"}" + rereleaseAuditDetail(version, targetVersion, result.version()) ); return new SkillLifecycleMutationResponse( result.skillId(), @@ -234,7 +238,7 @@ public SkillLifecycleMutationResponse confirmPublish(String namespace, null, auditContext.clientIp(), auditContext.userAgent(), - "{\"version\":\"" + version.replace("\"", "\\\"") + "\"}" + complianceAuditDetailFactory.latestPublishedEntered(skillVersion) ); return new SkillLifecycleMutationResponse( skill.getId(), @@ -264,4 +268,15 @@ private SkillVersion findVersion(Long skillId, String version) { private Map normalizeRoles(Map userNamespaceRoles) { return userNamespaceRoles != null ? userNamespaceRoles : Map.of(); } + + private String rereleaseAuditDetail(String sourceVersion, String targetVersion, SkillVersion publishedVersion) { + if (publishedVersion.getStatus() == SkillVersionStatus.PUBLISHED) { + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("sourceVersion", sourceVersion); + extras.put("targetVersion", targetVersion); + return complianceAuditDetailFactory.latestPublishedEntered(publishedVersion, extras); + } + return "{\"sourceVersion\":\"" + sourceVersion.replace("\"", "\\\"") + + "\",\"targetVersion\":\"" + targetVersion.replace("\"", "\\\"") + "\"}"; + } } 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 634106785..0259d91ec 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 @@ -7,6 +7,7 @@ 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.metadata.ComplianceStandard; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.search.SearchQuery; @@ -68,7 +69,7 @@ public SearchResponse search( int size, String userId, Map userNsRoles) { - return search(keyword, namespaceSlug, sortBy, page, size, List.of(), userId, userNsRoles); + return search(keyword, namespaceSlug, sortBy, page, size, List.of(), null, userId, userNsRoles); } public SearchResponse search( @@ -78,6 +79,7 @@ public SearchResponse search( int page, int size, List labelSlugs, + ComplianceStandard complianceStandard, String userId, Map userNsRoles) { @@ -85,7 +87,29 @@ public SearchResponse search( SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); - return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false); + return searchVisibleSkills( + keyword, + namespaceId, + sortBy != null ? sortBy : "newest", + page, + size, + labelSlugs, + complianceStandard, + scope, + false + ); + } + + public SearchResponse search( + String keyword, + String namespaceSlug, + String sortBy, + int page, + int size, + List labelSlugs, + String userId, + Map userNsRoles) { + return search(keyword, namespaceSlug, sortBy, page, size, labelSlugs, null, userId, userNsRoles); } public SearchResponse searchInstallableLatest( @@ -98,7 +122,7 @@ public SearchResponse searchInstallableLatest( 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); + return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), null, scope, true); } private Long resolveNamespaceId(String namespaceSlug, String userId, Map userNsRoles) { @@ -146,6 +170,7 @@ private SearchResponse searchVisibleSkills( int page, int size, List labelSlugs, + ComplianceStandard complianceStandard, SearchVisibilityScope scope, boolean requireInstallableLatest) { SearchResult result = searchQueryService.search(new SearchQuery( @@ -156,6 +181,7 @@ private SearchResponse searchVisibleSkills( page, size, normalizeLabelSlugs(labelSlugs), + complianceStandard, requireInstallableLatest )); List pageItems = mapVisibleSkillSummaries(result.skillIds()); 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 1fcd2e259..4c5e88f81 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 @@ -1,7 +1,10 @@ package com.iflytek.skillhub.service.cli; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; @@ -21,6 +24,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -33,18 +37,23 @@ public class CliSkillAppService { private final SkillDownloadService skillDownloadService; private final SkillDeleteAppService skillDeleteAppService; private final SkillPublishService skillPublishService; + private final AuditLogService auditLogService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public CliSkillAppService( SkillSearchAppService skillSearchAppService, SkillQueryService skillQueryService, SkillDownloadService skillDownloadService, SkillDeleteAppService skillDeleteAppService, - SkillPublishService skillPublishService) { + SkillPublishService skillPublishService, + AuditLogService auditLogService) { this.skillSearchAppService = skillSearchAppService; this.skillQueryService = skillQueryService; this.skillDownloadService = skillDownloadService; this.skillDeleteAppService = skillDeleteAppService; this.skillPublishService = skillPublishService; + this.auditLogService = auditLogService; } public record CliSearchItem(String namespace, String slug, String latestVersion, String summary) {} @@ -132,10 +141,16 @@ public CliDryRunResponse validatePublish(String namespace, List en ); } - public CliPublishResponse publish(String namespace, List entries, String publisherId, SkillVisibility visibility, Set platformRoles) { + public CliPublishResponse publish(String namespace, + List entries, + String publisherId, + SkillVisibility visibility, + Set platformRoles, + AuditRequestContext auditContext) { SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( namespace, entries, publisherId, visibility, platformRoles, false ); + recordPublishAuditIfLatestPublished(publisherId, namespace, result, auditContext); return new CliPublishResponse( namespace, @@ -145,6 +160,29 @@ public CliPublishResponse publish(String namespace, List entries, ); } + private void recordPublishAuditIfLatestPublished(String publisherId, + String namespace, + SkillPublishService.PublishResult result, + AuditRequestContext auditContext) { + if (result.version().getStatus() != SkillVersionStatus.PUBLISHED) { + return; + } + + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("namespace", namespace); + extras.put("slug", result.slug()); + auditLogService.record( + publisherId, + "CLI_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + null, + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + complianceAuditDetailFactory.latestPublishedEntered(result.version(), extras) + ); + } + private ResponseEntity buildDownloadResponse(SkillDownloadService.DownloadResult result) { if (result.presignedUrl() != null) { return ResponseEntity.status(302) diff --git a/server/skillhub-app/src/main/resources/db/migration/V44__skill_version_compliance_index.sql b/server/skillhub-app/src/main/resources/db/migration/V44__skill_version_compliance_index.sql new file mode 100644 index 000000000..e69d84587 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V44__skill_version_compliance_index.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS idx_skill_version_compliance_mappings +ON skill_version +USING GIN ((parsed_metadata_json -> 'frontmatter' -> 'x-astron-compliance')); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 25a631276..ad70e566e 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -47,6 +47,7 @@ error.auth.sessionBootstrap.disabled=Session bootstrap is disabled error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0} error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found error.badRequest=Invalid request +error.search.complianceStandard.invalid=Invalid compliance standard: {0} error.forbidden=Forbidden error.request.timeout=Request timed out error.rateLimit.exceeded=Rate limit exceeded diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 99183ae5b..d32a74614 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -47,6 +47,7 @@ error.auth.sessionBootstrap.disabled=会话引导能力未启用 error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0} error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 error.badRequest=请求参数不合法 +error.search.complianceStandard.invalid=不支持的合规标准:{0} error.forbidden=没有权限执行该操作 error.request.timeout=请求超时 error.rateLimit.exceeded=请求过于频繁,请稍后再试 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 bc5602a0f..cad7e967e 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 @@ -3,7 +3,9 @@ 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.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -11,6 +13,7 @@ import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -74,6 +77,7 @@ class BuiltinSkillInitializerTest { @Mock private SkillVersionRepository skillVersionRepository; @Mock private SkillFileRepository skillFileRepository; @Mock private SkillPublishService skillPublishService; + @Mock private AuditLogService auditLogService; private BuiltinSkillProperties properties; private BuiltinSkillInitializer initializer; @@ -94,7 +98,8 @@ void setUp() { skillRepository, skillVersionRepository, skillFileRepository, - skillPublishService + skillPublishService, + auditLogService ); globalNamespace = new Namespace(GLOBAL, "Global", "system"); ReflectionTestUtils.setField(globalNamespace, "id", 1L); @@ -250,6 +255,15 @@ void publishesNewVersionToGlobalAsPublicWithSystemPublisher() throws Exception { List entries = packageEntries("skillhub-hello", "1.0.0", "same"); givenExtractedPackage(entries); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), + eq(entries), + eq(PUBLISHER), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(true) + )).thenReturn(new SkillPublishService.PublishResult(100L, "skillhub-hello", + version(300L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED))); runInitializer(); @@ -265,6 +279,58 @@ void publishesNewVersionToGlobalAsPublicWithSystemPublisher() throws Exception { assertThat(entriesCaptor.getValue()).isEqualTo(entries); } + @Test + void publishesBuiltInSkill_recordsComplianceSnapshotWhenLatestPublished() throws Exception { + List entries = packageEntriesWithCompliance("skillhub-hello", "1.0.0", "CC6.1"); + SkillVersion published = version(300L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + published.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), + eq(entries), + eq(PUBLISHER), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(true) + )).thenReturn(new SkillPublishService.PublishResult(100L, "skillhub-hello", published)); + + runInitializer(); + + verify(auditLogService).record( + eq(PUBLISHER), + eq("BUILTIN_PUBLISH"), + eq("SKILL_VERSION"), + eq(300L), + isNull(), + isNull(), + isNull(), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq(PUBLISHER), + eq("BUILTIN_PUBLISH"), + eq("SKILL_VERSION"), + eq(300L), + isNull(), + isNull(), + isNull(), + contains("\"controlId\":\"CC6.1\"") + ); + } + @Test void createsSystemPublisherAndGlobalMembershipBeforePublishing() throws Exception { List entries = packageEntries("skillhub-hello", "1.0.0", "same"); @@ -272,6 +338,15 @@ void createsSystemPublisherAndGlobalMembershipBeforePublishing() throws Exceptio when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.empty()); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)).thenReturn(Optional.empty()); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), + eq(entries), + eq(PUBLISHER), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(true) + )).thenReturn(new SkillPublishService.PublishResult(100L, "skillhub-hello", + version(301L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED))); runInitializer(); @@ -399,6 +474,26 @@ private static List packageEntries(String name, String version, St ); } + private static List packageEntriesWithCompliance(String name, String version, String controlId) { + byte[] skillMd = (""" + --- + name: %s + description: Built-in guardrails + version: %s + x-astron-compliance: + - standard: soc2 + standardVersion: "2017" + controlId: %s + --- + # %s + """).formatted(name, version, controlId, name).getBytes(StandardCharsets.UTF_8); + byte[] readmeBytes = "same".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( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java index c5921f59b..f7943d029 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -2,23 +2,36 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.support.MultipartPackageExtractor; import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.social.SkillStarService; import com.iflytek.skillhub.service.SkillSearchAppService; +import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; class ClawHubCompatAppServiceTest { @@ -77,4 +90,162 @@ void downloadLocationByQuery_returnsCanonicalPath_whenLegacySkillIsVisible() { assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download"); } + + @Test + void publishSkill_recordsComplianceSnapshotWhenLatestPublished() throws IOException { + MockMultipartFile file = new MockMultipartFile( + "files", + "SKILL.md", + "text/markdown", + "name: demo".getBytes() + ); + MockMultipartFile[] files = new MockMultipartFile[]{file}; + MultipartPackageExtractor.PublishPayload payload = new MultipartPackageExtractor.PublishPayload( + "team-ai", + "demo-skill", + "Demo Skill", + "1.0.0", + null, + true, + List.of(), + null + ); + List entries = List.of( + new PackageEntry("SKILL.md", "name: demo".getBytes(), 10, "text/markdown") + ); + when(multipartPackageExtractor.extract(files, "{\"slug\":\"demo-skill\"}")) + .thenReturn(new MultipartPackageExtractor.ExtractedPackage(payload, entries)); + + SkillVersion version = publishedVersionWithCompliance(22L, "1.0.0", "CC6.1"); + when(skillPublishService.publishFromEntries( + "team-ai", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + true + )).thenReturn(new SkillPublishService.PublishResult(7L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + + service.publishSkill( + "{\"slug\":\"demo-skill\"}", + files, + true, + principal, + "127.0.0.1", + "JUnit" + ); + + verify(auditLogService).record( + eq("user-1"), + eq("COMPAT_PUBLISH"), + eq("SKILL_VERSION"), + eq(22L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("user-1"), + eq("COMPAT_PUBLISH"), + eq("SKILL_VERSION"), + eq(22L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"CC6.1\"") + ); + } + + @Test + void publish_recordsComplianceSnapshotWhenLatestPublished() throws IOException { + MockMultipartFile file = new MockMultipartFile( + "file", + "demo.zip", + "application/zip", + "zip".getBytes() + ); + List entries = List.of( + new PackageEntry("SKILL.md", "name: demo".getBytes(), 10, "text/markdown") + ); + when(zipPackageExtractor.extract(file)).thenReturn(entries); + + SkillVersion version = publishedVersionWithCompliance(23L, "1.2.0", "Article-17"); + when(skillPublishService.publishFromEntries( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false + )).thenReturn(new SkillPublishService.PublishResult(8L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + + service.publish( + file, + "global", + false, + principal, + "127.0.0.1", + "JUnit" + ); + + verify(auditLogService).record( + eq("user-1"), + eq("COMPAT_PUBLISH"), + eq("SKILL_VERSION"), + eq(23L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("user-1"), + eq("COMPAT_PUBLISH"), + eq("SKILL_VERSION"), + eq(23L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"Article-17\"") + ); + } + + private SkillVersion publishedVersionWithCompliance(Long versionId, String versionNumber, String controlId) { + SkillVersion version = new SkillVersion(7L, versionNumber, "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "gdpr", + "standardVersion": "2024", + "controlId": "%s" + } + ] + } + } + """.formatted(controlId)); + ReflectionTestUtils.setField(version, "id", versionId); + return version; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index ce622374b..fb83d13cf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -5,6 +5,8 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceMapping; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; @@ -26,7 +28,9 @@ import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; import static org.mockito.ArgumentMatchers.any; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -67,7 +71,14 @@ void getVersionDetailShouldReturnMetadataFields() throws Exception { 128L, Instant.parse("2026-03-12T12:00:00Z"), "{\"name\":\"demo\"}", - "[{\"path\":\"SKILL.md\"}]" + "[{\"path\":\"SKILL.md\"}]", + List.of(new SkillComplianceMapping( + ComplianceStandard.GDPR, + "2024", + "Article-17", + "Right to erasure", + "https://example.com/gdpr" + )) )); mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) @@ -76,6 +87,9 @@ void getVersionDetailShouldReturnMetadataFields() throws Exception { .andExpect(jsonPath("$.data.version").value("1.0.0")) .andExpect(jsonPath("$.data.parsedMetadataJson").value("{\"name\":\"demo\"}")) .andExpect(jsonPath("$.data.manifestJson").value("[{\"path\":\"SKILL.md\"}]")) + .andExpect(jsonPath("$.data.complianceMappings[0].standard").value("gdpr")) + .andExpect(jsonPath("$.data.complianceMappings[0].standardVersion").value("2024")) + .andExpect(jsonPath("$.data.complianceMappings[0].controlId").value("Article-17")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } @@ -97,7 +111,8 @@ void getVersionDetailShouldRemainUtcAcrossJvmDefaultTimeZones() throws Exception 128L, Instant.parse("2026-03-12T12:00:00Z"), "{\"name\":\"demo\"}", - "[{\"path\":\"SKILL.md\"}]" + "[{\"path\":\"SKILL.md\"}]", + List.of() )); TimeZone original = TimeZone.getDefault(); @@ -106,13 +121,24 @@ void getVersionDetailShouldRemainUtcAcrossJvmDefaultTimeZones() throws Exception TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.publishedAt").value("2026-03-12T12:00:00Z")); + .andExpect(jsonPath("$.data.publishedAt").value("2026-03-12T12:00:00Z")) + .andExpect(jsonPath("$.data.complianceMappings").isArray()); } } finally { TimeZone.setDefault(original); } } + @Test + void openApiShouldExposeClosedComplianceStandardEnums() throws Exception { + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"SkillComplianceMappingResponse\""))) + .andExpect(content().string(containsString("\"standard\""))) + .andExpect(content().string(containsString("\"enum\":[\"mitre_attack\",\"nist_csf\",\"gdpr\",\"hipaa\",\"soc2\"]"))) + .andExpect(content().string(containsString("\"name\":\"complianceStandard\""))); + } + @Test void resolveVersionShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.resolveVersion( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java index bed761ad0..3a0fddaf0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.service.SkillSearchAppService; import org.junit.jupiter.api.Test; @@ -13,8 +14,10 @@ import java.util.List; import java.util.Map; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -43,6 +46,7 @@ void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception { eq(0), eq(20), eq(null), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); @@ -67,6 +71,7 @@ void searchShouldPassExplicitSortPageAndSize() throws Exception { eq(0), eq(12), eq(null), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 12)); @@ -89,6 +94,7 @@ void searchShouldPassLabelFilters() throws Exception { eq(0), eq(20), eq(List.of("code-generation", "official")), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); @@ -110,6 +116,7 @@ void searchShouldFallbackToDefaultsForBlankQueryParams() throws Exception { eq(0), eq(20), eq(null), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); @@ -132,6 +139,7 @@ void searchShouldFallbackToDefaultsForInvalidPagination() throws Exception { eq(0), eq(20), eq(null), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); @@ -143,4 +151,37 @@ void searchShouldFallbackToDefaultsForInvalidPagination() throws Exception { .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)); } + + @Test + void searchShouldPassComplianceStandardFilter() throws Exception { + when(skillSearchAppService.search( + eq("review"), + eq(null), + eq("newest"), + eq(0), + eq(20), + eq(List.of("official")), + eq(ComplianceStandard.GDPR), + any(), + any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/web/skills") + .param("q", "review") + .param("label", "official") + .param("complianceStandard", "gdpr")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + } + + @Test + void searchShouldRejectInvalidComplianceStandard() throws Exception { + mockMvc.perform(get("/api/web/skills") + .param("complianceStandard", "not-a-standard")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.msg").value(containsString("not-a-standard"))); + + verifyNoInteractions(skillSearchAppService); + } } 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 9f6fe695a..9b028ba0d 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 @@ -69,7 +69,7 @@ void downloadRoutesUseDownloadRateLimit() throws Exception { @Test void publishConsumesMultipartFormData() throws Exception { Method publish = CliSkillController.class.getMethod( - "publish", String.class, MultipartFile.class, String.class, PlatformPrincipal.class); + "publish", String.class, MultipartFile.class, String.class, PlatformPrincipal.class, HttpServletRequest.class); PostMapping mapping = publish.getAnnotation(PostMapping.class); assertNotNull(mapping); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index 8b8c714a1..0c5e8765b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -1,12 +1,15 @@ package com.iflytek.skillhub.controller.portal; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.audit.AuditLogService; import org.mockito.ArgumentMatchers; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -63,6 +66,9 @@ class SkillPublishControllerTest { @MockBean private SkillHubMetrics skillHubMetrics; + @MockBean + private AuditLogService auditLogService; + @Test void publish_recordsMetricsAfterSuccess() throws Exception { SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); @@ -114,6 +120,88 @@ void publish_recordsMetricsAfterSuccess() throws Exception { verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW"); } + @Test + void publish_recordsComplianceSnapshotWhenLatestPublished() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setFileCount(1); + version.setTotalSize(128L); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); + ReflectionTestUtils.setField(version, "id", 34L); + + given(skillPublishService.publishFromEntries( + eq("global"), + ArgumentMatchers.>any(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false))) + .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + buildZipBytes() + ); + + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(file) + .param("visibility", "PUBLIC") + .header("User-Agent", "JUnit") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(auditLogService).record( + eq("usr_1"), + eq("PUBLISH"), + eq("SKILL_VERSION"), + eq(34L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("usr_1"), + eq("PUBLISH"), + eq("SKILL_VERSION"), + eq(34L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"CC6.1\"") + ); + } + @Test void publish_passesWarningConfirmationFlag() throws Exception { SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java new file mode 100644 index 000000000..675554ff5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java @@ -0,0 +1,103 @@ +package com.iflytek.skillhub.service; + +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewService; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.dto.ReviewTaskResponse; +import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class ReviewPortalAppServiceTest { + + private final ReviewService reviewService = mock(ReviewService.class); + private final ReviewTaskRepository reviewTaskRepository = mock(ReviewTaskRepository.class); + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + private final GovernanceQueryRepository governanceQueryRepository = mock(GovernanceQueryRepository.class); + private final RbacService rbacService = mock(RbacService.class); + private final AuditLogService auditLogService = mock(AuditLogService.class); + private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + + private final ReviewPortalAppService service = new ReviewPortalAppService( + reviewService, + reviewTaskRepository, + namespaceRepository, + governanceQueryRepository, + rbacService, + auditLogService, + skillVersionRepository + ); + + @Test + void approveReview_recordsComplianceSnapshotInAuditDetail() { + ReviewTask task = new ReviewTask(22L, 5L, "owner-1"); + ReflectionTestUtils.setField(task, "id", 9L); + + SkillVersion version = new SkillVersion(7L, "1.0.0", "owner-1"); + ReflectionTestUtils.setField(version, "id", 22L); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "gdpr", + "standardVersion": "2024", + "controlId": "Article-17" + } + ] + } + } + """); + + when(reviewService.approveReview(9L, "reviewer-1", "looks good", Map.of(), Set.of("SKILL_ADMIN"))) + .thenReturn(task); + when(rbacService.getUserRoleCodes("reviewer-1")).thenReturn(Set.of("SKILL_ADMIN")); + when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(version)); + when(governanceQueryRepository.getReviewTaskResponse(task)) + .thenReturn(new ReviewTaskResponse(9L, 22L, "team", "demo", "1.0.0", "APPROVED", "owner-1", null, "reviewer-1", null, "looks good", null, null)); + + service.approveReview( + 9L, + "looks good", + "reviewer-1", + Map.of(), + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq("reviewer-1"), + eq("REVIEW_APPROVE"), + eq("REVIEW_TASK"), + eq(9L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("reviewer-1"), + eq("REVIEW_APPROVE"), + eq("REVIEW_TASK"), + eq(9L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"Article-17\"") + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index e2c101462..2ed2d3729 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.review.ReviewService; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; @@ -27,6 +28,9 @@ import java.util.Map; import java.util.Optional; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.isNull; + class SkillLifecycleAppServiceTest { private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); @@ -76,4 +80,65 @@ void archiveSkill_resolvesNamespaceAndDelegatesLifecycleMutation() { assertThat(response.status()).isEqualTo("ARCHIVED"); verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), "127.0.0.1", "JUnit", "cleanup"); } + + @Test + void confirmPublish_recordsComplianceSnapshot() { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 7L); + Skill skill = new Skill(7L, "demo-skill", "owner-1", SkillVisibility.PRIVATE); + ReflectionTestUtils.setField(skill, "id", 11L); + SkillVersion version = new SkillVersion(11L, "1.0.0", "owner-1"); + ReflectionTestUtils.setField(version, "id", 22L); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillSlugResolutionService.resolve(7L, "demo-skill", "owner-1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .thenReturn(skill); + when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); + + var response = service.confirmPublish( + "global", + "demo-skill", + "1.0.0", + "owner-1", + Map.of(7L, NamespaceRole.OWNER), + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + assertThat(response.versionId()).isEqualTo(22L); + assertThat(response.status()).isEqualTo("PUBLISHED"); + verify(skillReviewSubmitService).confirmPublish(11L, 22L, "owner-1", Map.of(7L, NamespaceRole.OWNER)); + verify(auditLogService).record( + eq("owner-1"), + eq("CONFIRM_PUBLISH"), + eq("SKILL_VERSION"), + eq(22L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("owner-1"), + eq("CONFIRM_PUBLISH"), + eq("SKILL_VERSION"), + eq(22L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"CC6.1\"") + ); + } } 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 3cd408e45..e50163f09 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 @@ -12,6 +12,7 @@ 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.ComplianceStandard; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; @@ -294,6 +295,28 @@ void search_shouldNormalizeAndPassLabelSlugs() { assertEquals(List.of("code-generation", "official"), captor.getValue().labelSlugs()); } + @Test + void search_shouldPassComplianceStandardEnum() { + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(), 0, 0, 20)); + + service.search( + "skill", + null, + "newest", + 0, + 20, + List.of("official"), + ComplianceStandard.GDPR, + "user-9", + Map.of(7L, NamespaceRole.MEMBER) + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SearchQuery.class); + verify(searchQueryService).search(captor.capture()); + assertEquals(ComplianceStandard.GDPR, captor.getValue().complianceStandard()); + } + @Test void search_shouldIncludeMemberNamespacesInVisibilityScope() { 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 0c75ca341..b106fb697 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,5 +1,6 @@ package com.iflytek.skillhub.service.cli; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; @@ -40,8 +41,11 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) class CliSkillAppServiceTest { @@ -56,6 +60,7 @@ class CliSkillAppServiceTest { @Mock SkillVersionRepository skillVersionRepository; @Mock NamespaceService namespaceService; @Mock RbacService rbacService; + @Mock AuditLogService auditLogService; private CliSkillAppService service; @@ -63,7 +68,7 @@ class CliSkillAppServiceTest { void setUp() { service = new CliSkillAppService( skillSearchAppService, skillQueryService, - skillDownloadService, skillDeleteAppService, skillPublishService); + skillDownloadService, skillDeleteAppService, skillPublishService, auditLogService); } @Test @@ -166,7 +171,8 @@ private void assertLimitOneSkipsUninstallableFirstMatch( skillQueryService, skillDownloadService, skillDeleteAppService, - skillPublishService + skillPublishService, + auditLogService ); Skill installableSecondMatch = new Skill(1L, "ready-second", "owner-1", SkillVisibility.PUBLIC); @@ -247,12 +253,73 @@ void publish_delegatesToPublishService() { given(skillPublishService.publishFromEntries("global", entries, "user-1", SkillVisibility.PUBLIC, Set.of("USER"), false)) .willReturn(new SkillPublishService.PublishResult(1L, "test-skill", mockVersion)); - CliPublishResponse response = service.publish("global", entries, "user-1", SkillVisibility.PUBLIC, Set.of("USER")); + CliPublishResponse response = service.publish( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("USER"), + new AuditRequestContext("127.0.0.1", "CLI/1.0") + ); assertEquals("global", response.namespace()); assertEquals("test-skill", response.slug()); assertEquals("1.0.0", response.version()); assertEquals("PUBLIC", response.visibility()); + verifyNoInteractions(auditLogService); + } + + @Test + void publish_recordsComplianceSnapshotWhenLatestPublished() { + List entries = List.of( + new PackageEntry("SKILL.md", "name: test".getBytes(), 10, "text/markdown") + ); + SkillVersion version = publishedVersion(1L, 42L, "1.0.0"); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); + given(skillPublishService.publishFromEntries("global", entries, "user-1", SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN"), false)) + .willReturn(new SkillPublishService.PublishResult(1L, "test-skill", version)); + + service.publish( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + new AuditRequestContext("127.0.0.1", "CLI/1.0") + ); + + verify(auditLogService).record( + eq("user-1"), + eq("CLI_PUBLISH"), + eq("SKILL_VERSION"), + eq(42L), + isNull(), + eq("127.0.0.1"), + eq("CLI/1.0"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("user-1"), + eq("CLI_PUBLISH"), + eq("SKILL_VERSION"), + eq(42L), + isNull(), + eq("127.0.0.1"), + eq("CLI/1.0"), + contains("\"controlId\":\"CC6.1\"") + ); } private boolean requiresInstallableLatest(SearchQuery query) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/ComplianceStandard.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/ComplianceStandard.java new file mode 100644 index 000000000..df4525508 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/ComplianceStandard.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; +import java.util.Optional; + +/** + * Controlled vocabulary for phase-1 compliance mappings. + */ +public enum ComplianceStandard { + MITRE_ATTACK("mitre_attack"), + NIST_CSF("nist_csf"), + GDPR("gdpr"), + HIPAA("hipaa"), + SOC2("soc2"); + + private final String value; + + ComplianceStandard(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return value; + } + + @JsonCreator + public static ComplianceStandard fromValue(String value) { + return findByValue(value) + .orElseThrow(() -> new IllegalArgumentException("Unknown compliance standard: " + value)); + } + + public static Optional findByValue(String value) { + if (value == null) { + return Optional.empty(); + } + return Arrays.stream(values()) + .filter(candidate -> candidate.value.equals(value.trim().toLowerCase(java.util.Locale.ROOT))) + .findFirst(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java new file mode 100644 index 000000000..136a45d3f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.skill.SkillVersion; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Builds audit detail payloads that carry version-scoped compliance snapshots. + */ +public class SkillComplianceAuditDetailFactory { + + private final ObjectMapper objectMapper; + private final SkillComplianceMetadataService complianceMetadataService; + + public SkillComplianceAuditDetailFactory() { + this(new ObjectMapper(), new SkillComplianceMetadataService()); + } + + SkillComplianceAuditDetailFactory(ObjectMapper objectMapper, + SkillComplianceMetadataService complianceMetadataService) { + this.objectMapper = objectMapper; + this.complianceMetadataService = complianceMetadataService; + } + + public String latestPublishedEntered(SkillVersion version) { + return latestPublishedEntered(version, Map.of()); + } + + public String latestPublishedEntered(SkillVersion version, Map extras) { + return build("latest_published_entered", version, extras); + } + + public String latestPublishedRemoved(SkillVersion version, Map extras) { + return build("latest_published_removed", version, extras); + } + + public String latestPublishedRemoved(SkillVersion version, + SkillVersion replacementLatestPublished, + Map extras) { + LinkedHashMap payload = new LinkedHashMap<>(); + if (extras != null && !extras.isEmpty()) { + payload.putAll(extras); + } + if (replacementLatestPublished != null) { + payload.put("replacementLatestPublished", snapshotPayload("latest_published_entered", replacementLatestPublished)); + } + return build("latest_published_removed", version, payload); + } + + public String build(String snapshotKind, SkillVersion version, Map extras) { + LinkedHashMap payload = snapshotPayload(snapshotKind, version); + if (extras != null && !extras.isEmpty()) { + payload.putAll(extras); + } + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize compliance audit detail", ex); + } + } + + private LinkedHashMap snapshotPayload(String snapshotKind, SkillVersion version) { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put("snapshotKind", snapshotKind); + payload.put("versionId", version.getId()); + payload.put("version", version.getVersion()); + payload.put( + "compliance", + complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson()) + ); + return payload; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMapping.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMapping.java new file mode 100644 index 000000000..d0478388f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMapping.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +/** + * Normalized phase-1 compliance mapping payload stored in parsed metadata and projected to APIs. + */ +public record SkillComplianceMapping( + ComplianceStandard standard, + String standardVersion, + String controlId, + String controlTitle, + String evidenceUrl +) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataService.java new file mode 100644 index 000000000..bbfa01771 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataService.java @@ -0,0 +1,265 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Validates and extracts version-scoped compliance mappings from parsed frontmatter metadata. + */ +public class SkillComplianceMetadataService { + + private static final String COMPLIANCE_FIELD = "x-astron-compliance"; + private static final int MAX_ITEMS = 20; + private static final int MAX_STANDARD_VERSION_LENGTH = 32; + private static final int MAX_CONTROL_TITLE_LENGTH = 200; + private static final Pattern CONTROL_ID_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$"); + private static final Set ALLOWED_EVIDENCE_URL_SCHEMES = Set.of("http", "https"); + private static final String EVIDENCE_URL_ERROR = "evidenceUrl must use an http or https URI"; + private static final Set ALLOWED_KEYS = Set.of( + "standard", + "standardVersion", + "controlId", + "controlTitle", + "evidenceUrl" + ); + + private final ObjectMapper objectMapper; + + public SkillComplianceMetadataService() { + this(new ObjectMapper()); + } + + public SkillComplianceMetadataService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public ParseResult parseFrontmatter(Map frontmatter) { + if (frontmatter == null || frontmatter.isEmpty()) { + return ParseResult.empty(); + } + + Object rawMappings = frontmatter.get(COMPLIANCE_FIELD); + if (rawMappings == null) { + return ParseResult.empty(); + } + if (!(rawMappings instanceof List items) || items.isEmpty()) { + return ParseResult.invalid("x-astron-compliance must be a non-empty array"); + } + + List errors = new ArrayList<>(); + if (items.size() > MAX_ITEMS) { + errors.add("x-astron-compliance must contain at most " + MAX_ITEMS + " items"); + } + + List mappings = new ArrayList<>(); + Set seenKeys = new HashSet<>(); + for (int index = 0; index < items.size(); index++) { + Object rawItem = items.get(index); + if (!(rawItem instanceof Map rawMap)) { + errors.add("x-astron-compliance[" + index + "] must be an object"); + continue; + } + + Map item = normalizeItem(rawMap, index, errors); + if (item == null) { + continue; + } + + ComplianceStandard standard = parseStandard(item, index, errors); + String standardVersion = parseRequiredString(item, index, "standardVersion", MAX_STANDARD_VERSION_LENGTH, errors); + String controlId = parseRequiredControlId(item, index, errors); + String controlTitle = parseOptionalString(item, index, "controlTitle", MAX_CONTROL_TITLE_LENGTH, errors); + String evidenceUrl = parseOptionalAbsoluteUri(item, index, errors); + + if (standard == null || standardVersion == null || controlId == null) { + continue; + } + + String duplicateKey = normalizedDuplicateKey(standard, standardVersion, controlId); + if (!seenKeys.add(duplicateKey)) { + errors.add("x-astron-compliance contains duplicate mapping " + duplicateKey); + continue; + } + + mappings.add(new SkillComplianceMapping( + standard, + standardVersion.trim(), + controlId.trim(), + controlTitle, + evidenceUrl + )); + } + + if (!errors.isEmpty()) { + return new ParseResult(List.of(), List.copyOf(errors)); + } + return new ParseResult(List.copyOf(mappings), List.of()); + } + + public List readFromParsedMetadataJson(String parsedMetadataJson) { + if (parsedMetadataJson == null || parsedMetadataJson.isBlank()) { + return List.of(); + } + try { + Map parsed = objectMapper.readValue( + parsedMetadataJson, + new TypeReference>() {} + ); + Object rawFrontmatter = parsed.get("frontmatter"); + if (!(rawFrontmatter instanceof Map rawMap)) { + return List.of(); + } + Map frontmatter = new LinkedHashMap<>(); + rawMap.forEach((key, value) -> { + if (key instanceof String stringKey) { + frontmatter.put(stringKey, value); + } + }); + ParseResult result = parseFrontmatter(frontmatter); + return result.errors().isEmpty() ? result.mappings() : List.of(); + } catch (Exception ignored) { + return List.of(); + } + } + + private Map normalizeItem(Map rawMap, int index, List errors) { + Map item = new LinkedHashMap<>(); + for (Map.Entry entry : rawMap.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + errors.add("x-astron-compliance[" + index + "] contains a non-string field name"); + return null; + } + item.put(key, entry.getValue()); + } + + for (String key : item.keySet()) { + if (!ALLOWED_KEYS.contains(key)) { + errors.add("x-astron-compliance[" + index + "]." + key + " is not allowed"); + } + } + return item; + } + + private ComplianceStandard parseStandard(Map item, int index, List errors) { + Object rawValue = item.get("standard"); + if (!(rawValue instanceof String value) || value.isBlank()) { + errors.add("x-astron-compliance[" + index + "].standard is required"); + return null; + } + return ComplianceStandard.findByValue(value) + .orElseGet(() -> { + errors.add( + "x-astron-compliance[" + index + "].standard must be one of " + + List.of("mitre_attack", "nist_csf", "gdpr", "hipaa", "soc2") + ); + return null; + }); + } + + private String parseRequiredString(Map item, + int index, + String fieldName, + int maxLength, + List errors) { + Object rawValue = item.get(fieldName); + if (!(rawValue instanceof String value) || value.isBlank()) { + errors.add("x-astron-compliance[" + index + "]." + fieldName + " is required"); + return null; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + errors.add("x-astron-compliance[" + index + "]." + fieldName + " must be at most " + maxLength + " characters"); + return null; + } + return trimmed; + } + + private String parseRequiredControlId(Map item, int index, List errors) { + String controlId = parseRequiredString(item, index, "controlId", 128, errors); + if (controlId == null) { + return null; + } + if (!CONTROL_ID_PATTERN.matcher(controlId).matches()) { + errors.add("x-astron-compliance[" + index + "].controlId has an invalid format"); + return null; + } + return controlId; + } + + private String parseOptionalString(Map item, + int index, + String fieldName, + int maxLength, + List errors) { + Object rawValue = item.get(fieldName); + if (rawValue == null) { + return null; + } + if (!(rawValue instanceof String value) || value.isBlank()) { + errors.add("x-astron-compliance[" + index + "]." + fieldName + " must be a non-empty string"); + return null; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + errors.add("x-astron-compliance[" + index + "]." + fieldName + " must be at most " + maxLength + " characters"); + return null; + } + return trimmed; + } + + private String parseOptionalAbsoluteUri(Map item, int index, List errors) { + Object rawValue = item.get("evidenceUrl"); + if (rawValue == null) { + return null; + } + if (!(rawValue instanceof String value) || value.isBlank()) { + errors.add("x-astron-compliance[" + index + "]." + EVIDENCE_URL_ERROR); + return null; + } + try { + URI uri = URI.create(value.trim()); + String scheme = uri.getScheme(); + if (!uri.isAbsolute() + || scheme == null + || !ALLOWED_EVIDENCE_URL_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT))) { + errors.add("x-astron-compliance[" + index + "]." + EVIDENCE_URL_ERROR); + return null; + } + return uri.toString(); + } catch (IllegalArgumentException ex) { + errors.add("x-astron-compliance[" + index + "]." + EVIDENCE_URL_ERROR); + return null; + } + } + + private String normalizedDuplicateKey(ComplianceStandard standard, String standardVersion, String controlId) { + return standard.value() + + "/" + + standardVersion.trim().toLowerCase(Locale.ROOT) + + "/" + + controlId.trim().toUpperCase(Locale.ROOT); + } + + public record ParseResult( + List mappings, + List errors + ) { + public static ParseResult empty() { + return new ParseResult(List.of(), List.of()); + } + + public static ParseResult invalid(String error) { + return new ParseResult(List.of(), List.of(error)); + } + } +} 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 bbb748f8e..dc15f376d 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 @@ -15,10 +15,12 @@ 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.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.storage.ObjectStorageService; import java.time.Clock; import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.slf4j.Logger; @@ -47,6 +49,8 @@ public class SkillGovernanceService { private final SecurityScanService securityScanService; private final SkillStorageDeletionCompensationService compensationService; private final Clock clock; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public SkillGovernanceService(SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, @@ -266,26 +270,56 @@ public SkillVersion yankVersion(Long versionId, String actorUserId, String clien version.setYankReason(reason); version.setDownloadReady(false); SkillVersion saved = skillVersionRepository.save(version); - skillRepository.findById(version.getSkillId()).ifPresent(skill -> { - if (versionId.equals(skill.getLatestVersionId())) { - skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId())); - skill.setUpdatedBy(actorUserId); - skillRepository.save(skill); - } - }); - auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason)); + SkillVersion replacementLatestPublished = null; + Skill skill = skillRepository.findById(version.getSkillId()).orElse(null); + if (skill != null && versionId.equals(skill.getLatestVersionId())) { + replacementLatestPublished = findLatestPublishedVersion(skill.getId()); + skill.setLatestVersionId(replacementLatestPublished != null ? replacementLatestPublished.getId() : null); + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + } + LinkedHashMap auditExtras = new LinkedHashMap<>(); + if (reason != null && !reason.isBlank()) { + auditExtras.put("reason", reason); + } + auditLogService.record( + actorUserId, + "YANK_SKILL_VERSION", + "SKILL_VERSION", + versionId, + null, + clientIp, + userAgent, + complianceAuditDetailFactory.latestPublishedRemoved(version, replacementLatestPublished, auditExtras) + ); + if (replacementLatestPublished != null) { + auditLogService.record( + actorUserId, + "YANK_SKILL_VERSION", + "SKILL_VERSION", + replacementLatestPublished.getId(), + null, + clientIp, + userAgent, + complianceAuditDetailFactory.latestPublishedEntered(replacementLatestPublished, auditExtras) + ); + } eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent( version.getSkillId(), versionId, actorUserId)); return saved; } private Long findLatestPublishedVersionId(Long skillId) { + SkillVersion latestPublishedVersion = findLatestPublishedVersion(skillId); + return latestPublishedVersion != null ? latestPublishedVersion.getId() : null; + } + + private SkillVersion findLatestPublishedVersion(Long skillId) { return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream() .max(java.util.Comparator .comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) .thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) .thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))) - .map(SkillVersion::getId) .orElse(null); } 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 66c5e3191..67b0d214e 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 @@ -11,6 +11,8 @@ 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.domain.skill.metadata.SkillComplianceMapping; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceMetadataService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -61,6 +63,7 @@ public class SkillQueryService { private final SkillSlugResolutionService skillSlugResolutionService; private final SkillLifecycleProjectionService skillLifecycleProjectionService; private final UserAccountRepository userAccountRepository; + private final SkillComplianceMetadataService complianceMetadataService = new SkillComplianceMetadataService(); public SkillQueryService( NamespaceRepository namespaceRepository, @@ -127,7 +130,8 @@ public record SkillVersionDetailDTO( Long totalSize, java.time.Instant publishedAt, String parsedMetadataJson, - String manifestJson + String manifestJson, + List complianceMappings ) {} public record SkillVersionCompareDTO( @@ -311,7 +315,8 @@ public SkillVersionDetailDTO getVersionDetail( skillVersion.getTotalSize(), skillVersion.getPublishedAt(), skillVersion.getParsedMetadataJson(), - skillVersion.getManifestJson() + skillVersion.getManifestJson(), + complianceMetadataService.readFromParsedMetadataJson(skillVersion.getParsedMetadataJson()) ); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index fb2b7a41f..9edbc9414 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.domain.skill.validation; import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceMetadataService; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import java.util.ArrayList; @@ -18,6 +20,7 @@ public class SkillPackageValidator { private static final Pattern YAML_LINE_COLUMN = Pattern.compile("line\\s+(\\d+),\\s+column\\s+(\\d+)"); private final SkillMetadataParser metadataParser; + private final SkillComplianceMetadataService complianceMetadataService; private final int maxFileCount; private final long maxSingleFileSize; private final long maxTotalPackageSize; @@ -26,6 +29,7 @@ public class SkillPackageValidator { public SkillPackageValidator(SkillMetadataParser metadataParser) { this( metadataParser, + new SkillComplianceMetadataService(), SkillPackagePolicy.MAX_FILE_COUNT, SkillPackagePolicy.MAX_SINGLE_FILE_SIZE, SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE, @@ -38,7 +42,24 @@ public SkillPackageValidator(SkillMetadataParser metadataParser, long maxSingleFileSize, long maxTotalPackageSize, Set allowedExtensions) { + this( + metadataParser, + new SkillComplianceMetadataService(), + maxFileCount, + maxSingleFileSize, + maxTotalPackageSize, + allowedExtensions + ); + } + + public SkillPackageValidator(SkillMetadataParser metadataParser, + SkillComplianceMetadataService complianceMetadataService, + int maxFileCount, + long maxSingleFileSize, + long maxTotalPackageSize, + Set allowedExtensions) { this.metadataParser = metadataParser; + this.complianceMetadataService = complianceMetadataService; this.maxFileCount = maxFileCount; this.maxSingleFileSize = maxSingleFileSize; this.maxTotalPackageSize = maxTotalPackageSize; @@ -89,7 +110,11 @@ public ValidationResult validate(List entries) { // 2. Validate frontmatter try { String content = new String(skillMd.content()); - metadataParser.parse(content); + SkillMetadata metadata = metadataParser.parse(content); + SkillComplianceMetadataService.ParseResult complianceResult = + complianceMetadataService.parseFrontmatter(metadata.frontmatter()); + complianceResult.errors().forEach(error -> + errors.add("Invalid SKILL.md frontmatter: " + error)); } catch (LocalizedDomainException e) { errors.add("Invalid SKILL.md frontmatter: " + formatMetadataError(e)); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataServiceTest.java new file mode 100644 index 000000000..bcec9bac5 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataServiceTest.java @@ -0,0 +1,146 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SkillComplianceMetadataServiceTest { + + private final SkillComplianceMetadataService service = + new SkillComplianceMetadataService(new ObjectMapper()); + + @Test + void parseFrontmatter_acceptsKnownComplianceMappings() { + Map frontmatter = Map.of( + "x-astron-compliance", + List.of( + Map.of( + "standard", "mitre_attack", + "standardVersion", "v14.1", + "controlId", "T1059", + "controlTitle", "Command and Scripting Interpreter", + "evidenceUrl", "https://example.com/evidence" + ) + ) + ); + + SkillComplianceMetadataService.ParseResult result = service.parseFrontmatter(frontmatter); + + assertThat(result.errors()).isEmpty(); + assertThat(result.mappings()).singleElement() + .extracting( + SkillComplianceMapping::standard, + SkillComplianceMapping::standardVersion, + SkillComplianceMapping::controlId, + SkillComplianceMapping::controlTitle, + SkillComplianceMapping::evidenceUrl + ) + .containsExactly( + ComplianceStandard.MITRE_ATTACK, + "v14.1", + "T1059", + "Command and Scripting Interpreter", + "https://example.com/evidence" + ); + } + + @Test + void parseFrontmatter_rejectsDuplicateMappingsAfterNormalization() { + Map frontmatter = Map.of( + "x-astron-compliance", + List.of( + Map.of( + "standard", "gdpr", + "standardVersion", "2024", + "controlId", "article-17" + ), + Map.of( + "standard", "gdpr", + "standardVersion", " 2024 ", + "controlId", "ARTICLE-17" + ) + ) + ); + + SkillComplianceMetadataService.ParseResult result = service.parseFrontmatter(frontmatter); + + assertThat(result.mappings()).isEmpty(); + assertThat(result.errors()).contains("x-astron-compliance contains duplicate mapping gdpr/2024/ARTICLE-17"); + } + + @Test + void parseFrontmatter_rejectsNonArrayComplianceField() { + Map frontmatter = Map.of( + "x-astron-compliance", + "gdpr:Article-17" + ); + + SkillComplianceMetadataService.ParseResult result = service.parseFrontmatter(frontmatter); + + assertThat(result.mappings()).isEmpty(); + assertThat(result.errors()).contains("x-astron-compliance must be a non-empty array"); + } + + @Test + void parseFrontmatter_rejectsUnsafeEvidenceUrlSchemes() { + Map frontmatter = Map.of( + "x-astron-compliance", + List.of( + Map.of( + "standard", "gdpr", + "standardVersion", "2024", + "controlId", "Article-17", + "evidenceUrl", "javascript:alert(1)" + ), + Map.of( + "standard", "soc2", + "standardVersion", "2017", + "controlId", "CC6.1", + "evidenceUrl", "data:text/html;base64,SGk=" + ) + ) + ); + + SkillComplianceMetadataService.ParseResult result = service.parseFrontmatter(frontmatter); + + assertThat(result.mappings()).isEmpty(); + assertThat(result.errors()).containsExactlyInAnyOrder( + "x-astron-compliance[0].evidenceUrl must use an http or https URI", + "x-astron-compliance[1].evidenceUrl must use an http or https URI" + ); + } + + @Test + void readFromParsedMetadataJson_extractsMappingsFromStoredSkillMetadata() { + String parsedMetadataJson = """ + { + "name": "demo", + "description": "demo", + "version": "1.0.0", + "body": "Body", + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """; + + List mappings = service.readFromParsedMetadataJson(parsedMetadataJson); + + assertThat(mappings).singleElement() + .extracting( + SkillComplianceMapping::standard, + SkillComplianceMapping::standardVersion, + SkillComplianceMapping::controlId + ) + .containsExactly(ComplianceStandard.SOC2, "2017", "CC6.1"); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index b6f3faff4..af93bb93a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -5,6 +5,8 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.doThrow; @@ -37,6 +39,7 @@ 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; @@ -143,6 +146,19 @@ void archiveSkill_requiresOwnerOrNamespaceAdmin() { void yankVersion_setsYankedStatus() { SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "gdpr", + "standardVersion": "2024", + "controlId": "Article-17" + } + ] + } + } + """); given(skillVersionRepository.findById(22L)).willReturn(Optional.of(version)); given(skillVersionRepository.save(version)).willReturn(version); given(skillRepository.findById(2L)).willReturn(Optional.empty()); @@ -152,7 +168,26 @@ void yankVersion_setsYankedStatus() { assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.YANKED); assertThat(result.getYankedBy()).isEqualTo("admin"); assertThat(result.getYankedAt()).isEqualTo(Instant.now(CLOCK)); - verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); + verify(auditLogService).record( + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq("YANK_SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq("SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq(22L), + org.mockito.ArgumentMatchers.eq(null), + org.mockito.ArgumentMatchers.eq("127.0.0.1"), + org.mockito.ArgumentMatchers.eq("JUnit"), + contains("\"reason\":\"broken\"") + ); + verify(auditLogService).record( + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq("YANK_SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq("SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq(22L), + org.mockito.ArgumentMatchers.eq(null), + org.mockito.ArgumentMatchers.eq("127.0.0.1"), + org.mockito.ArgumentMatchers.eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_removed\"") + ); } @Test @@ -179,11 +214,37 @@ void yankVersion_recomputesLatestPublishedPointer() { setField(yanked, "id", 22L); yanked.setStatus(SkillVersionStatus.PUBLISHED); yanked.setPublishedAt(Instant.parse("2026-03-18T10:00:00Z")); + yanked.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "gdpr", + "standardVersion": "2024", + "controlId": "Article-17" + } + ] + } + } + """); SkillVersion fallback = new SkillVersion(2L, "1.0.0", "owner"); setField(fallback, "id", 11L); fallback.setStatus(SkillVersionStatus.PUBLISHED); fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z")); + fallback.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); setField(skill, "id", 2L); @@ -199,6 +260,33 @@ void yankVersion_recomputesLatestPublishedPointer() { assertThat(skill.getLatestVersionId()).isEqualTo(11L); verify(skillRepository).save(skill); + + ArgumentCaptor entityIdCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor detailCaptor = ArgumentCaptor.forClass(String.class); + verify(auditLogService, times(2)).record( + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq("YANK_SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq("SKILL_VERSION"), + entityIdCaptor.capture(), + org.mockito.ArgumentMatchers.eq(null), + org.mockito.ArgumentMatchers.eq("127.0.0.1"), + org.mockito.ArgumentMatchers.eq("JUnit"), + detailCaptor.capture() + ); + assertThat(entityIdCaptor.getAllValues()).containsExactlyInAnyOrder(22L, 11L); + assertThat(detailCaptor.getAllValues()).anySatisfy(detail -> { + assertThat(detail).contains("\"snapshotKind\":\"latest_published_removed\""); + assertThat(detail).contains("\"versionId\":22"); + assertThat(detail).contains("\"replacementLatestPublished\""); + assertThat(detail).contains("\"snapshotKind\":\"latest_published_entered\""); + assertThat(detail).contains("\"versionId\":11"); + assertThat(detail).contains("\"standard\":\"soc2\""); + }); + assertThat(detailCaptor.getAllValues()).anySatisfy(detail -> { + assertThat(detail).contains("\"snapshotKind\":\"latest_published_entered\""); + assertThat(detail).contains("\"versionId\":11"); + assertThat(detail).contains("\"standard\":\"soc2\""); + }); } @Test 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 c2b6b63cf..eda0246b8 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 @@ -189,6 +189,58 @@ void testInvalidYamlFrontmatterWithColonInValueShouldStillPass() { assertTrue(result.errors().isEmpty()); } + @Test + void testInvalidComplianceMetadataRejected() { + String skillMdContent = """ + --- + name: compliance-skill + description: Skill with malformed compliance metadata + version: 1.0.0 + x-astron-compliance: gdpr + --- + Body + """; + + List entries = List.of( + new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(error -> + error.contains("Invalid SKILL.md frontmatter") + && error.contains("x-astron-compliance must be a non-empty array"))); + } + + @Test + void testUnsafeComplianceEvidenceUrlRejected() { + String skillMdContent = """ + --- + name: compliance-skill + description: Skill with unsafe evidence url + version: 1.0.0 + x-astron-compliance: + - standard: gdpr + standardVersion: "2024" + controlId: Article-17 + evidenceUrl: "javascript:alert(1)" + --- + Body + """; + + List entries = List.of( + new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(error -> + error.contains("Invalid SKILL.md frontmatter") + && error.contains("evidenceUrl must use an http or https URI"))); + } + @Test void testPackageTooLarge() { // Use a custom validator with 2KB total limit to test the logic 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 5a54d0a6c..e1c3ceb7d 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 @@ -1,5 +1,6 @@ package com.iflytek.skillhub.search; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import java.util.List; /** @@ -13,8 +14,21 @@ public record SearchQuery( int page, int size, List labelSlugs, + ComplianceStandard complianceStandard, boolean requireInstallableLatest ) { + public SearchQuery( + String keyword, + Long namespaceId, + SearchVisibilityScope visibilityScope, + String sortBy, + int page, + int size, + List labelSlugs, + boolean requireInstallableLatest) { + this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, null, requireInstallableLatest); + } + public SearchQuery( String keyword, Long namespaceId, @@ -23,7 +37,7 @@ public SearchQuery( int page, int size, List labelSlugs) { - this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, false); + this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, null, false); } public SearchQuery( @@ -33,6 +47,6 @@ public SearchQuery( String sortBy, int page, int size) { - this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of(), false); + this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of(), null, 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 640158443..e093069ae 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 @@ -2,6 +2,7 @@ import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import com.iflytek.skillhub.search.SearchEmbeddingService; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; @@ -80,8 +81,10 @@ public PostgresFullTextQueryService(EntityManager entityManager, public SearchResult search(SearchQuery query) { String normalizedKeyword = normalizeKeyword(query.keyword()); String tsQuery = buildPrefixTsQuery(normalizedKeyword); + String complianceStandard = normalizeComplianceStandard(query.complianceStandard()); boolean hasKeyword = normalizedKeyword != null; boolean hasTsQuery = tsQuery != null; + boolean hasComplianceStandard = complianceStandard != null; boolean useRelevanceOrdering = "relevance".equals(query.sortBy()) && hasKeyword; boolean useShortPrefixTitleSearch = hasTsQuery && isShortAsciiPrefixSearch(normalizedKeyword); boolean useSemanticRerank = semanticEnabled @@ -107,7 +110,7 @@ public SearchResult search(SearchQuery query) { 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()) { + if (query.requireInstallableLatest() || hasComplianceStandard) { sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id "); } sql.append("WHERE 1=1 "); @@ -128,6 +131,9 @@ public SearchResult search(SearchQuery query) { sql.append("AND latest.download_ready = TRUE "); sql.append("AND latest.yanked_at IS NULL "); } + if (hasComplianceStandard) { + sql.append("AND (latest.parsed_metadata_json -> 'frontmatter' -> 'x-astron-compliance') @> CAST(:complianceFilter AS jsonb) "); + } sql.append("AND (n.status <> 'ARCHIVED' "); if (query.visibilityScope().userId() != null) { sql.append("OR d.namespace_id IN :memberNamespaceIds "); @@ -203,6 +209,9 @@ public SearchResult search(SearchQuery query) { if (query.labelSlugs() != null && !query.labelSlugs().isEmpty()) { nativeQuery.setParameter("labelSlugs", query.labelSlugs()); } + if (hasComplianceStandard) { + nativeQuery.setParameter("complianceFilter", "[{\"standard\":\"" + complianceStandard + "\"}]"); + } if (hasKeyword) { if (hasTsQuery) { @@ -247,6 +256,9 @@ public SearchResult search(SearchQuery query) { if (query.labelSlugs() != null && !query.labelSlugs().isEmpty()) { countQuery.setParameter("labelSlugs", query.labelSlugs()); } + if (hasComplianceStandard) { + countQuery.setParameter("complianceFilter", "[{\"standard\":\"" + complianceStandard + "\"}]"); + } if (hasKeyword) { if (hasTsQuery) { @@ -264,6 +276,13 @@ public SearchResult search(SearchQuery query) { return new SearchResult(skillIds, total, query.page(), query.size()); } + private String normalizeComplianceStandard(ComplianceStandard complianceStandard) { + if (complianceStandard == null) { + return null; + } + return complianceStandard.value(); + } + private List rerankBySemanticSimilarity(List candidateSkillIds, String normalizedKeyword, int requestedOffset, 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 c84c6cbd1..7d8ec8083 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 @@ -1,5 +1,6 @@ package com.iflytek.skillhub.search.postgres; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; import com.iflytek.skillhub.search.HashingSearchEmbeddingService; @@ -295,6 +296,42 @@ void labelFiltersShouldBeAppliedToSearchAndCountQueries() { verify(countQuery).setParameter("labelSlugs", List.of("code-generation", "official")); } + @Test + void complianceStandardFilterShouldBeAppliedToSearchAndCountQueries() { + 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( + "review", + null, + SearchVisibilityScope.anonymous(), + "relevance", + 0, + 20, + List.of("official"), + ComplianceStandard.GDPR, + false + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains( + "(latest.parsed_metadata_json -> 'frontmatter' -> 'x-astron-compliance') @> CAST(:complianceFilter AS jsonb)" + ); + verify(nativeQuery).setParameter("complianceFilter", "[{\"standard\":\"gdpr\"}]"); + verify(countQuery).setParameter("complianceFilter", "[{\"standard\":\"gdpr\"}]"); + } + @Test void downloadsSortShouldNotBindRelevanceOnlyParameters() { EntityManager entityManager = mock(EntityManager.class); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a99ba1a97..d69ac94de 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4161,6 +4161,14 @@ export interface components { publishedAt?: string; parsedMetadataJson?: string; manifestJson?: string; + complianceMappings?: components["schemas"]["SkillComplianceMappingResponse"][]; + }; + SkillComplianceMappingResponse: { + standard?: "mitre_attack" | "nist_csf" | "gdpr" | "hipaa" | "soc2"; + standardVersion?: string; + controlId?: string; + controlTitle?: string; + evidenceUrl?: string; }; ApiResponseSkillVersionCompareResponse: { /** Format: int32 */ @@ -8508,6 +8516,7 @@ export interface operations { q?: string; namespace?: string; label?: string[]; + complianceStandard?: "mitre_attack" | "nist_csf" | "gdpr" | "hipaa" | "soc2"; sort?: string; page?: number; size?: number; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 60bef288e..1baf372df 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -255,6 +255,14 @@ export interface SkillVersion { downloadAvailable: boolean } +export interface SkillComplianceMapping { + standard: ComplianceStandard + standardVersion: string + controlId: string + controlTitle?: string + evidenceUrl?: string +} + export interface SkillVersionDetail { id: number version: string @@ -265,6 +273,7 @@ export interface SkillVersionDetail { publishedAt: string parsedMetadataJson?: string manifestJson?: string + complianceMappings: SkillComplianceMapping[] } export interface SkillFile { @@ -323,11 +332,20 @@ export interface SkillTag { createdAt: string } +export const COMPLIANCE_STANDARD_VALUES = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const + +export type ComplianceStandard = (typeof COMPLIANCE_STANDARD_VALUES)[number] + +export function isComplianceStandard(value: string): value is ComplianceStandard { + return COMPLIANCE_STANDARD_VALUES.some((candidate) => candidate === value) +} + // Search and pagination export interface SearchParams { q?: string namespace?: string label?: string + complianceStandard?: ComplianceStandard sort?: string page?: number size?: number diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index c9b3bac87..fde277b04 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -1,10 +1,12 @@ import { lazy, Suspense, type ComponentType } from 'react' import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router' +import type { ComplianceStandard } from '@/api/types' import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' import { createRequireAuth } from '@/shared/lib/auth-route' import { normalizeSearchQuery } from '@/shared/lib/search-query' +import { isComplianceStandard } from '@/api/types' /** * Central route registry for the SkillHub web app. @@ -199,11 +201,13 @@ const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: 'search', component: SearchPage, - validateSearch: (search: Record): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => { + validateSearch: (search: Record): { q: string; namespace?: string; label?: string; complianceStandard?: ComplianceStandard; sort: string; page: number; starredOnly: boolean } => { + const rawComplianceStandard = typeof search.complianceStandard === 'string' ? search.complianceStandard : '' 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, + complianceStandard: isComplianceStandard(rawComplianceStandard) ? rawComplianceStandard : undefined, sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, starredOnly: search.starredOnly === true || search.starredOnly === 'true', @@ -227,8 +231,9 @@ const namespaceRoute = createRoute({ const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', - validateSearch: (search: Record): { returnTo?: string } => ({ + validateSearch: (search: Record): { returnTo?: string; version?: string } => ({ returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, + version: typeof search.version === 'string' && search.version ? search.version : undefined, }), component: SkillDetailPage, }) diff --git a/web/src/features/skill/install-command.test.ts b/web/src/features/skill/install-command.test.ts index 60b8ee3a1..3b2e4eeae 100644 --- a/web/src/features/skill/install-command.test.ts +++ b/web/src/features/skill/install-command.test.ts @@ -68,6 +68,12 @@ describe('install-command', () => { ) }) + it('adds an explicit version to the ClawHub install command when a version is selected', () => { + expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0')).toBe( + 'npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn --version 0.9.0', + ) + }) + it('builds a one-line SkillHub npx command for the global namespace', () => { expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe( 'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn', @@ -80,6 +86,12 @@ describe('install-command', () => { ) }) + it('adds an explicit version to the SkillHub install command when a version is selected', () => { + expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0')).toBe( + 'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn --version 0.9.0', + ) + }) + it('uses the runtime app base url when available', () => { setMockWindow('https://app.example.com') diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index 3f409b9dc..d15818487 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -29,14 +29,21 @@ export function getBaseUrl(): string { return `${window.location.protocol}//${window.location.host}` } -export function buildInstallCommand(namespace: string, slug: string, baseUrl: string): string { +function buildVersionedCommand(command: string, version?: string): string { + if (!version) { + return command + } + return `${command} --version ${version}` +} + +export function buildInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string { const installTarget = buildInstallTarget(namespace, slug) - return `npx clawhub install ${installTarget} --registry ${baseUrl}` + return buildVersionedCommand(`npx clawhub install ${installTarget} --registry ${baseUrl}`, version) } -export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string): string { +export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string { const namespaceArg = namespace === 'global' ? '' : ` --namespace ${namespace}` - return `npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}` + return buildVersionedCommand(`npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`, version) } interface CommandBlockProps { @@ -80,11 +87,11 @@ function CommandBlock({ command }: CommandBlockProps) { ) } -export function InstallCommand({ namespace, slug }: InstallCommandProps) { +export function InstallCommand({ namespace, slug, version }: 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]) + const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl, version), [baseUrl, namespace, slug, version]) + const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl, version), [baseUrl, namespace, slug, version]) return ( diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2e7d4f285..6ffc3dba4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -176,6 +176,16 @@ "filters": { "label": "Filter:" }, + "compliance": { + "label": "Compliance:", + "options": { + "mitre_attack": "MITRE ATT&CK", + "nist_csf": "NIST CSF", + "gdpr": "GDPR", + "hipaa": "HIPAA", + "soc2": "SOC 2" + } + }, "sort": { "label": "Sort:", "relevance": "Relevance", @@ -822,6 +832,13 @@ "noVersions": "No versions", "fileCount": "{{count}} files", "version": "Version", + "complianceSectionTitle": "Declared Alignment", + "complianceSectionDescription": "Version {{version}} declares the following control mappings.", + "complianceEmptyTitle": "No declared mappings", + "complianceEmptyDescription": "This version does not declare alignment to a compliance standard yet.", + "complianceStandardVersion": "Standard version: {{version}}", + "complianceControlId": "Control ID: {{controlId}}", + "complianceEvidenceLink": "Open evidence", "downloads": "Downloads", "rating": "Rating", "ratingNone": "None", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2281121ae..7234cea84 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -176,6 +176,16 @@ "filters": { "label": "筛选:" }, + "compliance": { + "label": "合规标准:", + "options": { + "mitre_attack": "MITRE ATT&CK", + "nist_csf": "NIST CSF", + "gdpr": "GDPR", + "hipaa": "HIPAA", + "soc2": "SOC 2" + } + }, "sort": { "label": "排序:", "relevance": "相关性", @@ -822,6 +832,13 @@ "noVersions": "暂无版本", "fileCount": "{{count}} 个文件", "version": "版本", + "complianceSectionTitle": "声明的对齐关系", + "complianceSectionDescription": "版本 {{version}} 声明了以下控制项映射。", + "complianceEmptyTitle": "暂无声明映射", + "complianceEmptyDescription": "这个版本暂未声明任何合规标准映射。", + "complianceStandardVersion": "标准版本:{{version}}", + "complianceControlId": "控制项 ID:{{controlId}}", + "complianceEvidenceLink": "查看证据链接", "downloads": "下载量", "rating": "评分", "ratingNone": "暂无", diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index aac629a03..0ee0b6692 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -134,6 +134,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 1, starredOnly: false, @@ -156,6 +157,7 @@ describe('SearchPage', () => { expect(html).toContain('Code Generation') expect(findButton('Code Generation').variant).toBe('default') expect(findButton('Official').variant).toBe('outline') + expect(findButton('search.compliance.options.gdpr').variant).toBe('default') }) it('toggles the selected label off and resets paging', () => { @@ -169,6 +171,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: '', + complianceStandard: 'gdpr', sort: 'downloads', page: 0, starredOnly: false, @@ -187,6 +190,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'newest', page: 0, starredOnly: false, @@ -206,6 +210,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 2, starredOnly: false, @@ -217,6 +222,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 0, starredOnly: true, @@ -231,6 +237,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 1, size: 12, @@ -248,6 +255,7 @@ describe('SearchPage', () => { q: 'onboarding', namespace: 'product-team', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 0, starredOnly: false, @@ -256,6 +264,25 @@ describe('SearchPage', () => { }) }) + it('toggles the compliance filter and resets paging', () => { + renderToStaticMarkup() + + findButton('search.compliance.options.soc2').onClick?.() + + expect(navigateMock).toHaveBeenCalledWith({ + to: '/search', + search: { + q: 'agent', + namespace: 'team-ai', + label: 'code-generation', + complianceStandard: 'soc2', + sort: 'downloads', + page: 0, + starredOnly: false, + }, + }) + }) + 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 ec23d319a..d4730c236 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -2,7 +2,7 @@ import { startTransition, useEffect, useRef, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' -import type { SkillSummary } from '@/api/types' +import { COMPLIANCE_STANDARD_VALUES, type ComplianceStandard, type SkillSummary } from '@/api/types' import { useAuth } from '@/features/auth/use-auth' import { SearchBar } from '@/features/search/search-bar' import { SkillCard } from '@/features/skill/skill-card' @@ -93,12 +93,32 @@ export function SearchPage() { const q = normalizeSearchQuery(searchParams.q || '') const namespace = (searchParams.namespace || '').replace(/^@/, '') const selectedLabel = searchParams.label || '' + const complianceStandard = searchParams.complianceStandard const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false const [queryInput, setQueryInput] = useState(formatNamespaceSearchInput(namespace, q)) const previousPageRef = useRef(page) + const buildSearchState = (overrides: Partial<{ + q: string + namespace: string + label: string + complianceStandard?: ComplianceStandard + sort: string + page: number + starredOnly: boolean + }> = {}) => ({ + q, + namespace, + label: selectedLabel, + complianceStandard, + sort, + page, + starredOnly, + ...overrides, + }) + useEffect(() => { setQueryInput(formatNamespaceSearchInput(namespace, q)) }, [namespace, q]) @@ -121,6 +141,7 @@ export function SearchPage() { q, namespace: namespace || undefined, label: selectedLabel || undefined, + complianceStandard, sort, page, size: PAGE_SIZE, @@ -142,44 +163,77 @@ export function SearchPage() { if (!parsedInput.query && !parsedInput.namespace) { startTransition(() => { - navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 }) + navigate({ + to: '/search', + search: { + q: '', + namespace: '', + label: selectedLabel, + complianceStandard, + sort, + page: 0, + starredOnly, + }, + replace: page === 0, + }) }) return } const timeoutId = window.setTimeout(() => { startTransition(() => { - navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ + to: '/search', + search: { + q: parsedInput.query, + namespace: parsedInput.namespace, + label: selectedLabel, + complianceStandard, + sort, + page: 0, + starredOnly, + }, + replace: true, + }) }) }, 250) return () => window.clearTimeout(timeoutId) - }, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly]) + }, [complianceStandard, navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly]) const handleSearch = (query: string) => { const parsedInput = parseNamespaceSearchInput(query) setQueryInput(query) startTransition(() => { - navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ + to: '/search', + search: buildSearchState({ q: parsedInput.query, namespace: parsedInput.namespace, page: 0 }), + replace: true, + }) }) } const handleSortChange = (newSort: string) => { - navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ sort: newSort, page: 0 }) }) } const handlePageChange = (newPage: number) => { blurActiveElement() - navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: newPage, starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ page: newPage }) }) } const handleLabelToggle = (label: string) => { const nextLabel = selectedLabel === label ? '' : label - navigate({ to: '/search', search: { q, namespace, label: nextLabel, sort, page: 0, starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ label: nextLabel, page: 0 }) }) + } + + const handleComplianceToggle = (standard: ComplianceStandard) => { + const nextStandard = complianceStandard === standard ? undefined : standard + navigate({ to: '/search', search: buildSearchState({ complianceStandard: nextStandard, page: 0 }) }) } const handleNamespaceClear = () => { - navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ namespace: '', page: 0 }) }) } const handleStarredToggle = () => { @@ -193,7 +247,7 @@ export function SearchPage() { return } - navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ page: 0, starredOnly: !starredOnly }) }) } const handleSkillClick = (namespace: string, slug: string) => { @@ -301,6 +355,21 @@ export function SearchPage() { ) : null} + {!starredOnly && ( +
+ {t('search.compliance.label')} + {COMPLIANCE_STANDARD_VALUES.map((standard) => ( + + ))} +
+ )} {/* Results */} diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 1c6374ceb..026d98654 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -16,9 +16,11 @@ const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() const useSkillVersionsMock = vi.fn() +const useSkillVersionDetailMock = vi.fn() const useSkillFilesMock = vi.fn() const useSkillReadmeMock = vi.fn() const useSkillFileMock = vi.fn() +let detailSearchState: { returnTo?: string; version?: string } = { returnTo: '/dashboard/skills' } let authState: { user: { userId: string; platformRoles: string[] } | null hasRole: (role: string) => boolean @@ -31,7 +33,7 @@ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, useParams: () => ({ namespace: 'global', slug: 'demo-skill' }), useRouterState: () => ({ pathname: '/space/global/demo-skill', searchStr: '', hash: '' }), - useSearch: () => ({ returnTo: '/dashboard/skills' }), + useSearch: () => detailSearchState, })) vi.mock('react-i18next', async () => { @@ -137,7 +139,7 @@ vi.mock('@/features/skill/file-tree', () => ({ })) vi.mock('@/features/skill/install-command', () => ({ - InstallCommand: () =>
install
, + InstallCommand: ({ version }: { version?: string }) =>
install:{version ?? 'latest'}
, })) vi.mock('@/features/social/rating-input', () => ({ @@ -159,7 +161,7 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useAttachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), useDetachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args), - useSkillVersionDetail: () => ({ data: undefined }), + useSkillVersionDetail: (...args: unknown[]) => useSkillVersionDetailMock(...args), useSkillFiles: (...args: unknown[]) => useSkillFilesMock(...args), useSkillReadme: (...args: unknown[]) => useSkillReadmeMock(...args), useSkillFile: (...args: unknown[]) => useSkillFileMock(...args), @@ -236,8 +238,10 @@ describe('SkillDetailPage', () => { useSkillFilesMock.mockReset() useSkillReadmeMock.mockReset() useSkillFileMock.mockReset() + useSkillVersionDetailMock.mockReset() toastMocks.success.mockReset() toastMocks.error.mockReset() + detailSearchState = { returnTo: '/dashboard/skills' } hasRoleMock.mockImplementation((role: string) => role === 'USER') authState = { user: { userId: 'owner-1', platformRoles: ['USER'] }, @@ -269,6 +273,20 @@ describe('SkillDetailPage', () => { useSkillFilesMock.mockReturnValue({ data: [] }) useSkillReadmeMock.mockReturnValue({ data: '# Demo', error: null }) useSkillFileMock.mockReturnValue({ data: null, isLoading: false, error: null }) + useSkillVersionDetailMock.mockReturnValue({ + data: { + id: 10, + version: '1.0.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-20T00:00:00Z', + parsedMetadataJson: '{}', + manifestJson: '[]', + complianceMappings: [], + }, + }) }) it('shows hard delete action for the skill owner', () => { @@ -314,6 +332,66 @@ describe('SkillDetailPage', () => { expect(html).not.toContain('skillDetail.deleteSkill') }) + it('renders version-scoped compliance mappings for the selected version from route search', () => { + detailSearchState = { returnTo: '/dashboard/skills', version: '0.9.0' } + useSkillVersionsMock.mockReturnValue({ + data: [ + { + id: 10, + version: '1.0.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-20T00:00:00Z', + downloadAvailable: true, + }, + { + id: 9, + version: '0.9.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-19T00:00:00Z', + downloadAvailable: true, + }, + ], + }) + useSkillVersionDetailMock.mockImplementation((_namespace: string, _slug: string, version?: string) => ({ + data: version === '0.9.0' + ? { + id: 9, + version: '0.9.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-19T00:00:00Z', + parsedMetadataJson: '{}', + manifestJson: '[]', + complianceMappings: [ + { + standard: 'gdpr', + standardVersion: '2024', + controlId: 'Article-17', + controlTitle: 'Right to erasure', + evidenceUrl: 'https://example.com/evidence', + }, + ], + } + : undefined, + })) + + const html = renderToStaticMarkup() + + expect(useSkillVersionDetailMock).toHaveBeenCalledWith('global', 'demo-skill', '0.9.0', true) + expect(html).toContain('skillDetail.complianceSectionTitle') + expect(html).toContain('Right to erasure') + expect(html).toContain('skillDetail.complianceControlId') + expect(html).toContain('install:0.9.0') + }) + it('shows the label management panel for a user who can manage the skill lifecycle', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 77b0c2914..56d1ca473 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -8,7 +8,7 @@ import { resolvePackageRelativeLink } from '@/features/skill/package-relative-li 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 type { SkillComplianceMapping, 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' @@ -103,6 +103,10 @@ function createPackageFilePreviewNode(file: SkillFile): FileTreeNode { } } +function renderComplianceMappingTitle(mapping: SkillComplianceMapping) { + return mapping.controlTitle || mapping.controlId +} + function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { if (error.serverMessageKey === 'promotion.duplicate_pending') { return 'promotion.duplicate_pending' @@ -160,8 +164,16 @@ export function SkillDetailPage() { const headlineVersion = skill ? getHeadlineVersion(skill) : null const publishedVersion = skill ? getPublishedVersion(skill) : null const ownerPreviewVersion = skill ? getOwnerPreviewVersion(skill) : null - const selectedVersion = headlineVersion?.version ?? versions?.[0]?.version - const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0] + const requestedVersion = search.version + const requestedVersionEntry = requestedVersion + ? versions?.find((version) => version.version === requestedVersion) + : undefined + const headlineVersionEntry = headlineVersion + ? versions?.find((version) => version.version === headlineVersion.version) + : undefined + const selectedVersionEntry = requestedVersionEntry ?? headlineVersionEntry ?? versions?.[0] + const selectedVersion = selectedVersionEntry?.version ?? requestedVersion ?? headlineVersion?.version ?? versions?.[0]?.version + const { data: selectedVersionDetail } = useSkillVersionDetail(qns, qslug, selectedVersion, skillReady) const { data: files } = useSkillFiles(qns, qslug, selectedVersion, skillReady) const documentationPath = resolveDocumentationFilePath(files) const { data: readme, error: readmeError } = useSkillReadme(qns, qslug, selectedVersion, documentationPath, skillReady) @@ -194,6 +206,10 @@ export function SkillDetailPage() { const canHardDeleteSkill = Boolean(skill && user && (skill.ownerId === user.userId || hasRole('SUPER_ADMIN'))) const canManageLabels = Boolean(skill && user && (skill.canManageLifecycle || hasRole('SUPER_ADMIN'))) const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false) + const selectedVersionComplianceMappings = selectedVersionDetail?.complianceMappings ?? [] + const installCommandVersion = selectedVersionEntry?.status === 'PUBLISHED' + ? selectedVersionEntry.version + : publishedVersion?.version useEffect(() => { // Recompute collapse rules whenever rendered documentation height changes so the page can keep @@ -261,6 +277,14 @@ export function SkillDetailPage() { queryClient.invalidateQueries({ queryKey: ['skills'] }) } + const handleVersionSelect = (version: string) => { + navigate({ + to: '/space/$namespace/$slug', + params: { namespace, slug }, + search: { returnTo: search.returnTo, version }, + }) + } + const hideMutation = useMutation({ mutationFn: () => adminApi.hideSkill(skill!.id), onSuccess: refreshSkill, @@ -945,9 +969,18 @@ export function SkillDetailPage() {
- + {version.status && ( {resolveVersionStatusLabel(version.status)} @@ -1077,7 +1110,7 @@ export function SkillDetailPage() {
{t('skillDetail.version')}
- {headlineVersion ? `v${headlineVersion.version}` : '—'} + {selectedVersionEntry ? `v${selectedVersionEntry.version}` : '—'}
@@ -1148,7 +1181,7 @@ export function SkillDetailPage() { )} @@ -1218,6 +1251,55 @@ export function SkillDetailPage() { )} + {selectedVersionEntry && ( + +
+
+ {t('skillDetail.complianceSectionTitle')} +
+

+ {t('skillDetail.complianceSectionDescription', { version: selectedVersionEntry.version })} +

+
+ {selectedVersionComplianceMappings.length > 0 ? ( +
+ {selectedVersionComplianceMappings.map((mapping) => ( +
+
+ {t(`search.compliance.options.${mapping.standard}`)} +
+
+ {renderComplianceMappingTitle(mapping)} +
+
+
{t('skillDetail.complianceStandardVersion', { version: mapping.standardVersion })}
+
{t('skillDetail.complianceControlId', { controlId: mapping.controlId })}
+ {mapping.evidenceUrl ? ( + + {t('skillDetail.complianceEvidenceLink')} + + ) : null} +
+
+ ))} +
+ ) : ( +
+
{t('skillDetail.complianceEmptyTitle')}
+

{t('skillDetail.complianceEmptyDescription')}

+
+ )} +
+ )} + { q: ' hello world ', namespace: '@team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'relevance', page: 2, size: 12, - })).toBe('/api/web/skills?q=hello+world&namespace=team-ai&label=code-generation&sort=relevance&page=2&size=12') + })).toBe('/api/web/skills?q=hello+world&namespace=team-ai&label=code-generation&complianceStandard=gdpr&sort=relevance&page=2&size=12') }) it('returns the base skills endpoint when no search params are provided', () => { diff --git a/web/src/shared/hooks/skill-query-helpers.ts b/web/src/shared/hooks/skill-query-helpers.ts index 24a9d7453..4ae1a05b5 100644 --- a/web/src/shared/hooks/skill-query-helpers.ts +++ b/web/src/shared/hooks/skill-query-helpers.ts @@ -19,6 +19,10 @@ export function buildSkillSearchUrl(params: SearchParams) { queryParams.append('label', params.label) } + if (params.complianceStandard) { + queryParams.append('complianceStandard', params.complianceStandard) + } + if (params.sort) { queryParams.append('sort', params.sort) }