diff --git a/docs/security-scanning.md b/docs/security-scanning.md index 3686d474..e5e09d5d 100644 --- a/docs/security-scanning.md +++ b/docs/security-scanning.md @@ -49,6 +49,7 @@ skillhub: key: skillhub:scan:requests group: skillhub-scanners reclaim-min-idle: PT16M + max-unavailable-age: PT1H ``` Important environment variables: @@ -60,6 +61,7 @@ Important environment variables: - `SKILLHUB_SCAN_STREAM_KEY` - `SKILLHUB_SCAN_STREAM_GROUP` - `SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE` +- `SKILLHUB_SECURITY_STREAM_MAX_UNAVAILABLE_AGE` Scanner-side optional environment variables: @@ -134,7 +136,11 @@ Response fields include: ## Failure Semantics - scan task retries are handled by `AbstractStreamConsumer` -- final failure marks the version as `SCAN_FAILED` +- scanner connection failures, HTTP 429, and HTTP 5xx remain pending for automatic recovery +- unavailable tasks older than `max-unavailable-age` are marked `SCAN_FAILED`, acknowledged, and removed from the Redis Stream +- the timeout is evaluated during pending reclaim; terminal handling can occur roughly one `reclaim-min-idle` plus one `reclaim-interval` after the configured age +- terminal failures retain a failure reason in the security audit response for operators and authorized users +- other final failures mark the version as `SCAN_FAILED` after retry exhaustion - even after scan failure, a review task is still created so the package does not get stuck forever This keeps the existing human review path intact while making scanner failures visible. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java index 484c3242..ccb1356e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java @@ -8,12 +8,13 @@ import com.iflytek.skillhub.observability.MessageObservationSupport; import com.iflytek.skillhub.storage.ObjectStorageService; import com.iflytek.skillhub.stream.RedissonScanTaskProducer; import com.iflytek.skillhub.stream.ScanTaskConsumer; +import java.time.Clock; +import java.time.Duration; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.time.Duration; @Configuration @ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true") @@ -40,6 +41,9 @@ public class RedisStreamConfig { @Value("${skillhub.security.scanner.retry-max-attempts:3}") private int maxRetryAttempts; + @Value("${skillhub.security.stream.max-unavailable-age:PT1H}") + private Duration maxUnavailableAge; + @Bean public RedissonScanTaskProducer redisScanTaskProducer( RedissonClient redissonClient, @@ -55,6 +59,7 @@ public class RedisStreamConfig { SkillVersionRepository skillVersionRepository, ScanTaskProducer scanTaskProducer, ObjectStorageService objectStorageService, + Clock clock, MessageObservationSupport messageObservationSupport) { return new ScanTaskConsumer( redissonClient, @@ -70,6 +75,8 @@ public class RedisStreamConfig { reclaimBatchSize, reclaimInterval, maxRetryAttempts, + maxUnavailableAge, + clock, messageObservationSupport ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java index f763424f..3666c46c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java @@ -121,6 +121,7 @@ public class SecurityAuditController extends BaseApiController { audit.getFindingsCount(), deserializeFindings(audit.getFindings()), audit.getScanDurationSeconds(), + audit.getFailureReason(), audit.getScannedAt(), audit.getCreatedAt() ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SecurityAuditResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SecurityAuditResponse.java index f4efeb9e..4e5b09c4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SecurityAuditResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SecurityAuditResponse.java @@ -16,6 +16,7 @@ public record SecurityAuditResponse( Integer findingsCount, List findings, Double scanDurationSeconds, + String failureReason, Instant scannedAt, Instant createdAt ) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java index a90bb4a8..a91fdb0f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java @@ -247,15 +247,17 @@ public abstract class AbstractStreamConsumer { } private void handleFailure(T payload, int retryCount, Exception e) { - if (retryCount < maxRetryCount()) { + if (shouldRetry(payload, e, retryCount)) { // Retry publication remains inside the current consumer scope, so the new producer // span and message carrier continue the original trace. retryMessage(payload, retryCount + 1); return; } - markFailed(payload, truncateError( - taskDisplayName() + " failed (retried " + retryCount + " times): " + e.getMessage() - )); + markFailed(payload, truncateError(finalFailureReason(payload, e, retryCount))); + } + + protected String finalFailureReason(T payload, Exception error, int retryCount) { + return taskDisplayName() + " failed (retried " + retryCount + " times): " + error.getMessage(); } protected int parseRetryCount(Map data) { @@ -293,6 +295,10 @@ public abstract class AbstractStreamConsumer { return DEFAULT_MAX_RETRY_COUNT; } + protected boolean shouldRetry(T payload, Exception error, int retryCount) { + return retryCount < maxRetryCount(); + } + protected boolean shouldDeferFailure(T payload, Exception error) { return false; } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java index 37579015..548b28be 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java @@ -21,12 +21,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.time.Clock; +import java.time.DateTimeException; import java.time.Duration; +import java.time.Instant; import java.util.Comparator; import java.util.Map; +import java.util.Objects; public class ScanTaskConsumer extends AbstractStreamConsumer { private static final Path SCAN_TEMP_DIR = Paths.get("/tmp/skillhub-scans").toAbsolutePath().normalize(); + private static final Duration DEFAULT_MAX_UNAVAILABLE_AGE = Duration.ofHours(1); + private static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(5); private final RedissonClient redissonClient; private final SecurityScanner securityScanner; @@ -35,6 +41,8 @@ public class ScanTaskConsumer extends AbstractStreamConsumer version.getStatus() == SkillVersionStatus.SCANNING) - .ifPresent(version -> { - version.setStatus(SkillVersionStatus.SCAN_FAILED); - skillVersionRepository.save(version); - }); + securityScanService.processScanFailure( + payload.taskId(), payload.versionId(), payload.scannerType(), error); } finally { cleanupTempPath(payload.cleanupPath()); } } + @Override protected void retryMessage(ScanTaskPayload payload, int retryCount) { log.warn("Retrying security scan task: taskId={}, versionId={}, scanner={}, nextRetryCount={}, source={}", @@ -315,6 +353,55 @@ public class ScanTaskConsumer extends AbstractStreamConsumer now.plus(MAX_CLOCK_SKEW).toEpochMilli()) { + return true; + } + try { + return !Instant.ofEpochMilli(createdAtMillis).plus(maxUnavailableAge).isAfter(now); + } catch (DateTimeException | ArithmeticException ignored) { + return true; + } + } + + private Duration taskAge(ScanTaskPayload payload) { + try { + Duration age = Duration.between(Instant.ofEpochMilli(payload.createdAtMillis()), clock.instant()); + return age.isNegative() ? Duration.ZERO : age; + } catch (DateTimeException | ArithmeticException ignored) { + return maxUnavailableAge; + } + } + + private long parseCreatedAtMillis(String messageId, String value) { + Long createdAt = parsePositiveLong(value); + if (createdAt != null) { + return createdAt; + } + int separator = messageId.indexOf('-'); + String redisTimestamp = separator >= 0 ? messageId.substring(0, separator) : messageId; + Long fallback = parsePositiveLong(redisTimestamp); + return fallback != null ? fallback : 0L; + } + + private Long parsePositiveLong(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + long parsed = Long.parseLong(value); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } + } + protected static final class ScanTaskPayload { private final String taskId; private final Long versionId; @@ -322,11 +409,12 @@ public class ScanTaskConsumer extends AbstractStreamConsumer task = Map.of( + "taskId", "task-ack-recovery", + "versionId", "42", + "skillPath", "/tmp/skillhub-scans/42", + "createdAtMillis", String.valueOf(Instant.parse("2026-09-03T06:00:00Z").toEpochMilli()), + "scannerType", ScannerType.SKILL_SCANNER.getValue() + ); + when(consumer.stream.ack("skillhub-scanners", messageId)) + .thenThrow(new IllegalStateException("redis unavailable")) + .thenReturn(1L); + + assertThatThrownBy(() -> consumer.handleMessage(messageId, task)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("redis unavailable"); + consumer.handleMessage(messageId, task); + + assertThat(securityScanner.invocations).isEqualTo(1); + verify(consumer.stream).remove(messageId); + } + @Test void processBusiness_whenScannerFails_releasesProcessingLock() { StubSecurityScanner securityScanner = new StubSecurityScanner(); @@ -392,6 +527,24 @@ class ScanTaskConsumerTest { verify(processingLock).unlock(); } + private StubSecurityScanner unavailableScanner() { + StubSecurityScanner scanner = new StubSecurityScanner(); + scanner.failure = new SecurityScanException( + "scanner timed out", new HttpClientException("request timed out", new java.util.concurrent.TimeoutException())); + return scanner; + } + + private SkillVersion scanningVersion(Long id) { + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + try { + setField(version, "id", id); + } catch (Exception e) { + throw new AssertionError(e); + } + version.setStatus(SkillVersionStatus.SCANNING); + return version; + } + private void setField(Object target, String fieldName, Object value) throws Exception { Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); @@ -436,6 +589,35 @@ class ScanTaskConsumerTest { this.stream = mock(RStream.class); } + @SuppressWarnings("unchecked") + private TestableScanTaskConsumer(SecurityScanner securityScanner, + SecurityScanService securityScanService, + SkillVersionRepository skillVersionRepository, + ScanTaskProducer scanTaskProducer, + ObjectStorageService objectStorageService, + Clock clock, + Duration maxUnavailableAge) { + super( + redissonClient(availableProcessingLock()), + "skillhub:scan:requests", + "skillhub-scanners", + securityScanner, + securityScanService, + skillVersionRepository, + scanTaskProducer, + objectStorageService, + true, + Duration.ofMinutes(16), + 20, + Duration.ofSeconds(30), + 3, + maxUnavailableAge, + clock, + new MessageObservationSupport(ObservationRegistry.NOOP, new RequestIdAccessor()) + ); + this.stream = mock(RStream.class); + } + @SuppressWarnings("unchecked") private TestableScanTaskConsumer(SecurityScanner securityScanner, SecurityScanService securityScanService, @@ -496,9 +678,11 @@ class ScanTaskConsumerTest { private SecurityScanRequest lastRequest; private SecurityScanResponse response; private RuntimeException failure; + private int invocations; @Override public SecurityScanResponse scan(SecurityScanRequest request) { + invocations++; this.lastRequest = request; if (failure != null) { throw failure; @@ -521,6 +705,10 @@ class ScanTaskConsumerTest { private Long lastVersionId; private ScannerType lastScannerType; private SecurityScanResponse lastResponse; + private String failedTaskId; + private Long failedVersionId; + private String failedReason; + private boolean processed; private StubSecurityScanService() { super(null, null, task -> { @@ -533,6 +721,19 @@ class ScanTaskConsumerTest { this.lastScannerType = scannerType; this.lastResponse = response; } + + @Override + public void processScanFailure(String taskId, Long versionId, ScannerType scannerType, String reason) { + this.failedTaskId = taskId; + this.failedVersionId = versionId; + this.failedReason = reason; + this.processed = true; + } + + @Override + public boolean isTaskAlreadyProcessed(String taskId) { + return processed && taskId.equals(failedTaskId); + } } private static final class InMemorySkillVersionRepository implements SkillVersionRepository { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java index 0b7844de..4c8c5520 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java @@ -56,6 +56,9 @@ public class SecurityAudit { @Column(name = "scan_duration_seconds") private Double scanDurationSeconds; + @Column(name = "failure_reason", length = 1000) + private String failureReason; + @Column(name = "scanned_at") private Instant scannedAt; @@ -131,6 +134,10 @@ public class SecurityAudit { return scanDurationSeconds; } + public String getFailureReason() { + return failureReason; + } + public Instant getScannedAt() { return scannedAt; } @@ -171,6 +178,18 @@ public class SecurityAudit { this.scannedAt = scannedAt; } + public void markFailed(Instant failedAt, String reason) { + this.failureReason = truncate(reason, 1000); + this.scannedAt = failedAt; + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + public Instant getDeletedAt() { return deletedAt; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java index 9bde6ea6..cf655430 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java @@ -12,6 +12,8 @@ public interface SecurityAuditRepository { Optional findByScanId(String scanId); + Optional findByTaskId(String taskId); + boolean existsByTaskIdAndScannedAtIsNotNull(String taskId); boolean existsBySkillVersionId(Long skillVersionId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index df12ee8e..61a8578a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -115,6 +115,33 @@ public class SecurityScanService { && auditRepository.existsByTaskIdAndScannedAtIsNotNull(taskId); } + @Transactional + public void processScanFailure(String taskId, Long versionId, ScannerType scannerType, String reason) { + SecurityAudit audit = auditRepository.findByTaskId(taskId) + .filter(candidate -> candidate.getSkillVersionId().equals(versionId)) + .filter(candidate -> candidate.getScannerType() == scannerType) + .orElseThrow(() -> new IllegalStateException("SecurityAudit not found for taskId=" + taskId)); + if (audit.getScannedAt() != null) { + return; + } + audit.markFailed(Instant.now(Clock.systemUTC()), reason); + auditRepository.save(audit); + + boolean currentAttempt = auditRepository + .findLatestActiveByVersionIdAndScannerType(versionId, scannerType) + .map(latest -> taskId.equals(latest.getTaskId())) + .orElse(false); + if (!currentAttempt) { + return; + } + skillVersionRepository.findById(versionId) + .filter(version -> version.getStatus() == SkillVersionStatus.SCANNING) + .ifPresent(version -> { + version.setStatus(SkillVersionStatus.SCAN_FAILED); + skillVersionRepository.save(version); + }); + } + @Transactional public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) { SecurityAudit audit = auditRepository.findLatestActiveByVersionIdAndScannerType(versionId, scannerType) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java index 4150c57d..b5e90381 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java @@ -292,6 +292,45 @@ class SecurityScanServiceTest { assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); } + @Test + void processScanFailure_marksExactCurrentAttemptAndVersionFailed() throws Exception { + SecurityAudit audit = 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-current")).willReturn(Optional.of(audit)); + given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) + .willReturn(Optional.of(audit)); + given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); + + service.processScanFailure("task-current", 42L, ScannerType.SKILL_SCANNER, "scanner unavailable"); + + assertThat(audit.getScannedAt()).isNotNull(); + assertThat(audit.getFailureReason()).isEqualTo("scanner unavailable"); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCAN_FAILED); + verify(auditRepository).save(audit); + verify(skillVersionRepository).save(version); + } + + @Test + void processScanFailure_forStaleAttemptDoesNotFailCurrentVersion() 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)); + + service.processScanFailure("task-stale", 42L, ScannerType.SKILL_SCANNER, "stale failure"); + + assertThat(stale.getScannedAt()).isNotNull(); + assertThat(stale.getFailureReason()).isEqualTo("stale failure"); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING); + verify(skillVersionRepository, never()).save(any()); + } + @Test void processScanResult_shouldNotChangeStatusWhenVersionAlreadyPublished() { SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SecurityAuditJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SecurityAuditJpaRepository.java index a12907db..1f5e8f6b 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SecurityAuditJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SecurityAuditJpaRepository.java @@ -20,6 +20,9 @@ public interface SecurityAuditJpaRepository extends JpaRepository findByScanId(String scanId); + @Override + Optional findByTaskId(String taskId); + @Override boolean existsBySkillVersionId(Long skillVersionId); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index fc2e53e9..b7f92c98 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -5309,6 +5309,7 @@ export interface components { findings?: components["schemas"]["SecurityFinding"][]; /** Format: double */ scanDurationSeconds?: number; + failureReason?: string; /** Format: date-time */ scannedAt?: string; /** Format: date-time */ diff --git a/web/src/features/security-audit/display-state.ts b/web/src/features/security-audit/display-state.ts index 42cc0cdc..df6f284d 100644 --- a/web/src/features/security-audit/display-state.ts +++ b/web/src/features/security-audit/display-state.ts @@ -1,14 +1,14 @@ import type { SecurityAuditDisplayState, SecurityAuditRecord } from './types' export function getSecurityAuditDisplayState( - audit: Pick, + audit: Pick, versionStatus?: string ): SecurityAuditDisplayState { + if (audit.failureReason || versionStatus === 'SCAN_FAILED') { + return 'SCAN_FAILED' + } if (audit.scannedAt) { return audit.verdict } - if (versionStatus === 'SCAN_FAILED') { - return 'SCAN_FAILED' - } return 'SCANNING' } diff --git a/web/src/features/security-audit/security-audit-section.test.tsx b/web/src/features/security-audit/security-audit-section.test.tsx index 6fec386c..e2acc764 100644 --- a/web/src/features/security-audit/security-audit-section.test.tsx +++ b/web/src/features/security-audit/security-audit-section.test.tsx @@ -8,8 +8,11 @@ vi.mock('react-i18next', async () => { return { ...actual, useTranslation: () => ({ - t: (key: string, values?: Record) => - values?.count !== undefined ? `${key}:${values.count}` : key, + t: (key: string, values?: Record) => { + if (values?.count !== undefined) return `${key}:${values.count}` + if (values?.reason !== undefined) return `${key}:${values.reason}` + return key + }, i18n: { language: 'en' }, }), } @@ -26,6 +29,7 @@ function createAudit(overrides: Partial = {}): SecurityAudi findingsCount: 0, findings: [], scanDurationSeconds: null, + failureReason: null, scannedAt: '2026-03-20T10:00:00Z', createdAt: '2026-03-20T10:00:00Z', ...overrides, @@ -109,13 +113,18 @@ describe('SecurityAuditSection', () => { }) it('renders scan failed status when version scan failed before audit completion', () => { - mockAudits = [createAudit({ verdict: 'SUSPICIOUS', scannedAt: null })] + mockAudits = [createAudit({ + verdict: 'SUSPICIOUS', + scannedAt: '2026-03-20T10:00:00Z', + failureReason: 'Scanner remained unavailable', + })] mockIsLoading = false const html = renderToStaticMarkup() expect(html).toContain('securityAudit.statusScanFailed') expect(html).not.toContain('securityAudit.statusScanning') + expect(html).toContain('Scanner remained unavailable') }) it('renders the findings count', () => { diff --git a/web/src/features/security-audit/security-audit-section.tsx b/web/src/features/security-audit/security-audit-section.tsx index 87fdc352..1212f1e3 100644 --- a/web/src/features/security-audit/security-audit-section.tsx +++ b/web/src/features/security-audit/security-audit-section.tsx @@ -86,6 +86,12 @@ function ScannerCard({ audit, versionStatus }: { audit: SecurityAuditRecord; ver + {audit.failureReason && ( +

+ {t('securityAudit.failureReason', { reason: audit.failureReason })} +

+ )} + {sortedFindings.length > 0 && ( <>