feat: [6ec22314-8660-4ac1-99cd-11c5598c3dea] add compliance version mapping

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-26 12:16:27 +08:00
parent 8413ee3950
commit 09cf3b5dcc
45 changed files with 1801 additions and 65 deletions

View file

@ -16,7 +16,9 @@ import com.iflytek.skillhub.domain.audit.AuditLogService;
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.dto.SkillSummaryResponse;
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 class ClawHubCompatAppService {
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 class ClawHubCompatAppService {
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 @@ public class ClawHubCompatAppService {
}
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<String, Object> extras = new LinkedHashMap<>();
extras.put("namespace", namespace);
extras.put("slug", slug);
return complianceAuditDetailFactory.latestPublishedEntered(version, extras);
}
return "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + slug + "\"}";
}
}

View file

@ -125,7 +125,8 @@ public class CliSkillController extends BaseApiController {
@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<PackageEntry> entries;
try {
entries = archiveExtractor.extract(file);
@ -135,7 +136,8 @@ public class CliSkillController extends BaseApiController {
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);
}
}

View file

@ -11,6 +11,7 @@ import com.iflytek.skillhub.dto.ApiResponse;
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 class SkillController extends BaseApiController {
detail.totalSize(),
detail.publishedAt(),
detail.parsedMetadataJson(),
detail.manifestJson()
detail.manifestJson(),
detail.complianceMappings().stream()
.map(mapping -> new SkillComplianceMappingResponse(
mapping.standard().value(),
mapping.standardVersion(),
mapping.controlId(),
mapping.controlTitle(),
mapping.evidenceUrl()
))
.toList()
);
return ok("response.success.read", response);
}

View file

@ -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.bind.annotation.*;
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 class SkillPublishController extends BaseApiController {
@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 class SkillPublishController extends BaseApiController {
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<String, Object> 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)
);
}
}

View file

@ -40,6 +40,7 @@ public class SkillSearchController extends BaseApiController {
@RequestParam(required = false) String q,
@RequestParam(required = false) String namespace,
@RequestParam(name = "label", required = false) java.util.List<String> labels,
@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 +57,7 @@ public class SkillSearchController extends BaseApiController {
parseNonNegativeInt(page, DEFAULT_PAGE),
parsePositiveInt(size, DEFAULT_SIZE),
labels,
complianceStandard,
userId,
userNsRoles
);

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
public record SkillComplianceMappingResponse(
String standard,
String standardVersion,
String controlId,
String controlTitle,
String evidenceUrl
) {}

View file

@ -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<SkillComplianceMappingResponse> complianceMappings
) {}

View file

@ -11,10 +11,14 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
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 class ReviewPortalAppService {
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 @@ public class ReviewPortalAppService {
}
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<String, Object> extras = new LinkedHashMap<>();
if (comment != null && !comment.isBlank()) {
extras.put("comment", comment);
}
return complianceAuditDetailFactory.latestPublishedEntered(version, extras);
}
}

View file

@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
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.domain.skill.service.SkillSlugResolutionService;
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 class SkillLifecycleAppService {
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 class SkillLifecycleAppService {
null,
auditContext.clientIp(),
auditContext.userAgent(),
"{\"version\":\"" + version.replace("\"", "\\\"") + "\"}"
complianceAuditDetailFactory.latestPublishedEntered(skillVersion)
);
return new SkillLifecycleMutationResponse(
skill.getId(),
@ -264,4 +268,15 @@ public class SkillLifecycleAppService {
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNamespaceRoles) {
return userNamespaceRoles != null ? userNamespaceRoles : Map.of();
}
private String rereleaseAuditDetail(String sourceVersion, String targetVersion, SkillVersion publishedVersion) {
if (publishedVersion.getStatus() == SkillVersionStatus.PUBLISHED) {
LinkedHashMap<String, Object> extras = new LinkedHashMap<>();
extras.put("sourceVersion", sourceVersion);
extras.put("targetVersion", targetVersion);
return complianceAuditDetailFactory.latestPublishedEntered(publishedVersion, extras);
}
return "{\"sourceVersion\":\"" + sourceVersion.replace("\"", "\\\"")
+ "\",\"targetVersion\":\"" + targetVersion.replace("\"", "\\\"") + "\"}";
}
}

View file

@ -68,7 +68,35 @@ public class SkillSearchAppService {
int size,
String userId,
Map<Long, NamespaceRole> 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(
String keyword,
String namespaceSlug,
String sortBy,
int page,
int size,
List<String> labelSlugs,
String complianceStandard,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles);
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
return searchVisibleSkills(
keyword,
namespaceId,
sortBy != null ? sortBy : "newest",
page,
size,
labelSlugs,
normalizeComplianceStandard(complianceStandard),
scope,
false
);
}
public SearchResponse search(
@ -80,12 +108,7 @@ public class SkillSearchAppService {
List<String> labelSlugs,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles);
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false);
return search(keyword, namespaceSlug, sortBy, page, size, labelSlugs, null, userId, userNsRoles);
}
public SearchResponse searchInstallableLatest(
@ -98,7 +121,7 @@ public class SkillSearchAppService {
Map<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {
@ -146,6 +169,7 @@ public class SkillSearchAppService {
int page,
int size,
List<String> labelSlugs,
String complianceStandard,
SearchVisibilityScope scope,
boolean requireInstallableLatest) {
SearchResult result = searchQueryService.search(new SearchQuery(
@ -156,6 +180,7 @@ public class SkillSearchAppService {
page,
size,
normalizeLabelSlugs(labelSlugs),
complianceStandard,
requireInstallableLatest
));
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds());
@ -173,6 +198,13 @@ public class SkillSearchAppService {
.toList();
}
private String normalizeComplianceStandard(String complianceStandard) {
if (complianceStandard == null || complianceStandard.isBlank()) {
return null;
}
return complianceStandard.trim().toLowerCase(java.util.Locale.ROOT);
}
private List<SkillSummaryResponse> mapVisibleSkillSummaries(List<Long> skillIds) {
if (skillIds.isEmpty()) {
return List.of();

View file

@ -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.MediaType;
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 class CliSkillAppService {
);
}
public CliPublishResponse publish(String namespace, List<PackageEntry> entries, String publisherId, SkillVisibility visibility, Set<String> platformRoles) {
public CliPublishResponse publish(String namespace,
List<PackageEntry> entries,
String publisherId,
SkillVisibility visibility,
Set<String> 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 class CliSkillAppService {
);
}
private void recordPublishAuditIfLatestPublished(String publisherId,
String namespace,
SkillPublishService.PublishResult result,
AuditRequestContext auditContext) {
if (result.version().getStatus() != SkillVersionStatus.PUBLISHED) {
return;
}
LinkedHashMap<String, Object> 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<InputStreamResource> buildDownloadResponse(SkillDownloadService.DownloadResult result) {
if (result.presignedUrl() != null) {
return ResponseEntity.status(302)

View file

@ -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'));

View file

@ -2,23 +2,36 @@ package com.iflytek.skillhub.compat;
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 @@ class ClawHubCompatAppServiceTest {
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<PackageEntry> 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<PackageEntry> 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;
}
}

View file

@ -5,6 +5,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
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;
@ -67,7 +69,14 @@ class SkillControllerTest {
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 +85,9 @@ class SkillControllerTest {
.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 +109,8 @@ class SkillControllerTest {
128L,
Instant.parse("2026-03-12T12:00:00Z"),
"{\"name\":\"demo\"}",
"[{\"path\":\"SKILL.md\"}]"
"[{\"path\":\"SKILL.md\"}]",
List.of()
));
TimeZone original = TimeZone.getDefault();
@ -106,7 +119,8 @@ class SkillControllerTest {
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);

View file

@ -43,6 +43,7 @@ class SkillSearchControllerTest {
eq(0),
eq(20),
eq(null),
eq(null),
any(),
any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20));
@ -67,6 +68,7 @@ class SkillSearchControllerTest {
eq(0),
eq(12),
eq(null),
eq(null),
any(),
any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 12));
@ -89,6 +91,7 @@ class SkillSearchControllerTest {
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 +113,7 @@ class SkillSearchControllerTest {
eq(0),
eq(20),
eq(null),
eq(null),
any(),
any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20));
@ -132,6 +136,7 @@ class SkillSearchControllerTest {
eq(0),
eq(20),
eq(null),
eq(null),
any(),
any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20));
@ -143,4 +148,26 @@ class SkillSearchControllerTest {
.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("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());
}
}

View file

@ -69,7 +69,7 @@ class CliSkillControllerTest {
@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);

View file

@ -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 @@ class SkillPublishControllerTest {
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.<List<PackageEntry>>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");

View file

@ -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\"")
);
}
}

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
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 org.springframework.test.util.ReflectionTestUtils;
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 @@ class SkillLifecycleAppServiceTest {
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\"")
);
}
}

View file

@ -294,6 +294,28 @@ class SkillSearchAppServiceTest {
assertEquals(List.of("code-generation", "official"), captor.getValue().labelSlugs());
}
@Test
void search_shouldNormalizeAndPassComplianceStandard() {
when(searchQueryService.search(any()))
.thenReturn(new SearchResult(List.of(), 0, 0, 20));
service.search(
"skill",
null,
"newest",
0,
20,
List.of("official"),
" GDPR ",
"user-9",
Map.of(7L, NamespaceRole.MEMBER)
);
ArgumentCaptor<SearchQuery> captor = ArgumentCaptor.forClass(SearchQuery.class);
verify(searchQueryService).search(captor.capture());
assertEquals("gdpr", captor.getValue().complianceStandard());
}
@Test
void search_shouldIncludeMemberNamespacesInVisibilityScope() {
when(searchQueryService.search(any()))

View file

@ -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.Map;
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 @@ class CliSkillAppServiceTest {
skillQueryService,
skillDownloadService,
skillDeleteAppService,
skillPublishService
skillPublishService,
auditLogService
);
Skill installableSecondMatch = new Skill(1L, "ready-second", "owner-1", SkillVisibility.PUBLIC);
@ -247,12 +253,73 @@ class CliSkillAppServiceTest {
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<PackageEntry> 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) {

View file

@ -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<ComplianceStandard> 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();
}
}

View file

@ -0,0 +1,58 @@
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<String, Object> extras) {
return build("latest_published_entered", version, extras);
}
public String latestPublishedRemoved(SkillVersion version, Map<String, Object> extras) {
return build("latest_published_removed", version, extras);
}
public String build(String snapshotKind, SkillVersion version, Map<String, Object> extras) {
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
payload.put("snapshotKind", snapshotKind);
payload.put("versionId", version.getId());
payload.put("version", version.getVersion());
payload.put(
"compliance",
complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson())
);
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);
}
}
}

View file

@ -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
) {}

View file

@ -0,0 +1,260 @@
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.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.HashSet;
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<String> 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<String, Object> 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<String> errors = new ArrayList<>();
if (items.size() > MAX_ITEMS) {
errors.add("x-astron-compliance must contain at most " + MAX_ITEMS + " items");
}
List<SkillComplianceMapping> mappings = new ArrayList<>();
Set<String> 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<String, Object> 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<SkillComplianceMapping> readFromParsedMetadataJson(String parsedMetadataJson) {
if (parsedMetadataJson == null || parsedMetadataJson.isBlank()) {
return List.of();
}
try {
Map<String, Object> parsed = objectMapper.readValue(
parsedMetadataJson,
new TypeReference<Map<String, Object>>() {}
);
Object rawFrontmatter = parsed.get("frontmatter");
if (!(rawFrontmatter instanceof Map<?, ?> rawMap)) {
return List.of();
}
Map<String, Object> 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<String, Object> normalizeItem(Map<?, ?> rawMap, int index, List<String> errors) {
Map<String, Object> 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<String, Object> item, int index, List<String> 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<String, Object> item,
int index,
String fieldName,
int maxLength,
List<String> 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<String, Object> item, int index, List<String> 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<String, Object> item,
int index,
String fieldName,
int maxLength,
List<String> 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<String, Object> item, int index, List<String> errors) {
Object rawValue = item.get("evidenceUrl");
if (rawValue == null) {
return null;
}
if (!(rawValue instanceof String value) || value.isBlank()) {
errors.add("x-astron-compliance[" + index + "].evidenceUrl must be an absolute URI");
return null;
}
try {
URI uri = URI.create(value.trim());
if (!uri.isAbsolute()) {
errors.add("x-astron-compliance[" + index + "].evidenceUrl must be an absolute URI");
return null;
}
return uri.toString();
} catch (IllegalArgumentException ex) {
errors.add("x-astron-compliance[" + index + "].evidenceUrl must be an absolute URI");
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<SkillComplianceMapping> mappings,
List<String> 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));
}
}
}

View file

@ -15,10 +15,12 @@ import com.iflytek.skillhub.domain.skill.SkillStatus;
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,
@ -273,7 +277,20 @@ public class SkillGovernanceService {
skillRepository.save(skill);
}
});
auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason));
LinkedHashMap<String, Object> 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, auditExtras)
);
eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent(
version.getSkillId(), versionId, actorUserId));
return saved;

View file

@ -11,6 +11,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.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 class SkillQueryService {
Long totalSize,
java.time.Instant publishedAt,
String parsedMetadataJson,
String manifestJson
String manifestJson,
List<SkillComplianceMapping> complianceMappings
) {}
public record SkillVersionCompareDTO(
@ -311,7 +315,8 @@ public class SkillQueryService {
skillVersion.getTotalSize(),
skillVersion.getPublishedAt(),
skillVersion.getParsedMetadataJson(),
skillVersion.getManifestJson()
skillVersion.getManifestJson(),
complianceMetadataService.readFromParsedMetadataJson(skillVersion.getParsedMetadataJson())
);
}

View file

@ -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 class SkillPackageValidator {
long maxSingleFileSize,
long maxTotalPackageSize,
Set<String> allowedExtensions) {
this(
metadataParser,
new SkillComplianceMetadataService(),
maxFileCount,
maxSingleFileSize,
maxTotalPackageSize,
allowedExtensions
);
}
public SkillPackageValidator(SkillMetadataParser metadataParser,
SkillComplianceMetadataService complianceMetadataService,
int maxFileCount,
long maxSingleFileSize,
long maxTotalPackageSize,
Set<String> allowedExtensions) {
this.metadataParser = metadataParser;
this.complianceMetadataService = complianceMetadataService;
this.maxFileCount = maxFileCount;
this.maxSingleFileSize = maxSingleFileSize;
this.maxTotalPackageSize = maxTotalPackageSize;
@ -89,7 +110,11 @@ public class SkillPackageValidator {
// 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));
}

View file

@ -0,0 +1,117 @@
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<String, Object> 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<String, Object> 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<String, Object> 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 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<SkillComplianceMapping> mappings = service.readFromParsedMetadataJson(parsedMetadataJson);
assertThat(mappings).singleElement()
.extracting(
SkillComplianceMapping::standard,
SkillComplianceMapping::standardVersion,
SkillComplianceMapping::controlId
)
.containsExactly(ComplianceStandard.SOC2, "2017", "CC6.1");
}
}

View file

@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.doThrow;
@ -143,6 +144,19 @@ class SkillGovernanceServiceTest {
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 +166,26 @@ class SkillGovernanceServiceTest {
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

View file

@ -189,6 +189,30 @@ class SkillPackageValidatorTest {
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<PackageEntry> 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 testPackageTooLarge() {
// Use a custom validator with 2KB total limit to test the logic

View file

@ -13,8 +13,21 @@ public record SearchQuery(
int page,
int size,
List<String> labelSlugs,
String complianceStandard,
boolean requireInstallableLatest
) {
public SearchQuery(
String keyword,
Long namespaceId,
SearchVisibilityScope visibilityScope,
String sortBy,
int page,
int size,
List<String> labelSlugs,
boolean requireInstallableLatest) {
this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, null, requireInstallableLatest);
}
public SearchQuery(
String keyword,
Long namespaceId,
@ -23,7 +36,7 @@ public record SearchQuery(
int page,
int size,
List<String> labelSlugs) {
this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, false);
this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, null, false);
}
public SearchQuery(
@ -33,6 +46,6 @@ public record 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);
}
}

View file

@ -80,8 +80,10 @@ public class PostgresFullTextQueryService implements SearchQueryService {
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 +109,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("FROM skill_search_document d ");
sql.append("JOIN skill s ON s.id = d.skill_id ");
sql.append("JOIN namespace n ON n.id = d.namespace_id ");
if (query.requireInstallableLatest()) {
if (query.requireInstallableLatest() || hasComplianceStandard) {
sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id ");
}
sql.append("WHERE 1=1 ");
@ -128,6 +130,9 @@ public class PostgresFullTextQueryService implements SearchQueryService {
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 +208,9 @@ public class PostgresFullTextQueryService implements SearchQueryService {
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 +255,9 @@ public class PostgresFullTextQueryService implements SearchQueryService {
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 +275,13 @@ public class PostgresFullTextQueryService implements SearchQueryService {
return new SearchResult(skillIds, total, query.page(), query.size());
}
private String normalizeComplianceStandard(String complianceStandard) {
if (complianceStandard == null || complianceStandard.isBlank()) {
return null;
}
return complianceStandard.trim().toLowerCase(Locale.ROOT);
}
private List<Long> rerankBySemanticSimilarity(List<Long> candidateSkillIds,
String normalizedKeyword,
int requestedOffset,

View file

@ -295,6 +295,42 @@ class PostgresFullTextQueryServiceTest {
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"),
"gdpr",
false
));
ArgumentCaptor<String> 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);

View file

@ -4161,6 +4161,14 @@ export interface components {
publishedAt?: string;
parsedMetadataJson?: string;
manifestJson?: string;
complianceMappings?: components["schemas"]["SkillComplianceMappingResponse"][];
};
SkillComplianceMappingResponse: {
standard?: string;
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?: string;
sort?: string;
page?: number;
size?: number;

View file

@ -255,6 +255,14 @@ export interface SkillVersion {
downloadAvailable: boolean
}
export interface SkillComplianceMapping {
standard: string
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 {
@ -328,6 +337,7 @@ export interface SearchParams {
q?: string
namespace?: string
label?: string
complianceStandard?: string
sort?: string
page?: number
size?: number

View file

@ -199,11 +199,12 @@ const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'search',
component: SearchPage,
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => {
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; complianceStandard?: string; sort: string; page: number; starredOnly: boolean } => {
return {
q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''),
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined,
label: typeof search.label === 'string' && search.label ? search.label : undefined,
complianceStandard: typeof search.complianceStandard === 'string' && search.complianceStandard ? search.complianceStandard : undefined,
sort: (search.sort as string) || 'newest',
page: Number(search.page) || 0,
starredOnly: search.starredOnly === true || search.starredOnly === 'true',
@ -227,8 +228,9 @@ const namespaceRoute = createRoute({
const skillDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/space/$namespace/$slug',
validateSearch: (search: Record<string, unknown>): { returnTo?: string } => ({
validateSearch: (search: Record<string, unknown>): { 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,
})

View file

@ -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",

View file

@ -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": "暂无",

View file

@ -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(<SearchPage />)
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: '',

View file

@ -17,6 +17,7 @@ import { Button } from '@/shared/ui/button'
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
const PAGE_SIZE = 12
const COMPLIANCE_STANDARD_OPTIONS = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const
function blurActiveElement() {
if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') {
@ -93,12 +94,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: string
sort: string
page: number
starredOnly: boolean
}> = {}) => ({
q,
namespace,
label: selectedLabel,
complianceStandard,
sort,
page,
starredOnly,
...overrides,
})
useEffect(() => {
setQueryInput(formatNamespaceSearchInput(namespace, q))
}, [namespace, q])
@ -121,6 +142,7 @@ export function SearchPage() {
q,
namespace: namespace || undefined,
label: selectedLabel || undefined,
complianceStandard: complianceStandard || undefined,
sort,
page,
size: PAGE_SIZE,
@ -142,44 +164,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: string) => {
const nextStandard = complianceStandard === standard ? '' : 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 +248,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 +356,21 @@ export function SearchPage() {
</Button>
) : null}
</div>
{!starredOnly && (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium text-muted-foreground">{t('search.compliance.label')}</span>
{COMPLIANCE_STANDARD_OPTIONS.map((standard) => (
<Button
key={standard}
variant={complianceStandard === standard ? 'default' : 'outline'}
size="sm"
onClick={() => handleComplianceToggle(standard)}
>
{t(`search.compliance.options.${standard}`)}
</Button>
))}
</div>
)}
</div>
{/* Results */}

View file

@ -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 () => {
@ -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,65 @@ 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(<SkillDetailPage />)
expect(useSkillVersionDetailMock).toHaveBeenCalledWith('global', 'demo-skill', '0.9.0', true)
expect(html).toContain('skillDetail.complianceSectionTitle')
expect(html).toContain('Right to erasure')
expect(html).toContain('Article-17')
})
it('shows the label management panel for a user who can manage the skill lifecycle', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({

View file

@ -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,7 @@ 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 ?? []
useEffect(() => {
// Recompute collapse rules whenever rendered documentation height changes so the page can keep
@ -261,6 +274,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 +966,18 @@ export function SkillDetailPage() {
<div key={version.id} className="py-5 first:pt-0 last:pb-0">
<div className="flex items-start justify-between gap-4 mb-2">
<span className="font-semibold font-heading text-foreground flex items-center gap-2 flex-wrap min-w-0">
<span className="px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-sm font-mono">
<button
type="button"
className={cn(
'rounded-full px-2.5 py-0.5 text-sm font-mono transition-colors',
selectedVersionEntry?.version === version.version
? 'bg-primary text-primary-foreground'
: 'bg-primary/10 text-primary hover:bg-primary/20',
)}
onClick={() => handleVersionSelect(version.version)}
>
v{version.version}
</span>
</button>
{version.status && (
<span className="rounded-full border border-border/60 bg-secondary/40 px-2.5 py-0.5 text-xs text-muted-foreground">
{resolveVersionStatusLabel(version.status)}
@ -1077,7 +1107,7 @@ export function SkillDetailPage() {
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">{t('skillDetail.version')}</div>
<div className="max-w-[11rem] break-all text-right font-mono font-semibold leading-snug text-foreground">
{headlineVersion ? `v${headlineVersion.version}` : '—'}
{selectedVersionEntry ? `v${selectedVersionEntry.version}` : '—'}
</div>
</div>
@ -1218,6 +1248,55 @@ export function SkillDetailPage() {
<SecurityAuditSummary skillId={skill.id} versionId={selectedVersionEntry.id} versionStatus={selectedVersionEntry.status} />
)}
{selectedVersionEntry && (
<Card className="p-5 space-y-4">
<div className="space-y-1">
<div className="text-sm font-semibold font-heading text-foreground">
{t('skillDetail.complianceSectionTitle')}
</div>
<p className="text-sm text-muted-foreground">
{t('skillDetail.complianceSectionDescription', { version: selectedVersionEntry.version })}
</p>
</div>
{selectedVersionComplianceMappings.length > 0 ? (
<div className="space-y-3">
{selectedVersionComplianceMappings.map((mapping) => (
<div
key={`${mapping.standard}:${mapping.standardVersion}:${mapping.controlId}`}
className="rounded-xl border border-border/60 bg-secondary/20 p-3"
>
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t(`search.compliance.options.${mapping.standard}`)}
</div>
<div className="mt-2 text-sm font-semibold text-foreground">
{renderComplianceMappingTitle(mapping)}
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>{t('skillDetail.complianceStandardVersion', { version: mapping.standardVersion })}</div>
<div>{t('skillDetail.complianceControlId', { controlId: mapping.controlId })}</div>
{mapping.evidenceUrl ? (
<a
className="inline-flex text-primary underline-offset-4 hover:underline"
href={mapping.evidenceUrl}
rel="noreferrer"
target="_blank"
>
{t('skillDetail.complianceEvidenceLink')}
</a>
) : null}
</div>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/70 bg-secondary/10 p-4 text-sm text-muted-foreground">
<div className="font-medium text-foreground">{t('skillDetail.complianceEmptyTitle')}</div>
<p className="mt-1">{t('skillDetail.complianceEmptyDescription')}</p>
</div>
)}
</Card>
)}
<SkillLabelPanel
namespace={namespace}
slug={slug}

View file

@ -7,10 +7,11 @@ describe('buildSkillSearchUrl', () => {
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', () => {

View file

@ -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)
}