mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat: [6ec22314-8660-4ac1-99cd-11c5598c3dea] add compliance version mapping
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
8413ee3950
commit
09cf3b5dcc
45 changed files with 1801 additions and 65 deletions
|
|
@ -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 + "\"}";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillComplianceMappingResponse(
|
||||
String standard,
|
||||
String standardVersion,
|
||||
String controlId,
|
||||
String controlTitle,
|
||||
String evidenceUrl
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("\"", "\\\"") + "\"}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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\"")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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\"")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
9
web/src/api/generated/schema.d.ts
vendored
9
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "暂无",
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue