feat(security): retry failed scans

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-03 17:55:08 +08:00
parent e02c22e678
commit 680a5d1b94
16 changed files with 513 additions and 8 deletions

View file

@ -19,9 +19,14 @@ import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SecurityAuditResponse;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SecurityScanRetryAppService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -41,19 +46,40 @@ public class SecurityAuditController extends BaseApiController {
private final SkillVersionRepository skillVersionRepository;
private final VisibilityChecker visibilityChecker;
private final ObjectMapper objectMapper;
private final SecurityScanRetryAppService securityScanRetryAppService;
public SecurityAuditController(SecurityAuditRepository securityAuditRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
VisibilityChecker visibilityChecker,
ApiResponseFactory responseFactory,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
SecurityScanRetryAppService securityScanRetryAppService) {
super(responseFactory);
this.securityAuditRepository = securityAuditRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.visibilityChecker = visibilityChecker;
this.objectMapper = objectMapper;
this.securityScanRetryAppService = securityScanRetryAppService;
}
@PostMapping("/retry")
public ApiResponse<SkillLifecycleMutationResponse> retrySecurityScan(
@PathVariable Long skillId,
@PathVariable Long versionId,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest request) {
SkillLifecycleMutationResponse result = securityScanRetryAppService.retry(
skillId,
versionId,
principal.userId(),
principal.platformRoles(),
userNsRoles,
AuditRequestContext.from(request)
);
return ok("security_audit.retry.started", result);
}
@GetMapping

View file

@ -0,0 +1,123 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.security.ScanTask;
import com.iflytek.skillhub.domain.security.ScannerType;
import com.iflytek.skillhub.domain.security.SecurityAuditRepository;
import com.iflytek.skillhub.domain.security.SecurityScanService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SecurityScanRetryAppService {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SecurityAuditRepository securityAuditRepository;
private final SecurityScanService securityScanService;
private final ObjectStorageService objectStorageService;
private final AuditLogService auditLogService;
public SecurityScanRetryAppService(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SecurityAuditRepository securityAuditRepository,
SecurityScanService securityScanService,
ObjectStorageService objectStorageService,
AuditLogService auditLogService) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.securityAuditRepository = securityAuditRepository;
this.securityScanService = securityScanService;
this.objectStorageService = objectStorageService;
this.auditLogService = auditLogService;
}
@Transactional
public SkillLifecycleMutationResponse retry(Long skillId,
Long versionId,
String userId,
Set<String> platformRoles,
Map<Long, NamespaceRole> namespaceRoles,
AuditRequestContext auditContext) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
authorize(skill, userId, platformRoles, namespaceRoles);
SkillVersion version = skillVersionRepository.findBySkillIdForUpdate(skillId).stream()
.filter(candidate -> candidate.getId().equals(versionId))
.findFirst()
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId));
if (version.getStatus() == SkillVersionStatus.SCANNING && hasActiveAttempt(versionId)) {
return response(skillId, versionId);
}
if (version.getStatus() != SkillVersionStatus.SCAN_FAILED) {
throw new DomainBadRequestException("error.security.scan.retry.status", version.getStatus());
}
if (!securityScanService.isEnabled()) {
throw new DomainBadRequestException("error.security.scan.retry.disabled");
}
String bundleKey = bundleKey(skillId, versionId);
if (!objectStorageService.exists(bundleKey)) {
throw new DomainBadRequestException("error.security.scan.retry.bundleMissing");
}
ScanTask task = securityScanService.retryStoredBundleScan(version, bundleKey, userId);
auditLogService.record(
userId,
"RETRY_SECURITY_SCAN",
"SKILL_VERSION",
versionId,
null,
auditContext.clientIp(),
auditContext.userAgent(),
AuditDetail.of("taskId", task.taskId(), "version", version.getVersion())
);
return response(skillId, versionId);
}
private void authorize(Skill skill,
String userId,
Set<String> platformRoles,
Map<Long, NamespaceRole> namespaceRoles) {
Set<String> roles = platformRoles != null ? platformRoles : Set.of();
Map<Long, NamespaceRole> memberships = namespaceRoles != null ? namespaceRoles : Map.of();
NamespaceRole namespaceRole = memberships.get(skill.getNamespaceId());
boolean allowed = skill.getOwnerId().equals(userId)
|| namespaceRole == NamespaceRole.OWNER
|| namespaceRole == NamespaceRole.ADMIN
|| roles.contains("SUPER_ADMIN")
|| roles.contains("SKILL_ADMIN");
if (!allowed) {
throw new DomainForbiddenException("error.forbidden");
}
}
private boolean hasActiveAttempt(Long versionId) {
return securityAuditRepository
.findLatestActiveByVersionIdAndScannerType(versionId, ScannerType.SKILL_SCANNER)
.filter(audit -> audit.getScannedAt() == null)
.isPresent();
}
private String bundleKey(Long skillId, Long versionId) {
return String.format("packages/%d/%d/bundle.zip", skillId, versionId);
}
private SkillLifecycleMutationResponse response(Long skillId, Long versionId) {
return new SkillLifecycleMutationResponse(skillId, versionId, "RETRY_SECURITY_SCAN", "SCANNING");
}
}

View file

@ -102,6 +102,10 @@ error.skill.publish.skillMd.notFound=SKILL.md not found
error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0}
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
error.security.scanner.required=Security scanner must be enabled before publishing public or namespace-visible skills
error.security.scan.retry.status=Only a failed security scan can be retried (current status: {0})
error.security.scan.retry.disabled=Security scanning is disabled; enable it before retrying
error.security.scan.retry.bundleMissing=The stored package is unavailable; upload the skill again to retry scanning
security_audit.retry.started=Security scan retry started
error.skill.publish.archived=Archived skill must be restored before publishing: {0}
review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0}
review.withdraw.not_submitter=Only the submitter can withdraw this review

View file

@ -99,6 +99,10 @@ error.skill.publish.skillMd.notFound=SKILL.md не найден
error.skill.publish.precheck.confirmRequired=Предупреждения перед публикацией требуют подтверждения:\n{0}
error.skill.publish.precheck.failed=Проверка перед публикацией не пройдена: {0}
error.security.scanner.required=Перед публикацией публичных или видимых в пространстве имён скиллов необходимо включить сканер безопасности
error.security.scan.retry.status=Повторить можно только неудачное сканирование безопасности (текущий статус: {0})
error.security.scan.retry.disabled=Сканер безопасности отключён; включите его перед повторной попыткой
error.security.scan.retry.bundleMissing=Сохранённый пакет недоступен; загрузите скилл заново для повторного сканирования
security_audit.retry.started=Повторное сканирование безопасности запущено
error.skill.publish.archived=Архивный скилл нужно восстановить перед публикацией: {0}
review.withdraw.not_pending=Отозвать можно только заявки на ревью со статусом pending: {0}
review.withdraw.not_submitter=Отозвать это ревью может только отправитель

View file

@ -102,6 +102,10 @@ error.skill.publish.skillMd.notFound=未找到 SKILL.md
error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0}
error.skill.publish.precheck.failed=预发布校验失败:{0}
error.security.scanner.required=发布公开或命名空间可见技能前必须启用安全扫描器
error.security.scan.retry.status=只有安全扫描失败的版本才能重试(当前状态:{0}
error.security.scan.retry.disabled=安全扫描器未启用,请启用后再重试
error.security.scan.retry.bundleMissing=原技能包已不存在,请重新上传技能后再扫描
security_audit.retry.started=已重新发起安全扫描
error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0}
review.withdraw.not_pending=只有待审核版本才能撤销审核:{0}
review.withdraw.not_submitter=只有提交人本人可以撤销此次审核

View file

@ -15,6 +15,8 @@ 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.SkillVisibility;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.service.SecurityScanRetryAppService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -34,7 +36,9 @@ import java.util.Set;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -61,6 +65,37 @@ class SecurityAuditControllerTest {
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private SecurityScanRetryAppService securityScanRetryAppService;
@Test
void retrySecurityScan_returnsScanningState() throws Exception {
given(securityScanRetryAppService.retry(
org.mockito.ArgumentMatchers.eq(8L),
org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq("owner-1"),
org.mockito.ArgumentMatchers.eq(Set.of()),
org.mockito.ArgumentMatchers.anyMap(),
org.mockito.ArgumentMatchers.any()))
.willReturn(new SkillLifecycleMutationResponse(8L, 42L, "RETRY_SECURITY_SCAN", "SCANNING"));
mockMvc.perform(post("/api/v1/skills/8/versions/42/security-audit/retry")
.with(auth("owner-1"))
.with(csrf())
.requestAttr("userNsRoles", Map.of()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.action").value("RETRY_SECURITY_SCAN"))
.andExpect(jsonPath("$.data.status").value("SCANNING"));
}
@Test
void retrySecurityScan_requiresAuthentication() throws Exception {
mockMvc.perform(post("/api/v1/skills/8/versions/42/security-audit/retry").with(csrf()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void getSecurityAudit_returnsAuditPayload() throws Exception {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);

View file

@ -0,0 +1,172 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.security.ScanTask;
import com.iflytek.skillhub.domain.security.ScannerType;
import com.iflytek.skillhub.domain.security.SecurityAudit;
import com.iflytek.skillhub.domain.security.SecurityAuditRepository;
import com.iflytek.skillhub.domain.security.SecurityScanService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class SecurityScanRetryAppServiceTest {
@Mock private SkillRepository skillRepository;
@Mock private SkillVersionRepository skillVersionRepository;
@Mock private SecurityAuditRepository securityAuditRepository;
@Mock private SecurityScanService securityScanService;
@Mock private ObjectStorageService objectStorageService;
@Mock private AuditLogService auditLogService;
private SecurityScanRetryAppService service;
private Skill skill;
private SkillVersion version;
@BeforeEach
void setUp() {
service = new SecurityScanRetryAppService(
skillRepository,
skillVersionRepository,
securityAuditRepository,
securityScanService,
objectStorageService,
auditLogService
);
skill = skill(8L, "owner-1");
version = version(42L, SkillVersionStatus.SCAN_FAILED);
given(skillRepository.findById(8L)).willReturn(Optional.of(skill));
}
@Test
void retry_asOwnerCreatesNewAttemptAndAuditLog() {
given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version));
given(securityScanService.isEnabled()).willReturn(true);
given(objectStorageService.exists("packages/8/42/bundle.zip")).willReturn(true);
given(securityScanService.retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1"))
.willReturn(new ScanTask("task-new", 42L, null, "packages/8/42/bundle.zip",
"owner-1", 1L, Map.of()));
var result = service.retry(
8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext("127.0.0.1", "test"));
assertThat(result.status()).isEqualTo("SCANNING");
verify(securityScanService).retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1");
verify(auditLogService).record(
"owner-1", "RETRY_SECURITY_SCAN", "SKILL_VERSION", 42L,
null, "127.0.0.1", "test", "{\"taskId\":\"task-new\",\"version\":\"1.0.0\"}");
}
@Test
void retry_allowsNamespaceAdminAndPlatformSecurityAdmin() {
given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version));
given(securityScanService.isEnabled()).willReturn(true);
given(objectStorageService.exists("packages/8/42/bundle.zip")).willReturn(true);
given(securityScanService.retryStoredBundleScan(any(), any(), any()))
.willReturn(new ScanTask("task-new", 42L, null, "bundle", "admin", 1L, Map.of()));
service.retry(8L, 42L, "namespace-admin", Set.of(), Map.of(5L, NamespaceRole.ADMIN),
new AuditRequestContext(null, null));
version.setStatus(SkillVersionStatus.SCAN_FAILED);
service.retry(8L, 42L, "security-admin", Set.of("SKILL_ADMIN"), Map.of(),
new AuditRequestContext(null, null));
verify(securityScanService, org.mockito.Mockito.times(2)).retryStoredBundleScan(any(), any(), any());
}
@Test
void retry_rejectsUnauthorizedUserBeforeReadingVersionState() {
assertThatThrownBy(() -> service.retry(
8L, 42L, "viewer", Set.of(), Map.of(), new AuditRequestContext(null, null)))
.isInstanceOf(DomainForbiddenException.class);
verify(skillVersionRepository, never()).findBySkillIdForUpdate(any());
}
@Test
void retry_rejectsNonFailedVersion() {
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version));
assertThatThrownBy(() -> service.retry(
8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null)))
.isInstanceOf(DomainBadRequestException.class);
verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any());
}
@Test
void retry_rejectsMissingStoredBundle() {
given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version));
given(securityScanService.isEnabled()).willReturn(true);
assertThatThrownBy(() -> service.retry(
8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null)))
.isInstanceOf(DomainBadRequestException.class);
verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any());
}
@Test
void retry_whenAttemptAlreadyStartedReturnsCurrentStateWithoutDuplicateTask() {
version.setStatus(SkillVersionStatus.SCANNING);
given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version));
given(securityAuditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER))
.willReturn(Optional.of(new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-existing")));
var result = service.retry(
8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null));
assertThat(result.status()).isEqualTo("SCANNING");
verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any());
verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
}
private Skill skill(Long id, String ownerId) {
Skill value = new Skill(5L, "demo", ownerId, SkillVisibility.PRIVATE);
setField(value, "id", id);
return value;
}
private SkillVersion version(Long id, SkillVersionStatus status) {
SkillVersion value = new SkillVersion(8L, "1.0.0", "owner-1");
setField(value, "id", id);
value.setStatus(status);
return value;
}
private void setField(Object target, String name, Object value) {
try {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
}
}

View file

@ -69,6 +69,27 @@ public class SecurityScanService {
return enabled;
}
@Transactional
public ScanTask retryStoredBundleScan(SkillVersion version, String bundleKey, String publisherId) {
if (!enabled) {
throw new IllegalStateException("Security scanner is disabled");
}
if (version.getStatus() != SkillVersionStatus.SCAN_FAILED) {
throw new IllegalStateException("Only SCAN_FAILED versions can be retried");
}
ScanTask scanTask = new ScanTask(
UUID.randomUUID().toString(),
version.getId(),
null,
bundleKey,
publisherId,
System.currentTimeMillis(),
Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue())
);
persistScanAttempt(version, scanTask);
return scanTask;
}
@Transactional
public void triggerScan(Long versionId, List<PackageEntry> entries, String publisherId) {
if (!enabled) {
@ -87,7 +108,6 @@ public class SecurityScanService {
} else {
packagePath = saveTempDirectory(versionId, entries).toString();
}
// Always create a new audit record supports multiple rounds per version
final ScanTask scanTask = new ScanTask(
UUID.randomUUID().toString(),
versionId,
@ -97,7 +117,12 @@ public class SecurityScanService {
System.currentTimeMillis(),
Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue())
);
auditRepository.save(new SecurityAudit(versionId, ScannerType.SKILL_SCANNER, scanTask.taskId()));
persistScanAttempt(version, scanTask);
}
private void persistScanAttempt(SkillVersion version, ScanTask scanTask) {
// A new record preserves prior scan history while identifying this attempt independently.
auditRepository.save(new SecurityAudit(version.getId(), ScannerType.SKILL_SCANNER, scanTask.taskId()));
if (scanTaskOutboxRepository != null) {
scanTaskOutboxRepository.save(new ScanTaskOutbox(scanTask));
} else {

View file

@ -136,6 +136,50 @@ class SecurityScanServiceTest {
assertThat(task.bundleKey()).isEqualTo("packages/8/42/bundle.zip");
}
@Test
void retryStoredBundleScan_createsFreshAuditAndDurableOutbox() throws Exception {
ScanTaskOutboxRepository outboxRepository = org.mockito.Mockito.mock(ScanTaskOutboxRepository.class);
service = new SecurityScanService(
auditRepository,
skillVersionRepository,
scanTaskProducer,
new ObjectMapper(),
"local",
true,
outboxRepository
);
SkillVersion version = new SkillVersion(8L, "1.0.0", "owner-1");
setId(version, 42L);
version.setStatus(SkillVersionStatus.SCAN_FAILED);
ScanTask task = service.retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1");
ArgumentCaptor<SecurityAudit> auditCaptor = ArgumentCaptor.forClass(SecurityAudit.class);
ArgumentCaptor<ScanTaskOutbox> outboxCaptor = ArgumentCaptor.forClass(ScanTaskOutbox.class);
verify(auditRepository).save(auditCaptor.capture());
verify(outboxRepository).save(outboxCaptor.capture());
verify(scanTaskProducer, never()).publishScanTask(any());
verify(skillVersionRepository).save(version);
assertThat(auditCaptor.getValue().getTaskId()).isEqualTo(task.taskId());
assertThat(outboxCaptor.getValue().toScanTask()).isEqualTo(task);
assertThat(task.bundleKey()).isEqualTo("packages/8/42/bundle.zip");
assertThat(task.metadata()).containsEntry("scannerType", "skill-scanner");
assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING);
}
@Test
void retryStoredBundleScan_rejectsNonFailedVersion() throws Exception {
SkillVersion version = new SkillVersion(8L, "1.0.0", "owner-1");
setId(version, 42L);
version.setStatus(SkillVersionStatus.SCANNING);
assertThatThrownBy(() -> service.retryStoredBundleScan(version, "bundle.zip", "owner-1"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("SCAN_FAILED");
verify(auditRepository, never()).save(any());
}
@Test
void triggerScan_defersTaskPublishingUntilTransactionCommit() throws Exception {
SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1");

View file

@ -34,9 +34,11 @@ function createAudit(overrides: Partial<SecurityAuditRecord> = {}): SecurityAudi
}
let mockAudits: SecurityAuditRecord[] | undefined = undefined
const retryMutation = { mutate: vi.fn(), isPending: false }
vi.mock('./use-security-audit', () => ({
useSecurityAudits: () => ({ data: mockAudits }),
useRetrySecurityScan: () => retryMutation,
}))
// Mock the Dialog components to avoid Radix UI portal / context issues in static render
@ -116,6 +118,20 @@ describe('SecurityAuditSummary', () => {
expect(html).not.toContain('securityAudit.statusScanning')
})
it('renders retry only for an authorized failed version', () => {
mockAudits = [createAudit({ scannedAt: null })]
const failedHtml = renderToStaticMarkup(
<SecurityAuditSummary skillId={1} versionId={10} versionStatus="SCAN_FAILED" canRetry />
)
const unauthorizedHtml = renderToStaticMarkup(
<SecurityAuditSummary skillId={1} versionId={10} versionStatus="SCAN_FAILED" />
)
expect(failedHtml).toContain('securityAudit.retry')
expect(unauthorizedHtml).not.toContain('securityAudit.retry')
})
it('renders the total findings count across all audits', () => {
mockAudits = [
createAudit({ id: 1, findingsCount: 3 }),

View file

@ -4,7 +4,8 @@ import { Shield } from 'lucide-react'
import { Card } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog'
import { useSecurityAudits } from './use-security-audit'
import { toast } from '@/shared/lib/toast'
import { useRetrySecurityScan, useSecurityAudits } from './use-security-audit'
import { getSecurityAuditDisplayState } from './display-state'
import { VerdictBadge } from './verdict-badge'
import { SecurityAuditSection } from './security-audit-section'
@ -13,12 +14,14 @@ interface SecurityAuditSummaryProps {
skillId: number
versionId: number
versionStatus?: string
canRetry?: boolean
}
export function SecurityAuditSummary({ skillId, versionId, versionStatus }: SecurityAuditSummaryProps) {
export function SecurityAuditSummary({ skillId, versionId, versionStatus, canRetry = false }: SecurityAuditSummaryProps) {
const { t } = useTranslation()
const { data: audits } = useSecurityAudits(skillId, versionId)
const [dialogOpen, setDialogOpen] = useState(false)
const retryMutation = useRetrySecurityScan(skillId, versionId)
if (!audits || audits.length === 0) {
return null
@ -49,6 +52,22 @@ export function SecurityAuditSummary({ skillId, versionId, versionStatus }: Secu
<p className="text-xs text-muted-foreground">
{t('securityAudit.totalFindings', { count: totalFindings })}
</p>
{canRetry && versionStatus === 'SCAN_FAILED' && (
<Button
size="sm"
className="w-full"
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(undefined, {
onSuccess: () => toast.success(t('securityAudit.retrySuccess')),
onError: (error) => toast.error(
t('securityAudit.retryError'),
error instanceof Error ? error.message : undefined
),
})}
>
{retryMutation.isPending ? t('securityAudit.retrying') : t('securityAudit.retry')}
</Button>
)}
<Button variant="outline" size="sm" className="w-full" onClick={() => setDialogOpen(true)}>
{t('securityAudit.viewDetails')}
</Button>

View file

@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, fetchJson } from '@/api/client'
import type { SecurityAuditRecord } from './types'
@ -31,3 +31,16 @@ export function useSecurityAudits(
retry: false,
})
}
export function useRetrySecurityScan(skillId: number, versionId: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => fetchJson(`/api/v1/skills/${skillId}/versions/${versionId}/security-audit/retry`, {
method: 'POST',
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['security-audits', skillId, versionId] })
void queryClient.invalidateQueries({ queryKey: ['skills'] })
},
})
}

View file

@ -1589,6 +1589,10 @@
"statusScanning": "Scanning",
"statusScanFailed": "Scan Failed",
"failureReason": "Reason: {{reason}}",
"retry": "Retry scan",
"retrying": "Retrying...",
"retrySuccess": "Security scan restarted",
"retryError": "Could not retry security scan",
"remediation": "Remediation",
"viewDetails": "View Details",
"verdict": {

View file

@ -1620,6 +1620,10 @@
"statusScanning": "Сканирование",
"statusScanFailed": "Сканирование не удалось",
"failureReason": "Причина: {{reason}}",
"retry": "Повторить сканирование",
"retrying": "Повторное сканирование...",
"retrySuccess": "Сканирование запущено повторно",
"retryError": "Не удалось повторить сканирование",
"remediation": "Рекомендации",
"viewDetails": "Подробности",
"verdict": {

View file

@ -1588,6 +1588,10 @@
"statusScanning": "扫描中",
"statusScanFailed": "扫描失败",
"failureReason": "失败原因:{{reason}}",
"retry": "重新扫描",
"retrying": "正在重新扫描...",
"retrySuccess": "已重新发起安全扫描",
"retryError": "重新扫描失败",
"remediation": "修复建议",
"viewDetails": "查看详情",
"verdict": {

View file

@ -199,6 +199,9 @@ export function SkillDetailPage() {
const canReport = skill?.canReport ?? true
const canHardDeleteSkill = Boolean(skill && user && (skill.ownerId === user.userId || hasRole('SUPER_ADMIN')))
const canManageLabels = Boolean(skill && user && (skill.canManageLifecycle || hasRole('SUPER_ADMIN')))
const canManageSecurityScan = Boolean(skill && user && (
skill.canManageLifecycle || hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
))
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false)
useEffect(() => {
@ -1256,8 +1259,13 @@ export function SkillDetailPage() {
description={skill.summary}
/>
{skill.canManageLifecycle && selectedVersionEntry && (
<SecurityAuditSummary skillId={skill.id} versionId={selectedVersionEntry.id} versionStatus={selectedVersionEntry.status} />
{canManageSecurityScan && selectedVersionEntry && (
<SecurityAuditSummary
skillId={skill.id}
versionId={selectedVersionEntry.id}
versionStatus={selectedVersionEntry.status}
canRetry
/>
)}
<SkillLabelPanel