Merge pull request #811 from iflytek/codex/feat/issue-690-manual-scan-retry

feat(security): retry failed scans
This commit is contained in:
XiaoSeS 2026-09-03 20:10:57 +08:00 • committed by GitHub
commit 61aa957ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 873 additions and 25 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,131 @@
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);
SkillVersionStatus observedStatus = skillVersionRepository.findStatusByIdAndSkillId(versionId, skillId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId));
if (observedStatus != SkillVersionStatus.SCAN_FAILED
&& observedStatus != SkillVersionStatus.SCANNING) {
throw new DomainBadRequestException("error.security.scan.retry.status", observedStatus);
}
if (!securityScanService.isEnabled()) {
throw new DomainBadRequestException("error.security.scan.retry.disabled");
}
String bundleKey = bundleKey(skillId, versionId);
if (observedStatus == SkillVersionStatus.SCAN_FAILED
&& !objectStorageService.exists(bundleKey)) {
throw new DomainBadRequestException("error.security.scan.retry.bundleMissing");
}
SkillVersion version = skillVersionRepository.findByIdForUpdate(versionId)
.filter(candidate -> candidate.getSkillId().equals(skillId))
.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());
}
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

@ -232,7 +232,8 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
SecurityScanRequest request = new SecurityScanRequest(
payload.taskId(), payload.versionId(), skillPath, Map.of());
SecurityScanResponse response = securityScanner.scan(request);
securityScanService.processScanResult(payload.versionId(), payload.scannerType(), response);
securityScanService.processScanResult(
payload.taskId(), payload.versionId(), payload.scannerType(), response);
}
private static final class ConcurrentScanInProgressException extends RuntimeException {

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,183 @@
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.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.findStatusByIdAndSkillId(42L, 8L))
.willReturn(Optional.of(SkillVersionStatus.SCAN_FAILED));
given(skillVersionRepository.findByIdForUpdate(42L)).willReturn(Optional.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.findStatusByIdAndSkillId(42L, 8L))
.willReturn(Optional.of(SkillVersionStatus.SCAN_FAILED));
given(skillVersionRepository.findByIdForUpdate(42L)).willReturn(Optional.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()).findByIdForUpdate(any());
verify(skillVersionRepository, never()).findStatusByIdAndSkillId(any(), any());
}
@Test
void retry_rejectsNonFailedVersion() {
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
given(skillVersionRepository.findStatusByIdAndSkillId(42L, 8L))
.willReturn(Optional.of(SkillVersionStatus.PENDING_REVIEW));
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());
verify(skillVersionRepository, never()).findByIdForUpdate(any());
}
@Test
void retry_rejectsMissingStoredBundle() {
given(skillVersionRepository.findStatusByIdAndSkillId(42L, 8L))
.willReturn(Optional.of(SkillVersionStatus.SCAN_FAILED));
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());
verify(skillVersionRepository, never()).findByIdForUpdate(any());
}
@Test
void retry_whenAttemptAlreadyStartedReturnsCurrentStateWithoutDuplicateTask() {
version.setStatus(SkillVersionStatus.SCANNING);
given(skillVersionRepository.findStatusByIdAndSkillId(42L, 8L))
.willReturn(Optional.of(SkillVersionStatus.SCANNING));
given(skillVersionRepository.findByIdForUpdate(42L)).willReturn(Optional.of(version));
given(securityScanService.isEnabled()).willReturn(true);
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

@ -0,0 +1,125 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
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.user.UserAccount;
import com.iflytek.skillhub.infra.jpa.SkillVersionJpaRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Testcontainers
class SecurityScanRetryLockingTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
}
@Autowired
private SkillVersionJpaRepository skillVersionRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@PersistenceContext
private EntityManager entityManager;
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void lockReadSeesStateCommittedWhileWaitingInsteadOfCachedPreflightEntity() throws Exception {
Fixture fixture = persistFailedVersion();
Long versionId = fixture.versionId();
CountDownLatch firstLocked = new CountDownLatch(1);
CountDownLatch secondAboutToLock = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var first = executor.submit(() -> transactions.executeWithoutResult(status -> {
SkillVersion version = skillVersionRepository.findByIdForUpdate(versionId).orElseThrow();
version.setStatus(SkillVersionStatus.SCANNING);
firstLocked.countDown();
await(releaseFirst);
}));
assertThat(firstLocked.await(10, TimeUnit.SECONDS)).isTrue();
var second = executor.submit(() -> transactions.execute(status -> {
assertThat(skillVersionRepository.findStatusByIdAndSkillId(versionId, fixture.skillId()))
.contains(SkillVersionStatus.SCAN_FAILED);
secondAboutToLock.countDown();
return skillVersionRepository.findByIdForUpdate(versionId).orElseThrow().getStatus();
}));
assertThat(secondAboutToLock.await(10, TimeUnit.SECONDS)).isTrue();
releaseFirst.countDown();
first.get();
assertThat(second.get()).isEqualTo(SkillVersionStatus.SCANNING);
}
}
private Fixture persistFailedVersion() {
TransactionTemplate transaction = new TransactionTemplate(transactionManager);
return transaction.execute(status -> {
UserAccount user = new UserAccount("retry-lock-user", "Retry Lock User", null, null);
entityManager.persist(user);
Namespace namespace = new Namespace("retry-lock", "Retry Lock", user.getId());
entityManager.persist(namespace);
entityManager.flush();
Skill skill = new Skill(namespace.getId(), "retry-lock", user.getId(), SkillVisibility.PRIVATE);
entityManager.persist(skill);
entityManager.flush();
SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", user.getId());
version.setStatus(SkillVersionStatus.SCAN_FAILED);
entityManager.persist(version);
entityManager.flush();
return new Fixture(version.getId(), skill.getId());
});
}
private void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("Timed out waiting for concurrent retry test");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Concurrent retry test interrupted", e);
}
}
private record Fixture(Long versionId, Long skillId) {
}
}

View file

@ -220,7 +220,10 @@ class ScanTaskConsumerLoggingTest {
}
@Override
public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) {
public void processScanResult(String taskId,
Long versionId,
ScannerType scannerType,
SecurityScanResponse response) {
}
@Override
@ -229,6 +232,7 @@ class ScanTaskConsumerLoggingTest {
ScannerType scannerType,
String reason) {
}
}
private static final class TestProducer implements ScanTaskProducer {

View file

@ -716,7 +716,10 @@ class ScanTaskConsumerTest {
}
@Override
public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) {
public void processScanResult(String taskId,
Long versionId,
ScannerType scannerType,
SecurityScanResponse response) {
this.lastVersionId = versionId;
this.lastScannerType = scannerType;
this.lastResponse = response;

View file

@ -144,6 +144,7 @@ public class RouteSecurityPolicyRegistry {
ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/skills/*/*", "skill:delete"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/publish", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/versions/*/security-audit/retry", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/publish", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/publish", "skill:publish"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/auth/whoami"),

View file

@ -64,6 +64,18 @@ class RouteSecurityPolicyRegistryTest {
assertTrue(allowed.allowed());
}
@Test
void authorizeApiToken_requiresPublishScopeForSecurityScanRetry() {
var denied = registry.authorizeApiToken(
"POST", "/api/v1/skills/8/versions/42/security-audit/retry", Set.of("skill:read"));
var allowed = registry.authorizeApiToken(
"POST", "/api/v1/skills/8/versions/42/security-audit/retry", Set.of("skill:publish"));
assertFalse(denied.allowed());
assertEquals("skill:publish", denied.requiredScope());
assertTrue(allowed.allowed());
}
@Test
void authorizeApiToken_requiresDeleteScopeForHardDeleteEndpoint() {
var denied = registry.authorizeApiToken("DELETE", "/api/v1/skills/global/demo-skill", Set.of("skill:publish"));

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 {
@ -143,10 +168,15 @@ public class SecurityScanService {
}
@Transactional
public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) {
SecurityAudit audit = auditRepository.findLatestActiveByVersionIdAndScannerType(versionId, scannerType)
public void processScanResult(String taskId,
Long versionId,
ScannerType scannerType,
SecurityScanResponse response) {
SecurityAudit audit = auditRepository.findByTaskId(taskId)
.filter(candidate -> candidate.getSkillVersionId().equals(versionId))
.filter(candidate -> candidate.getScannerType() == scannerType)
.orElseThrow(() -> new IllegalStateException(
"SecurityAudit not found for versionId=" + versionId + ", scannerType=" + scannerType));
"SecurityAudit not found for taskId=" + taskId));
SkillVersion version = skillVersionRepository.findById(versionId)
.orElseThrow(() -> new IllegalStateException("SkillVersion not found: " + versionId));
@ -160,15 +190,21 @@ public class SecurityScanService {
audit.setScannedAt(Instant.now(Clock.systemUTC()));
auditRepository.save(audit);
// Only transition from SCANNING — leave PUBLISHED/REJECTED/YANKED untouched
if (version.getStatus() == SkillVersionStatus.SCANNING) {
boolean currentAttempt = auditRepository
.findLatestActiveByVersionIdAndScannerType(versionId, scannerType)
.map(latest -> taskId.equals(latest.getTaskId()))
.orElse(false);
// A late result is retained on its own audit round but cannot complete a newer attempt.
if (currentAttempt && version.getStatus() == SkillVersionStatus.SCANNING) {
if (version.getRequestedVisibility() == SkillVisibility.PRIVATE) {
version.setStatus(SkillVersionStatus.UPLOADED);
} else {
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
}
}
skillVersionRepository.save(version);
if (currentAttempt) {
skillVersionRepository.save(version);
}
}
private Path saveTempDirectory(Long versionId, List<PackageEntry> entries) {

View file

@ -8,6 +8,14 @@ import java.util.Optional;
*/
public interface SkillVersionRepository {
Optional<SkillVersion> findById(Long id);
default Optional<SkillVersion> findByIdForUpdate(Long id) {
throw new UnsupportedOperationException("This repository does not provide row locking");
}
default Optional<SkillVersionStatus> findStatusByIdAndSkillId(Long id, Long skillId) {
return findById(id)
.filter(version -> version.getSkillId().equals(skillId))
.map(SkillVersion::getStatus);
}
List<SkillVersion> findByIdIn(List<Long> ids);
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
List<SkillVersion> findBySkillIdInAndStatus(List<Long> skillIds, SkillVersionStatus status);

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");
@ -230,10 +274,11 @@ class SecurityScanServiceTest {
@Test
void processScanResult_updatesAuditAndMovesVersionToPendingReview() {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-current");
SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1");
version.setStatus(SkillVersionStatus.SCANNING);
given(auditRepository.findByTaskId("task-current")).willReturn(Optional.of(audit));
given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER))
.willReturn(Optional.of(audit));
given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version));
@ -256,7 +301,7 @@ class SecurityScanServiceTest {
1.25
);
service.processScanResult(42L, ScannerType.SKILL_SCANNER, response);
service.processScanResult("task-current", 42L, ScannerType.SKILL_SCANNER, response);
assertThat(audit.getScanId()).isEqualTo("scan-123");
assertThat(audit.getVerdict()).isEqualTo(SecurityVerdict.DANGEROUS);
@ -333,10 +378,11 @@ class SecurityScanServiceTest {
@Test
void processScanResult_shouldNotChangeStatusWhenVersionAlreadyPublished() {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-published");
SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1");
version.setStatus(SkillVersionStatus.PUBLISHED);
given(auditRepository.findByTaskId("task-published")).willReturn(Optional.of(audit));
given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER))
.willReturn(Optional.of(audit));
given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version));
@ -350,7 +396,7 @@ class SecurityScanServiceTest {
0.5
);
service.processScanResult(42L, ScannerType.SKILL_SCANNER, response);
service.processScanResult("task-published", 42L, ScannerType.SKILL_SCANNER, response);
assertThat(audit.getVerdict()).isEqualTo(SecurityVerdict.SAFE);
assertThat(audit.getIsSafe()).isTrue();
@ -358,6 +404,30 @@ class SecurityScanServiceTest {
verify(skillVersionRepository).save(version);
}
@Test
void processScanResult_forStaleAttemptDoesNotCompleteCurrentAttempt() throws Exception {
SecurityAudit stale = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-stale");
SecurityAudit current = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-current");
SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1");
setId(version, 42L);
version.setStatus(SkillVersionStatus.SCANNING);
given(auditRepository.findByTaskId("task-stale")).willReturn(Optional.of(stale));
given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER))
.willReturn(Optional.of(current));
given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version));
service.processScanResult(
"task-stale",
42L,
ScannerType.SKILL_SCANNER,
new SecurityScanResponse("scan-stale", SecurityVerdict.SAFE, 0, null, List.of(), 0.1)
);
assertThat(stale.getScanId()).isEqualTo("scan-stale");
assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING);
verify(skillVersionRepository, never()).save(version);
}
private void setId(Object target, Long id) throws Exception {
Field field = target.getClass().getDeclaredField("id");
field.setAccessible(true);

View file

@ -22,6 +22,14 @@ import org.springframework.stereotype.Repository;
*/
@Repository
public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, Long>, SkillVersionRepository {
@Override
@Query(value = "SELECT * FROM skill_version WHERE id = :id FOR UPDATE", nativeQuery = true)
Optional<SkillVersion> findByIdForUpdate(@Param("id") Long id);
@Override
@Query("SELECT version.status FROM SkillVersion version WHERE version.id = :id AND version.skillId = :skillId")
Optional<SkillVersionStatus> findStatusByIdAndSkillId(@Param("id") Long id, @Param("skillId") Long skillId);
List<SkillVersion> findByIdIn(List<Long> ids);
List<SkillVersion> findBySkillId(Long skillId);
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);

View file

@ -1268,6 +1268,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/skills/{skillId}/versions/{versionId}/security-audit/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["retrySecurityScan"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/skills/{canonicalSlug}/undelete": {
parameters: {
query?: never;
@ -8517,6 +8533,29 @@ export interface operations {
};
};
};
retrySecurityScan: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
versionId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"];
};
};
};
};
undeleteSkill: {
parameters: {
query?: never;

View file

@ -1,5 +1,8 @@
/** @vitest-environment jsdom */
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SecurityAuditRecord } from './types'
import { SecurityAuditSummary } from './security-audit-summary'
@ -34,11 +37,18 @@ function createAudit(overrides: Partial<SecurityAuditRecord> = {}): SecurityAudi
}
let mockAudits: SecurityAuditRecord[] | undefined = undefined
const { retryMutation, toastMocks } = vi.hoisted(() => ({
retryMutation: { mutate: vi.fn(), isPending: false },
toastMocks: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('./use-security-audit', () => ({
useSecurityAudits: () => ({ data: mockAudits }),
useRetrySecurityScan: () => retryMutation,
}))
vi.mock('@/shared/lib/toast', () => ({ toast: toastMocks }))
// Mock the Dialog components to avoid Radix UI portal / context issues in static render
vi.mock('@/shared/ui/dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@ -54,6 +64,10 @@ vi.mock('./security-audit-section', () => ({
}))
describe('SecurityAuditSummary', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('returns null when audits is undefined', () => {
mockAudits = undefined
@ -116,6 +130,34 @@ 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('starts retry and exposes success and failure feedback callbacks', () => {
mockAudits = [createAudit({ scannedAt: null })]
render(<SecurityAuditSummary skillId={1} versionId={10} versionStatus="SCAN_FAILED" canRetry />)
fireEvent.click(screen.getByRole('button', { name: 'securityAudit.retry' }))
expect(retryMutation.mutate).toHaveBeenCalledOnce()
const options = retryMutation.mutate.mock.calls[0]?.[1]
options.onSuccess()
expect(toastMocks.success).toHaveBeenCalledWith('securityAudit.retrySuccess')
options.onError(new Error('scanner unavailable'))
expect(toastMocks.error).toHaveBeenCalledWith('securityAudit.retryError', 'scanner unavailable')
})
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

@ -12,12 +12,22 @@ import { describe, expect, it, vi } from 'vitest'
// Capture the options passed to useQuery so we can assert on them.
let capturedOptions: Record<string, unknown> | undefined
let capturedMutationOptions: Record<string, unknown> | undefined
const apiMocks = vi.hoisted(() => ({
fetchJson: vi.fn(),
getCsrfHeaders: vi.fn(() => ({ 'X-XSRF-TOKEN': 'csrf-token' })),
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: Record<string, unknown>) => {
capturedOptions = options
return { data: undefined, isLoading: false }
},
useMutation: (options: Record<string, unknown>) => {
capturedMutationOptions = options
return { mutate: vi.fn(), isPending: false }
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}))
// Mock fetchJson to avoid actual network calls. The hook's queryFn
@ -30,11 +40,12 @@ vi.mock('@/api/client', () => ({
this.status = status
}
},
fetchJson: vi.fn(),
fetchJson: apiMocks.fetchJson,
getCsrfHeaders: apiMocks.getCsrfHeaders,
}))
// Dynamic import to ensure mocks are established first.
const { useSecurityAudits } = await import('./use-security-audit')
const { useRetrySecurityScan, useSecurityAudits } = await import('./use-security-audit')
describe('useSecurityAudits', () => {
it('uses the correct query key structure', () => {
@ -78,4 +89,17 @@ describe('useSecurityAudits', () => {
expect(capturedOptions?.retry).toBe(false)
})
it('sends the CSRF header when retrying a security scan', async () => {
apiMocks.fetchJson.mockResolvedValueOnce({ status: 'SCANNING' })
useRetrySecurityScan(42, 100)
await (capturedMutationOptions?.mutationFn as () => Promise<unknown>)()
expect(apiMocks.getCsrfHeaders).toHaveBeenCalledOnce()
expect(apiMocks.fetchJson).toHaveBeenCalledWith(
'/api/v1/skills/42/versions/100/security-audit/retry',
{ method: 'POST', headers: { 'X-XSRF-TOKEN': 'csrf-token' } },
)
})
})

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { ApiError, fetchJson } from '@/api/client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, fetchJson, getCsrfHeaders } from '@/api/client'
import type { SecurityAuditRecord } from './types'
async function fetchSecurityAudits(
@ -31,3 +31,17 @@ 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',
headers: getCsrfHeaders(),
}),
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

@ -74,6 +74,12 @@ vi.mock('@/features/report/use-skill-reports', () => ({
useSubmitSkillReport: () => ({ mutateAsync: vi.fn(), isPending: false }),
}))
vi.mock('@/features/security-audit/security-audit-summary', () => ({
SecurityAuditSummary: ({ versionId, versionStatus }: { versionId: number; versionStatus?: string }) => (
<div data-testid="security-audit-summary">audit:{versionId}:{versionStatus}</div>
),
}))
vi.mock('@/shared/lib/toast', () => ({
toast: { success: toastMocks.success, error: toastMocks.error },
}))
@ -499,6 +505,32 @@ describe('SkillDetailPage', () => {
expect(html).not.toContain('skillDetail.versionStatusScanFailed')
})
it('binds scan retry to the failed owner preview when a published version remains visible', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({
canManageLifecycle: true,
headlineVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' },
publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' },
ownerPreviewVersion: { id: 12, version: '1.2.0', status: 'SCAN_FAILED' },
resolutionMode: 'PUBLISHED',
}),
isLoading: false,
isFetching: false,
error: null,
})
useSkillVersionsMock.mockReturnValue({
data: [
{ id: 10, version: '1.0.0', status: 'PUBLISHED', downloadAvailable: true },
{ id: 12, version: '1.2.0', status: 'SCAN_FAILED', downloadAvailable: false },
],
})
const html = renderToStaticMarkup(<SkillDetailPage />)
expect(html).toContain('audit:12:SCAN_FAILED')
expect(html).not.toContain('audit:10:PUBLISHED')
})
it('allows long pending review versions to wrap inside the review card', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({

View file

@ -199,6 +199,12 @@ 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 securityAuditVersion = ownerPreviewVersion?.status === 'SCAN_FAILED'
? ownerPreviewVersion
: selectedVersionEntry
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false)
useEffect(() => {
@ -1256,8 +1262,13 @@ export function SkillDetailPage() {
description={skill.summary}
/>
{skill.canManageLifecycle && selectedVersionEntry && (
<SecurityAuditSummary skillId={skill.id} versionId={selectedVersionEntry.id} versionStatus={selectedVersionEntry.status} />
{canManageSecurityScan && securityAuditVersion && (
<SecurityAuditSummary
skillId={skill.id}
versionId={securityAuditVersion.id}
versionStatus={securityAuditVersion.status}
canRetry
/>
)}
<SkillLabelPanel