diff --git a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java index 26693cbbd..5421da72f 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java @@ -38,6 +38,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Locale; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -65,6 +66,7 @@ public class ScanAPI { private final org.eclipse.openvsx.scanning.ExtensionScanCompletionService completionService; private final org.eclipse.openvsx.scanning.ScannerRegistry scannerRegistry; private final org.eclipse.openvsx.repositories.ScannerJobRepository scanJobRepository; + private final org.eclipse.openvsx.scanning.ExtensionScanService scanService; public ScanAPI( RepositoryService repositories, @@ -73,7 +75,8 @@ public ScanAPI( StorageUtilService storageUtil, org.eclipse.openvsx.scanning.ExtensionScanCompletionService completionService, org.eclipse.openvsx.scanning.ScannerRegistry scannerRegistry, - org.eclipse.openvsx.repositories.ScannerJobRepository scanJobRepository + org.eclipse.openvsx.repositories.ScannerJobRepository scanJobRepository, + org.eclipse.openvsx.scanning.ExtensionScanService scanService ) { this.repositories = repositories; this.admins = admins; @@ -82,6 +85,7 @@ public ScanAPI( this.completionService = completionService; this.scannerRegistry = scannerRegistry; this.scanJobRepository = scanJobRepository; + this.scanService = scanService; } /** @@ -571,6 +575,51 @@ public ResponseEntity getScan( } } + /** + * Retry all failed scanner jobs for a terminal scan. + */ + @PostMapping( + path = "/{scanId}/jobs/retry", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Retry all failed scanner jobs for a scan") + @ApiResponse( + responseCode = "200", + description = "Failed jobs re-queued; returns the updated scan in SCANNING state", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ScanResultJson.class) + ) + ) + @ApiResponse( + responseCode = "400", + description = "Scan is not terminal or has no failed jobs to retry", + content = @Content() + ) + @ApiResponse( + responseCode = "404", + description = "Scan or scanner jobs were not found", + content = @Content() + ) + public ResponseEntity retryFailedScannerJobs( + @PathVariable @Parameter(description = "Scan ID", example = "123") long scanId + ) { + try { + var adminUser = admins.checkAdminUser(); + + var scan = Optional.ofNullable(repositories.findExtensionScan(scanId)) + .orElseThrow(() -> new ErrorResultException("Scan not found: " + scanId, HttpStatus.NOT_FOUND)); + + var updatedScan = scanService.retryFailedJobs(scan); + + logs.logAction(adminUser, ResultJson.success("Retrying failed scanner jobs for scan #" + scanId)); + return ResponseEntity.ok(toScanResultJson(updatedScan)); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(ScanResultJson.class); + } + } + /** * Make security decisions for one or more quarantined scans. * Only valid for scans with QUARANTINED status. diff --git a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionScan.java b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionScan.java index 82e9cc43a..7161ceefc 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionScan.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionScan.java @@ -253,6 +253,16 @@ public void addCheckResult(ScanCheckResult result) { result.setScan(this); } + /** + * Resets this {@code ExtensionScan} instance back to status {@code SCANNING} + * to retry a failed scanner jobs. + */ + public void resetToScanning() { + setStatus(ScanStatus.SCANNING); + setCompletedAt(null); + setErrorMessage(null); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/server/src/main/java/org/eclipse/openvsx/entities/ScannerJob.java b/server/src/main/java/org/eclipse/openvsx/entities/ScannerJob.java index 2d4172a18..ff6c3583f 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/ScannerJob.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/ScannerJob.java @@ -13,6 +13,8 @@ package org.eclipse.openvsx.entities; import jakarta.persistence.*; +import org.eclipse.openvsx.util.TimeUtil; + import java.time.LocalDateTime; import java.util.Objects; @@ -248,7 +250,24 @@ public String getFileHashesJson() { public void setFileHashesJson(String fileHashesJson) { this.fileHashesJson = fileHashesJson; } - + + /** + * Resets this {@code ScannerJob} instance back to status {@code QUEUED} + * to retry a specific scanner job. + */ + public void resetToQueued() { + setStatus(ScannerJob.JobStatus.QUEUED); + setErrorMessage(null); + setExternalJobId(null); + setPollAttempts(0); + setPollLeaseUntil(null); + setRecoveryInProgress(false); + + var now = TimeUtil.getCurrentUTC(); + setCreatedAt(now); + setUpdatedAt(now); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ScanCheckResultRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ScanCheckResultRepository.java index d4651f214..ce6bfa22b 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ScanCheckResultRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ScanCheckResultRepository.java @@ -62,4 +62,9 @@ ScanCheckResult findFirstByScanAndCheckTypeOrderByStartedAtDesc( * Delete all check results for a scan. */ void deleteByScan(ExtensionScan scan); + + /** + * Delete the check result recorded for a specific scanner job. + */ + void deleteByScannerJobId(Long scannerJobId); } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceService.java b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceService.java index 264af2342..61983b836 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceService.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceService.java @@ -19,6 +19,7 @@ import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.repositories.FileDecisionRepository; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.repositories.ScanCheckResultRepository; import org.eclipse.openvsx.repositories.ScannerJobRepository; import org.eclipse.openvsx.util.TimeUtil; import org.slf4j.Logger; @@ -56,6 +57,7 @@ public class ExtensionScanPersistenceService { private final ObjectMapper objectMapper; private final FileDecisionRepository fileDecisionRepository; private final ScannerJobRepository scannerJobRepository; + private final ScanCheckResultRepository scanCheckResultRepository; private final ScannerRegistry scannerRegistry; public ExtensionScanPersistenceService( @@ -63,12 +65,14 @@ public ExtensionScanPersistenceService( ObjectMapper objectMapper, FileDecisionRepository fileDecisionRepository, ScannerJobRepository scannerJobRepository, + ScanCheckResultRepository scanCheckResultRepository, ScannerRegistry scannerRegistry ) { this.repositories = repositories; this.objectMapper = objectMapper; this.fileDecisionRepository = fileDecisionRepository; this.scannerJobRepository = scannerJobRepository; + this.scanCheckResultRepository = scanCheckResultRepository; this.scannerRegistry = scannerRegistry; } @@ -176,6 +180,25 @@ public void removeScan(@Nonnull ExtensionScan scan) { repositories.deleteExtensionScan(scan); } + /** + * Reset a FAILED scanner job back to QUEUED and flip the parent scan to + * SCANNING so the completion service will process it once the job finishes. + *

+ * Caller is expected to have already validated that {@code job} belongs to + * {@code scan}, that the job is FAILED, and that the scan is terminal. + */ + @Transactional(TxType.REQUIRES_NEW) + public void resetJobForRetry(@Nonnull ExtensionScan scan, @Nonnull ScannerJob job) { + // Drop the stale check result so the retry's outcome is the only record the UI shows + scanCheckResultRepository.deleteByScannerJobId(job.getId()); + + job.resetToQueued(); + scannerJobRepository.save(job); + + scan.resetToScanning(); + repositories.saveExtensionScan(scan); + } + /** * Records a validation failure with the given check type. */ @@ -572,10 +595,10 @@ private Map parseFileHashes(String fileHashesJson) { /** * Delete all scan-related data for a specific extension version. - * + *

* This should be called when an extension version is deleted to prevent * orphaned scan jobs from trying to access deleted files. - * + *

* Also marks any in-progress scans as ERRORED so they don't remain stuck. */ @Transactional diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanService.java b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanService.java index 5f6513078..02c36ec9e 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanService.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanService.java @@ -22,6 +22,7 @@ import org.jobrunr.scheduling.JobRequestScheduler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import jakarta.annotation.Nonnull; @@ -255,6 +256,88 @@ public boolean submitScannerJobs(@Nonnull ExtensionScan scan, @Nonnull Extension return true; } + /** + * Retry a single FAILED scanner job for a terminal scan. + *

+ * The job is moved back to QUEUED (preserving the row), the parent scan is + * flipped back to SCANNING so the completion service will pick up the + * result, and a fresh JobRunr request is enqueued. + * + * @throws ErrorResultException if the scan is not in a terminal state, the + * job does not belong to the scan, or the job is not in FAILED status. + */ + public void retryFailedJob(@Nonnull ExtensionScan scan, @Nonnull ScannerJob job) { + if (job.getStatus().isActive()) { + throw new ErrorResultException( + "Cannot retry: this job is currently in an active state (current: " + job.getStatus() + ")" + ); + } + + // reset the scan job for retry + persistenceService.resetJobForRetry(scan, job); + + var scanner = scannerRegistry.getScanner(job.getScannerType()); + if (scanner == null) { + throw new ErrorResultException( + String.format("Encountered unknown scanner type %s when retrying scan job with id %s", + job.getScannerType(), job.getScanId() + ) + ); + } + + // Check if the scanner has a maxConcurrency set, in which case the invocation + // will be coordinated by the ScannerConcurrencyDispatcher, otherwise dispatch immediately + if (scanner.getMaxConcurrency() <= 0) { + try { + logger.info("Retrying scanner job {} ({}) for scanId={}, extension={}.{}", + job.getId(), job.getScannerType(), job.getScanId(), scan.getNamespaceName(), scan.getExtensionName() + ); + jobScheduler.enqueue(new ScannerInvocationRequest(job.getScannerType(), job.getExtensionVersionId(), job.getScanId())); + } catch (Exception e) { + logger.error("Failed to enqueue retry scanner job for scanner {} (jobId={}, scanId={}): {}", + job.getScannerType(), job.getId(), job.getScanId(), e.getMessage() + ); + } + } + } + + /** + * Retry all FAILED scanner jobs for a terminal scan. + * + * @return the scan after it has been flipped back to SCANNING + * @throws ErrorResultException if the scan is not terminal, has no jobs, + * or has no failed jobs to retry. + */ + public ExtensionScan retryFailedJobs(@Nonnull ExtensionScan scan) { + if (!scan.getStatus().isCompleted()) { + throw new ErrorResultException( + String.format( + "Cannot retry failed jobs: scan #%d is not in a terminal state (current: %s)", + scan.getId(), + scan.getStatus() + ), + HttpStatus.BAD_REQUEST + ); + } + + String scanId = String.valueOf(scan.getId()); + var jobs = scanJobRepository.findByScanId(scanId); + if (jobs.isEmpty()) { + throw new ErrorResultException("No scanner jobs found for scan #" + scanId, HttpStatus.NOT_FOUND); + } + + var failedJobs = jobs.stream() + .filter(job -> job.getStatus() == ScannerJob.JobStatus.FAILED) + .toList(); + + if (failedJobs.isEmpty()) { + throw new ErrorResultException("No failed scanner jobs found for scan #" + scanId, HttpStatus.BAD_REQUEST); + } + + failedJobs.forEach(failedJob -> retryFailedJob(scan, failedJob)); + return scan; + } + /** * Check if there are any scanners registered for long-running scans. */ diff --git a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java index ac0b69dc4..0d0aef6b7 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java @@ -22,7 +22,6 @@ import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.LogService; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -37,7 +36,9 @@ import java.util.List; import java.util.Map; +import static org.mockito.Mockito.*; 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.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -73,10 +74,13 @@ class ScanAPITest { @MockitoBean org.eclipse.openvsx.repositories.ScannerJobRepository scanJobRepository; + @MockitoBean + org.eclipse.openvsx.scanning.ExtensionScanService scanService; + @Test void getScans_filters_sorting_and_pagination_are_applied() throws Exception { // Always allow the request to pass the admin gate in this test setup. - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); // Build scan with display name for the sorted/filtered result. var scanC = TestData.scan(3, "gamma", "third", "2.0.0", "alpha-team", ScanStatus.VALIDATING, LocalDateTime.of(2024, 12, 3, 10, 0)); @@ -84,18 +88,18 @@ void getScans_filters_sorting_and_pagination_are_applied() throws Exception { // Mock the DB-level filtered/paginated query to return just the expected result. // The DB does filtering and pagination, so tests now verify correct parameters are passed. - Mockito.when(repositories.findScansFullyFiltered( - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any() + when(repositories.findScansFullyFiltered( + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), anyBoolean(), any() )).thenReturn(new PageImpl<>(List.of(scanC), org.springframework.data.domain.PageRequest.of(0, 1), 2)); - Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + when(repositories.findValidationFailures(any())).thenReturn(Streamable.empty()); + when(repositories.findExtensionThreats(any())).thenReturn(Streamable.empty()); + when(storageUtil.getFileUrls(anyList(), anyString(), any(), any())).thenReturn(Map.of()); // Provide display name from linked version - Mockito.when(repositories.findVersion("2.0.0", "universal", "third", "gamma")).thenReturn(TestData.version(12, "Alpha Utility")); + when(repositories.findVersion("2.0.0", "universal", "third", "gamma")).thenReturn(TestData.version(12, "Alpha Utility")); mockMvc.perform(get("/admin/scans") .param("status", "VALIDATING") @@ -120,21 +124,21 @@ void getScans_filters_sorting_and_pagination_are_applied() throws Exception { @Test void getScans_namespace_partial_match_is_applied() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); var scanA = TestData.scan(1, "alpha-ns", "ext-a", "1.0.0", "pub", ScanStatus.PASSED, LocalDateTime.of(2024, 12, 1, 10, 0)); // DB-level filtering returns only the matching scan - Mockito.when(repositories.findScansFullyFiltered( - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any() + when(repositories.findScansFullyFiltered( + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), anyBoolean(), any() )).thenReturn(new PageImpl<>(List.of(scanA))); - Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(repositories.findVersion(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString())).thenReturn(null); - Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + when(repositories.findValidationFailures(any())).thenReturn(Streamable.empty()); + when(repositories.findExtensionThreats(any())).thenReturn(Streamable.empty()); + when(repositories.findVersion(anyString(), anyString(), anyString(), anyString())).thenReturn(null); + when(storageUtil.getFileUrls(anyList(), anyString(), any(), any())).thenReturn(Map.of()); mockMvc.perform(get("/admin/scans") .param("namespace", "alp") @@ -147,25 +151,25 @@ void getScans_namespace_partial_match_is_applied() throws Exception { @Test void getScans_name_matches_extensionName_and_displayName_partial() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); var scanA = TestData.scan(1, "alpha-ns", "alpha-one", "1.0.0", "pub", ScanStatus.PASSED, LocalDateTime.of(2024, 12, 1, 10, 0)); scanA.setExtensionDisplayName("Zebra Toolkit"); var scanB = TestData.scan(2, "beta-ns", "beta-two", "1.0.0", "pub", ScanStatus.PASSED, LocalDateTime.of(2024, 12, 1, 10, 0)); scanB.setExtensionDisplayName("Something Else"); - Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + when(repositories.findValidationFailures(any())).thenReturn(Streamable.empty()); + when(repositories.findExtensionThreats(any())).thenReturn(Streamable.empty()); + when(storageUtil.getFileUrls(anyList(), anyString(), any(), any())).thenReturn(Map.of()); - Mockito.when(repositories.findVersion("1.0.0", "universal", "alpha-one", "alpha-ns")).thenReturn(TestData.version(10, "Zebra Toolkit")); - Mockito.when(repositories.findVersion("1.0.0", "universal", "beta-two", "beta-ns")).thenReturn(TestData.version(11, "Something Else")); + when(repositories.findVersion("1.0.0", "universal", "alpha-one", "alpha-ns")).thenReturn(TestData.version(10, "Zebra Toolkit")); + when(repositories.findVersion("1.0.0", "universal", "beta-two", "beta-ns")).thenReturn(TestData.version(11, "Something Else")); // First request: DB returns scanA which matches displayName "Toolkit" - Mockito.when(repositories.findScansFullyFiltered( - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any() + when(repositories.findScansFullyFiltered( + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), anyBoolean(), any() )).thenReturn(new PageImpl<>(List.of(scanA))); // Match by displayName partial (case-insensitive) @@ -177,10 +181,10 @@ void getScans_name_matches_extensionName_and_displayName_partial() throws Except .andExpect(jsonPath("$.scans[0].extensionName").value("alpha-one")); // Second request: DB returns scanB which matches extensionName "beta" - Mockito.when(repositories.findScansFullyFiltered( - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any() + when(repositories.findScansFullyFiltered( + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), anyBoolean(), any() )).thenReturn(new PageImpl<>(List.of(scanB))); // Match by extensionName partial @@ -194,22 +198,22 @@ void getScans_name_matches_extensionName_and_displayName_partial() throws Except @Test void getScans_status_supports_comma_separated_values() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); var scanPassed = TestData.scan(2, "ns", "ext-passed", "1.0.0", "pub", ScanStatus.PASSED, LocalDateTime.of(2024, 12, 1, 10, 0)); var scanErrored = TestData.scan(3, "ns", "ext-error", "1.0.0", "pub", ScanStatus.ERRORED, LocalDateTime.of(2024, 12, 1, 10, 0)); // DB returns only PASSED and ERRORED scans (filtered by status) - Mockito.when(repositories.findScansFullyFiltered( - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any() + when(repositories.findScansFullyFiltered( + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), anyBoolean(), any() )).thenReturn(new PageImpl<>(List.of(scanPassed, scanErrored))); - Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(repositories.findVersion(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString())).thenReturn(null); - Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + when(repositories.findValidationFailures(any())).thenReturn(Streamable.empty()); + when(repositories.findExtensionThreats(any())).thenReturn(Streamable.empty()); + when(repositories.findVersion(anyString(), anyString(), anyString(), anyString())).thenReturn(null); + when(storageUtil.getFileUrls(anyList(), anyString(), any(), any())).thenReturn(Map.of()); // explode=false behavior: status=PASSED,ERROR should be parsed into a list of two values. mockMvc.perform(get("/admin/scans") @@ -222,23 +226,23 @@ void getScans_status_supports_comma_separated_values() throws Exception { @Test void getScans_checkType_supports_comma_separated_values() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); var scanA = TestData.scan(1, "ns", "ext-a", "1.0.0", "pub", ScanStatus.REJECTED, LocalDateTime.of(2024, 12, 1, 10, 0)); // DB returns only scanA (filtered by checkType) - Mockito.when(repositories.findScansFullyFiltered( - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any() + when(repositories.findScansFullyFiltered( + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), anyBoolean(), any() )).thenReturn(new PageImpl<>(List.of(scanA))); - Mockito.when(repositories.findVersion(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString())).thenReturn(null); - Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + when(repositories.findVersion(anyString(), anyString(), anyString(), anyString())).thenReturn(null); + when(repositories.findExtensionThreats(any())).thenReturn(Streamable.empty()); + when(storageUtil.getFileUrls(anyList(), anyString(), any(), any())).thenReturn(Map.of()); // scanA has a validation failure with checkType NAME_SQUATTING - Mockito.when(repositories.findValidationFailures(Mockito.any())).thenAnswer(invocation -> { + when(repositories.findValidationFailures(any())).thenAnswer(invocation -> { var scan = (ExtensionScan) invocation.getArgument(0); if (scan.getId() == 1) { var failure = ExtensionValidationFailure.create("NAME_SQUATTING", "any-name", "reason"); @@ -261,8 +265,8 @@ void getScans_checkType_supports_comma_separated_values() throws Exception { @Test void getScanFilterOptions_returns_validationTypes() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - Mockito.when(repositories.findDistinctValidationFailureCheckTypes()).thenReturn(java.util.List.of("NAME_SQUATTING", "BLOCKLIST")); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(repositories.findDistinctValidationFailureCheckTypes()).thenReturn(java.util.List.of("NAME_SQUATTING", "BLOCKLIST")); mockMvc.perform(get("/admin/scans/filterOptions").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -273,8 +277,8 @@ void getScanFilterOptions_returns_validationTypes() throws Exception { @Test void getScans_rejects_unknown_sort_field() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - Mockito.when(repositories.findAllExtensionScans()).thenReturn(Streamable.empty()); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(repositories.findAllExtensionScans()).thenReturn(Streamable.empty()); mockMvc.perform(get("/admin/scans") .param("sortBy", "unknownField") @@ -286,14 +290,14 @@ void getScans_rejects_unknown_sort_field() throws Exception { @Test void getScanCounts_returns_status_counts_and_zero_decisions() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); - Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.STARTED)).thenReturn(1L); - Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.VALIDATING)).thenReturn(2L); - Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.SCANNING)).thenReturn(3L); - Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.PASSED)).thenReturn(4L); - Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.QUARANTINED)).thenReturn(5L); - Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.REJECTED)).thenReturn(6L); - Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.ERRORED)).thenReturn(7L); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(repositories.countExtensionScansByStatus(ScanStatus.STARTED)).thenReturn(1L); + when(repositories.countExtensionScansByStatus(ScanStatus.VALIDATING)).thenReturn(2L); + when(repositories.countExtensionScansByStatus(ScanStatus.SCANNING)).thenReturn(3L); + when(repositories.countExtensionScansByStatus(ScanStatus.PASSED)).thenReturn(4L); + when(repositories.countExtensionScansByStatus(ScanStatus.QUARANTINED)).thenReturn(5L); + when(repositories.countExtensionScansByStatus(ScanStatus.REJECTED)).thenReturn(6L); + when(repositories.countExtensionScansByStatus(ScanStatus.ERRORED)).thenReturn(7L); // Default behavior (no filters): uses the fast count-by-status repository calls. mockMvc.perform(get("/admin/scans/counts").accept(MediaType.APPLICATION_JSON)) @@ -314,21 +318,21 @@ void getScanCounts_returns_status_counts_and_zero_decisions() throws Exception { @Test void getScanCounts_supports_enforcement_filtering() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); // DB-level enforcement filtering: mock counts for each status with enforcement // When enforcement filter is applied, the code uses countScansForStatistics // First request: enforced=true -> returns 1 for REJECTED - Mockito.when(repositories.countScansForStatistics( - Mockito.eq(ScanStatus.REJECTED), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.eq(true) + when(repositories.countScansForStatistics( + eq(ScanStatus.REJECTED), any(), any(), any(), any(), eq(true) )).thenReturn(1L); - Mockito.when(repositories.countScansForStatistics( - Mockito.eq(ScanStatus.REJECTED), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.eq(false) + when(repositories.countScansForStatistics( + eq(ScanStatus.REJECTED), any(), any(), any(), any(), eq(false) )).thenReturn(1L); // Other statuses return 0 when enforcement filter is applied - Mockito.when(repositories.countScansForStatistics( - Mockito.argThat(s -> s != ScanStatus.REJECTED), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyBoolean() + when(repositories.countScansForStatistics( + argThat(s -> s != ScanStatus.REJECTED), any(), any(), any(), any(), anyBoolean() )).thenReturn(0L); mockMvc.perform(get("/admin/scans/counts") @@ -346,21 +350,21 @@ void getScanCounts_supports_enforcement_filtering() throws Exception { @Test void getScans_returns_displayName_from_scan_when_version_missing() throws Exception { - Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); var scan = TestData.scan(99, "ns", "ext", "0.0.1", "pub", ScanStatus.REJECTED, LocalDateTime.of(2024, 12, 4, 10, 0)); scan.setExtensionDisplayName("Manifest Display"); - Mockito.when(repositories.findScansFullyFiltered( - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any() + when(repositories.findScansFullyFiltered( + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), anyBoolean(), any() )).thenReturn(new PageImpl<>(List.of(scan))); - Mockito.when(repositories.findVersion("0.0.1", "universal", "ext", "ns")).thenReturn(null); - Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); - Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + when(repositories.findVersion("0.0.1", "universal", "ext", "ns")).thenReturn(null); + when(repositories.findValidationFailures(any())).thenReturn(Streamable.empty()); + when(repositories.findExtensionThreats(any())).thenReturn(Streamable.empty()); + when(storageUtil.getFileUrls(anyList(), anyString(), any(), any())).thenReturn(Map.of()); mockMvc.perform(get("/admin/scans").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -372,7 +376,7 @@ void getScans_returns_displayName_from_scan_when_version_missing() throws Except @Test void getScanCounts_requires_admin() throws Exception { - Mockito.when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); + when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); mockMvc.perform(get("/admin/scans/counts").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); @@ -380,12 +384,58 @@ void getScanCounts_requires_admin() throws Exception { @Test void getScans_requires_admin() throws Exception { - Mockito.when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); + when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); mockMvc.perform(get("/admin/scans").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); } + @Test + void retryFailedScannerJobs_returns200_andDelegatesToService() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + var scan = TestData.scan(5, "ns", "ext", "1.0.0", "pub", ScanStatus.ERRORED, LocalDateTime.of(2024, 12, 1, 10, 0)); + when(repositories.findExtensionScan(5L)).thenReturn(scan); + when(scanService.retryFailedJobs(scan)).thenReturn(scan); + when(repositories.findVersion(anyString(), anyString(), anyString(), anyString())).thenReturn(null); + when(repositories.findValidationFailures(any())).thenReturn(org.springframework.data.util.Streamable.empty()); + when(repositories.findExtensionThreats(any())).thenReturn(org.springframework.data.util.Streamable.empty()); + when(storageUtil.getFileUrls(anyList(), anyString(), any(), any())).thenReturn(Map.of()); + + mockMvc.perform(post("/admin/scans/5/jobs/retry").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + verify(scanService).retryFailedJobs(scan); + } + + @Test + void retryFailedScannerJobs_returns404_whenScanNotFound() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + when(repositories.findExtensionScan(99L)).thenReturn(null); + + mockMvc.perform(post("/admin/scans/99/jobs/retry").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void retryFailedScannerJobs_returns400_whenServiceRejectsRequest() throws Exception { + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + var scan = TestData.scan(3, "ns", "ext", "1.0.0", "pub", ScanStatus.SCANNING, LocalDateTime.of(2024, 12, 1, 10, 0)); + when(repositories.findExtensionScan(3L)).thenReturn(scan); + when(scanService.retryFailedJobs(scan)) + .thenThrow(new ErrorResultException("Cannot retry: scan is not terminal", HttpStatus.BAD_REQUEST)); + + mockMvc.perform(post("/admin/scans/3/jobs/retry").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void retryFailedScannerJobs_requires_admin() throws Exception { + when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); + + mockMvc.perform(post("/admin/scans/1/jobs/retry").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + private static class TestData { static ExtensionScan scan(long id, String namespace, String name, String version, String publisher, ScanStatus status, LocalDateTime startedAt) { diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceServiceTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceServiceTest.java new file mode 100644 index 000000000..fccd44932 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceServiceTest.java @@ -0,0 +1,91 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.entities.ScannerJob; +import org.eclipse.openvsx.repositories.FileDecisionRepository; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.repositories.ScanCheckResultRepository; +import org.eclipse.openvsx.repositories.ScannerJobRepository; +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 java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ExtensionScanPersistenceServiceTest { + + @Mock RepositoryService repositories; + @Mock ObjectMapper objectMapper; + @Mock FileDecisionRepository fileDecisionRepository; + @Mock ScannerJobRepository scannerJobRepository; + @Mock ScanCheckResultRepository scanCheckResultRepository; + @Mock ScannerRegistry scannerRegistry; + + private ExtensionScanPersistenceService svc; + + @BeforeEach + void setUp() { + svc = new ExtensionScanPersistenceService(repositories, objectMapper, fileDecisionRepository, scannerJobRepository, scanCheckResultRepository, scannerRegistry); + } + + @Test + void resetJobForRetry_resetsJobFieldsAndFlipsScanToScanning() { + var completedAt = LocalDateTime.of(2026, 1, 1, 12, 0); + var scan = new ExtensionScan(); + scan.setId(10L); + scan.setStatus(ScanStatus.ERRORED); + scan.setCompletedAt(completedAt); + scan.setErrorMessage("scanner timed out"); + + var job = new ScannerJob(); + job.setScanId("10"); + job.setScannerType("CLAMAV_REST"); + job.setExtensionVersionId(100L); + job.setStatus(ScannerJob.JobStatus.FAILED); + job.setErrorMessage("connection refused"); + job.setExternalJobId("ext-job-123"); + job.setPollAttempts(7); + job.setPollLeaseUntil(LocalDateTime.of(2026, 1, 1, 11, 0)); + job.setRecoveryInProgress(true); + + svc.resetJobForRetry(scan, job); + + // Job is ready to be picked up again + assertThat(job.getStatus()).isEqualTo(ScannerJob.JobStatus.QUEUED); + assertThat(job.getErrorMessage()).isNull(); + assertThat(job.getExternalJobId()).isNull(); + assertThat(job.getPollAttempts()).isEqualTo(0); + assertThat(job.getPollLeaseUntil()).isNull(); + assertThat(job.isRecoveryInProgress()).isFalse(); + assertThat(job.getUpdatedAt()).isNotNull(); + + // Scan is re-opened so the completion service can finalize it once the job succeeds + assertThat(scan.getStatus()).isEqualTo(ScanStatus.SCANNING); + assertThat(scan.getCompletedAt()).isNull(); + assertThat(scan.getErrorMessage()).isNull(); + + verify(scanCheckResultRepository).deleteByScannerJobId(job.getId()); + verify(scannerJobRepository).save(job); + verify(repositories).saveExtensionScan(scan); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanServiceTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanServiceTest.java new file mode 100644 index 000000000..f07f66fd0 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanServiceTest.java @@ -0,0 +1,200 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.entities.ScannerJob; +import org.eclipse.openvsx.repositories.ScannerJobRepository; +import org.eclipse.openvsx.util.ErrorResultException; +import org.jobrunr.scheduling.JobRequestScheduler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ExtensionScanServiceTest { + + @Mock + ExtensionScanConfig config; + @Mock + PublishCheckRunner checkRunner; + @Mock + ExtensionScanPersistenceService persistenceService; + @Mock + ScannerRegistry scannerRegistry; + @Mock + RemoteScanner remoteScanner; + @Mock + JobRequestScheduler jobScheduler; + @Mock + ScannerJobRepository scanJobRepository; + + private ExtensionScanService svc; + + @BeforeEach + void setUp() { + svc = new ExtensionScanService(config, checkRunner, persistenceService, scannerRegistry, jobScheduler, scanJobRepository); + } + + @Test + void retryFailedJobs_throwsBadRequest_whenScanIsNotTerminal() { + var scan = scanWithStatus(42L, ScanStatus.SCANNING); + + assertThatThrownBy(() -> svc.retryFailedJobs(scan)) + .isInstanceOf(ErrorResultException.class) + .satisfies(e -> assertThat(((ErrorResultException) e).getStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + + verifyNoInteractions(scanJobRepository); + } + + @Test + void retryFailedJobs_throwsNotFound_whenScanHasNoJobs() { + var scan = scanWithStatus(42L, ScanStatus.PASSED); + when(scanJobRepository.findByScanId("42")).thenReturn(List.of()); + + assertThatThrownBy(() -> svc.retryFailedJobs(scan)) + .isInstanceOf(ErrorResultException.class) + .satisfies(e -> assertThat(((ErrorResultException) e).getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND)); + + verifyNoInteractions(persistenceService, jobScheduler); + } + + @Test + void retryFailedJobs_enqueuesScannerInvocationWithJobFields() { + // Verifies the enqueued request carries the FAILED job's scannerType/extensionVersionId/scanId — not stale data + var scan = scanWithStatus(77L, ScanStatus.ERRORED); + var failedJob = job("77", ScannerJob.JobStatus.FAILED); + failedJob.setScannerType("CLAMAV_REST"); + failedJob.setExtensionVersionId(987L); + when(scanJobRepository.findByScanId("77")).thenReturn(List.of(failedJob)); + when(scannerRegistry.getScanner("CLAMAV_REST")).thenReturn(remoteScanner); + when(remoteScanner.getMaxConcurrency()).thenReturn(0); + + svc.retryFailedJobs(scan); + + var captor = ArgumentCaptor.forClass(ScannerInvocationRequest.class); + verify(jobScheduler).enqueue(captor.capture()); + var request = captor.getValue(); + assertThat(request.getScannerType()).isEqualTo("CLAMAV_REST"); + assertThat(request.getExtensionVersionId()).isEqualTo(987L); + assertThat(request.getScanId()).isEqualTo("77"); + } + + @Test + void retryFailedJobs_throwsBadRequest_whenNoJobsAreFailed() { + // COMPLETE and REMOVED jobs exist but are not eligible for retry + var scan = scanWithStatus(42L, ScanStatus.ERRORED); + when(scanJobRepository.findByScanId("42")) + .thenReturn(List + .of( + job("42", ScannerJob.JobStatus.COMPLETE), + job("42", ScannerJob.JobStatus.REMOVED))); + + assertThatThrownBy(() -> svc.retryFailedJobs(scan)) + .isInstanceOf(ErrorResultException.class) + .satisfies(e -> assertThat(((ErrorResultException) e).getStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + + verifyNoInteractions(persistenceService, jobScheduler); + } + + @Test + void retryFailedJobs_onlyRetriesFailedJobs_returnsUpdatedScan() { + var scan = scanWithStatus(10L, ScanStatus.ERRORED); + scan.setCompletedAt(java.time.LocalDateTime.of(2026, 1, 1, 12, 0)); + scan.setErrorMessage("previous failure"); + var failedJob1 = job("10", ScannerJob.JobStatus.FAILED); + var failedJob2 = job("10", ScannerJob.JobStatus.FAILED); + when(scanJobRepository.findByScanId("10")) + .thenReturn(List + .of(failedJob1, failedJob2, job("10", ScannerJob.JobStatus.COMPLETE))); + + doAnswer(invocation -> { + ExtensionScan s = invocation.getArgument(0); + s.setStatus(ScanStatus.SCANNING); + s.setCompletedAt(null); + s.setErrorMessage(null); + return null; + }).when(persistenceService).resetJobForRetry(eq(scan), any()); + + when(scannerRegistry.getScanner("CLAMAV_REST")).thenReturn(remoteScanner); + when(remoteScanner.getMaxConcurrency()).thenReturn(0); + + var result = svc.retryFailedJobs(scan); + + // The returned scan reflects the "now running" state the UI will display + assertThat(result).isSameAs(scan); + assertThat(result.getStatus()).isEqualTo(ScanStatus.SCANNING); + assertThat(result.getCompletedAt()).isNull(); + assertThat(result.getErrorMessage()).isNull(); + verify(persistenceService, times(2)).resetJobForRetry(eq(scan), any()); + verify(jobScheduler, times(2)).enqueue(any(ScannerInvocationRequest.class)); + } + + @Test + void retryFailedJob_throwsErrorResult_whenJobIsActive() { + var scan = scanWithStatus(1L, ScanStatus.SCANNING); + var queuedJob = job("1", ScannerJob.JobStatus.QUEUED); + + assertThatThrownBy(() -> svc.retryFailedJob(scan, queuedJob)) + .isInstanceOf(ErrorResultException.class); + + verifyNoInteractions(persistenceService, jobScheduler); + } + + @Test + void retryFailedJob_swallowsSchedulerException_afterPersistingReset() { + // If JobRunr fails to enqueue after the DB reset was committed, we should not surface the error + var scan = scanWithStatus(8L, ScanStatus.ERRORED); + var failedJob = job("8", ScannerJob.JobStatus.FAILED); + doThrow(new RuntimeException("JobRunr unavailable")).when(jobScheduler).enqueue(any(ScannerInvocationRequest.class)); + + when(scannerRegistry.getScanner("CLAMAV_REST")).thenReturn(remoteScanner); + when(remoteScanner.getMaxConcurrency()).thenReturn(0); + + assertThatCode(() -> svc.retryFailedJob(scan, failedJob)).doesNotThrowAnyException(); + + verify(persistenceService).resetJobForRetry(scan, failedJob); + verify(jobScheduler).enqueue(any(ScannerInvocationRequest.class)); + } + + private static ExtensionScan scanWithStatus(long id, ScanStatus status) { + var scan = new ExtensionScan(); + scan.setId(id); + scan.setStatus(status); + return scan; + } + + private static ScannerJob job(String scanId, ScannerJob.JobStatus status) { + var job = new ScannerJob(); + job.setScanId(scanId); + job.setScannerType("CLAMAV_REST"); + job.setExtensionVersionId(100L); + job.setStatus(status); + return job; + } +} diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index 812321c76..b0dc6f777 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -2,6 +2,12 @@ This change log covers only the frontend library (webui) of Open VSX. +## [unreleased] + +### Added + +- Add support to retry failed scanner jobs in the admin dashboard ([#1832](https://github.com/eclipse-openvsx/openvsx/pull/1832)) + ## [v0.20.3] (08/05/2026) ### Changed diff --git a/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx b/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx index ea90d5499..5beace84a 100644 --- a/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx +++ b/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx @@ -12,8 +12,9 @@ ********************************************************************************/ import { FC } from 'react'; -import { Box, Typography, Collapse, Chip, Link } from '@mui/material'; +import { Box, Typography, Collapse, Chip, Link, Button, CircularProgress } from '@mui/material'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import ReplayIcon from '@mui/icons-material/Replay'; import { useTheme, Theme } from '@mui/material/styles'; import { ScanResult, Threat, ValidationFailure, CheckResult } from '../../../context/scan-admin'; import { ScanDetailCard } from './scan-detail-card'; @@ -22,6 +23,9 @@ import { formatDateTime } from '../common'; interface ScanCardExpandedContentProps { scan: ScanResult; expanded: boolean; + canRetryFailedScannerJobs?: boolean; + isRetryingFailedScannerJobs?: boolean; + onRetryFailedScannerJobs?: () => void; onCollapseComplete?: () => void; } @@ -227,7 +231,14 @@ const CheckResultItem: FC = ({ checkResult }) => { * The expanded content section showing threats, validation failures, and check results. * Each item's enforcedFlag controls its individual striping effect. */ -export const ScanCardExpandedContent: FC = ({ scan, expanded, onCollapseComplete }) => { +export const ScanCardExpandedContent: FC = ({ + scan, + expanded, + canRetryFailedScannerJobs, + isRetryingFailedScannerJobs, + onRetryFailedScannerJobs, + onCollapseComplete, +}) => { const theme = useTheme(); const hasThreats = scan.threats.length > 0; const hasValidationFailures = scan.validationFailures.length > 0; @@ -263,9 +274,31 @@ export const ScanCardExpandedContent: FC = ({ scan {/* Check Results - What scans/checks were run */} {hasCheckResults && ( - - Checks Executed - + + + Checks Executed + + {canRetryFailedScannerJobs && ( + + )} + {scan.checkResults.map((result, index) => ( diff --git a/webui/src/components/scan-admin/scan-card/scan-card.tsx b/webui/src/components/scan-admin/scan-card/scan-card.tsx index 5192afded..ed4683c45 100644 --- a/webui/src/components/scan-admin/scan-card/scan-card.tsx +++ b/webui/src/components/scan-admin/scan-card/scan-card.tsx @@ -14,7 +14,7 @@ import { FunctionComponent, useState, useEffect } from 'react'; import { Card, CardContent, Box } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import { ScanResult } from '../../../context/scan-admin'; +import { ScanResult, useScanContext } from '../../../context/scan-admin'; import { ScanCardHeader } from './scan-card-header'; import { ScanCardContent } from './scan-card-content'; @@ -25,6 +25,8 @@ import { ICON_SIZE, shouldShowStriped, getStatusBarColor, + hasFailedScannerJobs, + isRunning, } from './utils'; interface ScanCardProps { @@ -52,7 +54,9 @@ export const ScanCard: FunctionComponent = ({ checked, }) => { const theme = useTheme(); + const { actions } = useScanContext(); const [collapseComplete, setCollapseComplete] = useState(true); + const [isRetryingFailedScannerJobs, setIsRetryingFailedScannerJobs] = useState(false); const { expanded, handleExpandClick, @@ -61,6 +65,16 @@ export const ScanCard: FunctionComponent = ({ liveDuration, cardRef, } = useScanCardState(scan); + const canRetryFailedScannerJobs = !isRunning(scan.status) && hasFailedScannerJobs(scan); + + const handleRetryFailedScannerJobs = async () => { + setIsRetryingFailedScannerJobs(true); + try { + await actions.retryFailedScannerJobs(scan.id); + } finally { + setIsRetryingFailedScannerJobs(false); + } + }; // Reset collapseComplete when expanding useEffect(() => { @@ -144,6 +158,9 @@ export const ScanCard: FunctionComponent = ({ setCollapseComplete(true)} /> )} diff --git a/webui/src/components/scan-admin/scan-card/utils.ts b/webui/src/components/scan-admin/scan-card/utils.ts index b458b1685..163d3767b 100644 --- a/webui/src/components/scan-admin/scan-card/utils.ts +++ b/webui/src/components/scan-admin/scan-card/utils.ts @@ -37,6 +37,12 @@ export const isRunning = (status: ScanResult['status']): boolean => { return status === 'STARTED' || status === 'VALIDATING' || status === 'SCANNING'; }; +export const hasFailedScannerJobs = (scan: ScanResult): boolean => { + return scan.checkResults?.some( + checkResult => checkResult.category === 'SCANNER_JOB' && checkResult.result === 'ERROR' + ) ?? false; +}; + /** * Determines whether the scan card badge/strip should show the striped effect. * Only shows striping when the hypothetical status would be DIFFERENT from the current status. diff --git a/webui/src/context/scan-admin/index.ts b/webui/src/context/scan-admin/index.ts index ba169914f..2986c6bd6 100644 --- a/webui/src/context/scan-admin/index.ts +++ b/webui/src/context/scan-admin/index.ts @@ -37,7 +37,7 @@ export { } from './scan-api-effects'; // API Actions (for testing or advanced use cases) -export { useConfirmAction, useFileAction } from './scan-api-actions'; +export { useConfirmAction, useFileAction, useRetryFailedScannerJobsAction } from './scan-api-actions'; // Actions Factory (for testing or advanced use cases) export { useScanActions } from './scan-actions'; diff --git a/webui/src/context/scan-admin/scan-actions.ts b/webui/src/context/scan-admin/scan-actions.ts index fb55a7f12..96df5a14a 100644 --- a/webui/src/context/scan-admin/scan-actions.ts +++ b/webui/src/context/scan-admin/scan-actions.ts @@ -26,7 +26,8 @@ import { ScanActions } from './scan-context-types'; export const useScanActions = ( dispatch: Dispatch, executeConfirmAction: () => void, - executeFileAction: () => void + executeFileAction: () => void, + retryFailedScannerJobs: (scanId: string) => Promise ): ScanActions => { return useMemo(() => ({ // Tab @@ -84,5 +85,8 @@ export const useScanActions = ( openFileDialog: (action: FileActionType) => dispatch({ type: 'OPEN_FILE_DIALOG', payload: action }), closeFileDialog: () => dispatch({ type: 'CLOSE_FILE_DIALOG' }), executeFileAction, - }), [dispatch, executeConfirmAction, executeFileAction]); + + // Scan actions + retryFailedScannerJobs, + }), [dispatch, executeConfirmAction, executeFileAction, retryFailedScannerJobs]); }; diff --git a/webui/src/context/scan-admin/scan-api-actions.ts b/webui/src/context/scan-admin/scan-api-actions.ts index 4f30542dd..3353bba01 100644 --- a/webui/src/context/scan-admin/scan-api-actions.ts +++ b/webui/src/context/scan-admin/scan-api-actions.ts @@ -72,6 +72,33 @@ export const useConfirmAction = ( }, [service, state.quarantinedChecked, state.confirmAction, dispatch, handleErrorRef]); }; +// ============================================================================ +// Retry Scanner Jobs Action Hook +// ============================================================================ + +/** + * Hook that returns the async action handler for retrying all failed scanner jobs + * of a single terminal scan. + */ +export const useRetryFailedScannerJobsAction = ( + service: any, + dispatch: Dispatch, + handleErrorRef: MutableRefObject<(error: any) => void> +) => { + return useCallback(async (scanId: string): Promise => { + const abortController = new AbortController(); + + try { + await service.admin.retryFailedScannerJobs(abortController, scanId); + dispatch({ type: 'TRIGGER_REFRESH' }); + } catch (err: any) { + if (!abortController.signal.aborted) { + handleErrorRef.current(err); + } + } + }, [service, dispatch, handleErrorRef]); +}; + // ============================================================================ // File Action Hook // ============================================================================ diff --git a/webui/src/context/scan-admin/scan-context-types.ts b/webui/src/context/scan-admin/scan-context-types.ts index a628b4383..7f635d067 100644 --- a/webui/src/context/scan-admin/scan-context-types.ts +++ b/webui/src/context/scan-admin/scan-context-types.ts @@ -80,6 +80,9 @@ export interface ScanActions { openFileDialog: (action: FileActionType) => void; closeFileDialog: () => void; executeFileAction: () => void; + + // Scan actions + retryFailedScannerJobs: (scanId: string) => Promise; } export interface DerivedData { diff --git a/webui/src/context/scan-admin/scan-context.tsx b/webui/src/context/scan-admin/scan-context.tsx index f205b8dc1..ddc50ed32 100644 --- a/webui/src/context/scan-admin/scan-context.tsx +++ b/webui/src/context/scan-admin/scan-context.tsx @@ -23,7 +23,7 @@ import { useFileCountsEffect, useAutoRefreshEffect, } from './scan-api-effects'; -import { useConfirmAction, useFileAction } from './scan-api-actions'; +import { useConfirmAction, useFileAction, useRetryFailedScannerJobsAction } from './scan-api-actions'; import { useScanActions } from './scan-actions'; // ============================================================================ @@ -61,12 +61,13 @@ export const ScanProvider: FC = ({ children, service, handleE const executeConfirmAction = useConfirmAction(service, state, dispatch, handleErrorRef); const executeFileAction = useFileAction(service, state, dispatch, handleErrorRef); + const retryFailedScannerJobs = useRetryFailedScannerJobsAction(service, dispatch, handleErrorRef); // ======================================================================== // Actions // ======================================================================== - const actions = useScanActions(dispatch, executeConfirmAction, executeFileAction); + const actions = useScanActions(dispatch, executeConfirmAction, executeFileAction, retryFailedScannerJobs); // ======================================================================== // Derived Data (memoized) diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 51a03b88f..a01e3191f 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -511,6 +511,7 @@ export interface AdminService { revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise> getAllScans(abortController: AbortController, params?: { size?: number; offset?: number; status?: string | string[]; publisher?: string; namespace?: string; name?: string; validationType?: string[]; threatScannerName?: string[]; dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all' }): Promise> getScan(abortController: AbortController, scanId: string): Promise> + retryFailedScannerJobs(abortController: AbortController, scanId: string): Promise> getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> getScanFilterOptions(abortController: AbortController): Promise> // Files API @@ -724,6 +725,23 @@ export class AdminServiceImpl implements AdminService { }); } + async retryFailedScannerJobs(abortController: AbortController, scanId: string): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = {}; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + + return sendRequest({ + abortController, + method: 'POST', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', scanId, 'jobs', 'retry']), + headers, + }); + } + async getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) {