From 8bb637bd54f54f69b31201266b1bc89f26eec482 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Sun, 19 Jul 2026 10:56:00 +0300 Subject: [PATCH 1/2] fix: add date completed on failed status --- .../specs/repository-reports-table/spec.md | 12 +++++ .../repository/ReportRepositoryService.java | 44 ++++++++++++++++--- .../ReportRepositoryServiceTest.java | 44 +++++++++++++++++++ .../rest/ReportEndpointFailedRestTest.java | 27 +++++++++++- 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/openspec/specs/repository-reports-table/spec.md b/openspec/specs/repository-reports-table/spec.md index d4025c41..a5b94c13 100644 --- a/openspec/specs/repository-reports-table/spec.md +++ b/openspec/specs/repository-reports-table/spec.md @@ -29,6 +29,8 @@ The repository reports table SHALL display columns: **ID** (first column, width The ID column SHALL display `report.id` as a link to the report page (component route: `/reports/component/{cveId}/{report.id}`; product route: `/reports/product/{productId}/{cveId}/{report.id}`). The **Date Requested** column SHALL display `metadata.submitted_at` when present, in the format "DD Month YYYY, HH:MM:SS AM/PM"; when `metadata.submitted_at` is missing, the cell SHALL display "-". The **Date Completed** column SHALL display `report.completedAt` in the same format. All date fields SHALL use the format "DD Month YYYY, HH:MM:SS AM/PM" (e.g., "07 July 2025, 10:14:02 PM"). +When a report's analysis state is **failed** or **expired**, `report.completedAt` SHALL be populated with the time the analysis reached that terminal failure (same completion-time semantics used for SBOM product Date Completed), so the **Date Completed** cell is not empty. In-progress states (**pending**, **queued**, **sent**) MAY leave Date Completed empty. + The table SHALL display a single **Finding** column (no separate "Analysis state" or "ExploitIQ Status" column). The Finding cell SHALL show, per row: if the report's analysis state is **completed**, the ExploitIQ Status (Vulnerable, Not vulnerable, or Uncertain) from the vulnerability justification; if the report's analysis state is **pending**, **queued**, or **sent**, "In progress" using the shared InProgressStatus component (grey outline label, InProgressIcon); if the report's analysis state is **expired** or **failed**, "Failed" using the shared FailedStatus component (grey filled label, ExclamationCircleIcon). Styling SHALL match the Finding column in the reports table for in-progress and failed states. #### Scenario: Repository reports table columns @@ -40,6 +42,16 @@ The table SHALL display a single **Finding** column (no separate "Analysis state - **THEN** the Finding cell shows: "Vulnerable", "Not vulnerable", or "Uncertain" (with same label colors as reports table) when the report state is completed and vulnerability justification is available; "In progress" (grey outline, InProgressIcon) when the report state is pending, queued, or sent; "Failed" (grey filled, ExclamationCircleIcon) when the report state is expired or failed - **AND** In progress and Failed use the shared InProgressStatus and FailedStatus components so styling matches the reports table Finding column +#### Scenario: Date Completed populated for failed single-repository reports +- **WHEN** a user views **`/reports/single-repositories`** +- **AND** a row has analysis state **failed** or **expired** (Finding shows **Failed**) +- **THEN** the **Date Completed** column displays a formatted timestamp from `report.completedAt` +- **AND** the cell is not empty solely because the analysis failed + +#### Scenario: Date Completed still empty while in progress +- **WHEN** a user views a repository reports row with analysis state **pending**, **queued**, or **sent** +- **THEN** the **Date Completed** column MAY be empty + ### Requirement: Finding filter and toolbar The repository reports table toolbar SHALL provide a single **Finding** filter (not separate Analysis state and ExploitIQ Status filters); **RPM** **`/reports/rpm`** layouts SHALL NOT expose **`git_repo`** / Repository Name filter controls (see **RPM Reports tab table variant** above). The Finding filter SHALL allow selecting exactly one finding value (e.g. Vulnerable, Not vulnerable, Uncertain, In progress, Failed), or none. When the user selects a Finding value, the table SHALL pass the corresponding backend parameter(s) to the reports API: "In progress" maps to status values for pending, queued, sent; "Failed" maps to status values for expired, failed; "Vulnerable", "Not vulnerable", and "Uncertain" map to exploitIqStatus (or equivalent API parameter) so that the backend returns only rows matching the selected finding. When no Finding value is selected, the table SHALL not apply a finding filter (no status/exploitIqStatus restriction from this filter). diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryService.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryService.java index b67107e8..075458e1 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryService.java @@ -75,7 +75,7 @@ public class ReportRepositoryService { private static final Collection METADATA_DATES = List.of(SUBMITTED_AT, SENT_AT); private static final String COLLECTION = "reports"; private static final Map STATUS_FILTERS = Map.of( - "completed", Filters.ne("input.scan.completed_at", null), + "completed", Filters.and(Filters.ne("input.scan.completed_at", null), Filters.eq("error", null)), "sent", Filters.and(Filters.ne("metadata." + SENT_AT, null), Filters.eq("error", null), Filters.eq("input.scan.completed_at", null)), @@ -244,9 +244,16 @@ public Report toReport(Document doc) { String submittedAt = Objects.nonNull(metadata) ? metadata.get(SUBMITTED_AT) : null; String scanId = scan.getString(RepositoryConstants.SCAN_ID); + // Only resolve a completion time for finished analyses. getReportCompletionTime falls back to + // submitted_at for incomplete docs (product aggregation), which must not fill Date Completed + // for in-progress single-repository rows. + String completedAt = scan.getString("completed_at"); + if (Objects.isNull(completedAt) || completedAt.isBlank()) { + completedAt = doc.containsKey("error") ? getReportCompletionTime(doc) : null; + } return new Report(id, scanId, scan.getString("started_at"), - scan.getString("completed_at"), + completedAt, image.getString("name"), image.getString("tag"), getStatus(doc, metadata), @@ -341,11 +348,34 @@ public List updateWithOutput(List ids, JsonNode report) } public void updateWithError(String id, String errorType, String errorMessage) { - String productId = getProductId(id); + ObjectId objectId = new ObjectId(id); + Document doc = getCollection().find(Filters.eq(RepositoryConstants.ID_KEY, objectId)).first(); + String productId = null; + boolean setCompletedAt = true; + if (Objects.nonNull(doc)) { + var metadata = doc.get("metadata", Document.class); + if (Objects.nonNull(metadata)) { + productId = metadata.getString(PRODUCT_ID); + } + var input = doc.get("input", Document.class); + if (Objects.nonNull(input)) { + var scan = input.get("scan", Document.class); + if (Objects.nonNull(scan)) { + String existingCompletedAt = scan.getString("completed_at"); + if (Objects.nonNull(existingCompletedAt) && !existingCompletedAt.isBlank()) { + setCompletedAt = false; + } + } + } + } + String at = Instant.now().toString(); var error = new Document("type", errorType) .append("message", errorMessage) - .append("at", Instant.now().toString()); - getCollection().updateOne(new Document(RepositoryConstants.ID_KEY, new ObjectId(id)), Updates.set("error", error)); + .append("at", at); + Bson update = setCompletedAt + ? Updates.combine(Updates.set("error", error), Updates.set("input.scan.completed_at", at)) + : Updates.set("error", error); + getCollection().updateOne(Filters.eq(RepositoryConstants.ID_KEY, objectId), update); if (Objects.nonNull(productId)) { checkAndStoreProductCompletion(productId); } @@ -397,7 +427,9 @@ public void setAsRetried(String id, String byUser) { Updates.combine( Updates.set("metadata." + SUBMITTED_AT, Instant.now()), Updates.set("metadata.user", byUser), - Updates.unset("error"))); + Updates.unset("error"), + // Clear completion time from a prior failed/expired outcome so retry is not treated as completed. + Updates.unset("input.scan.completed_at"))); reportSseBroadcaster.publishCatalogChanged(); } diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryServiceTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryServiceTest.java index bf107c65..4a16a470 100644 --- a/src/test/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryServiceTest.java +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryServiceTest.java @@ -7,11 +7,15 @@ import java.time.Instant; import java.util.Date; +import java.util.List; import org.bson.Document; +import org.bson.types.ObjectId; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import com.redhat.ecosystemappeng.exploitiq.model.Report; + /** Unit coverage for RPM package substring filter literals and report completion timestamps. */ class ReportRepositoryServiceTest { @@ -74,6 +78,46 @@ void getReportCompletionTime_failedReportWithoutTimestampsDoesNotFallBackToSubmi Assertions.assertNull(repository.getReportCompletionTime(doc)); } + @Test + void toReport_failedReportCompletedAtPrefersErrorAtWhenScanCompletedAtMissing() { + Document scan = new Document("id", "failed-scan-1") + .append("started_at", "2026-04-02T11:00:00.000000") + .append("vulns", List.of(new Document("vuln_id", "CVE-2026-1"))); + Document image = new Document("name", "https://github.com/example/repo").append("tag", "main"); + Document error = new Document("type", "failed") + .append("message", "analysis failed") + .append("at", "2026-04-02T12:00:00Z"); + Document metadata = new Document("submitted_at", Date.from(Instant.parse("2026-04-02T10:00:00Z"))) + .append("sent_at", Date.from(Instant.parse("2026-04-02T10:01:00Z"))); + Document doc = new Document("_id", new ObjectId()) + .append("error", error) + .append("input", new Document("scan", scan).append("image", image)) + .append("metadata", metadata); + + Report report = repository.toReport(doc); + + Assertions.assertEquals("2026-04-02T12:00:00Z", report.completedAt()); + Assertions.assertEquals("failed", report.state()); + } + + @Test + void toReport_inProgressReportCompletedAtIsNullEvenWhenSubmittedAtPresent() { + Document scan = new Document("id", "in-progress-scan-1") + .append("started_at", "2026-04-02T11:00:00.000000") + .append("vulns", List.of(new Document("vuln_id", "CVE-2026-1"))); + Document image = new Document("name", "https://github.com/example/repo").append("tag", "main"); + Document metadata = new Document("submitted_at", Date.from(Instant.parse("2026-04-02T10:00:00Z"))) + .append("sent_at", Date.from(Instant.parse("2026-04-02T10:01:00Z"))); + Document doc = new Document("_id", new ObjectId()) + .append("input", new Document("scan", scan).append("image", image)) + .append("metadata", metadata); + + Report report = repository.toReport(doc); + + Assertions.assertNull(report.completedAt()); + Assertions.assertEquals("sent", report.state()); + } + private static Document failedReportDocument( String errorAt, String startedAt, diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointFailedRestTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointFailedRestTest.java index 935914d3..b970d9de 100644 --- a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointFailedRestTest.java +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointFailedRestTest.java @@ -4,12 +4,14 @@ import java.util.Map; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.quarkus.test.junit.QuarkusTest; import io.restassured.RestAssured; import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; /** * {@code POST /api/v1/reports/failed} HTTP tests ({@link io.quarkus.test.junit.QuarkusTest}). @@ -28,7 +30,7 @@ void restAssuredBase() { private static final String NON_EXISTENT_SCAN_ID = "non-existent-scan-id-12345"; @Test - void testPostFailed_returns202AndScanIdInBody() { + void testPostFailed_returns202AndSetsCompletedAt() { Map body = Map.of( "scanId", ANONYMOUS_OWNED_SCAN_ID, "errorType", "processing-error", @@ -43,6 +45,29 @@ void testPostFailed_returns202AndScanIdInBody() { .then() .statusCode(202) .body(equalTo(ANONYMOUS_OWNED_SCAN_ID)); + + ValidatableResponse byScanId = RestAssured.given() + .when() + .get("/api/v1/reports/by-scan-id/" + ANONYMOUS_OWNED_SCAN_ID) + .then() + .statusCode(200) + .body("status", equalTo("failed")) + .body("report.error.at", notNullValue()) + .body("report.input.scan.completed_at", notNullValue()); + + String errorAt = byScanId.extract().path("report.error.at"); + String completedAt = byScanId.extract().path("report.input.scan.completed_at"); + Assertions.assertEquals(errorAt, completedAt, + "failed reports must persist scan.completed_at equal to error.at"); + + RestAssured.given() + .queryParam("reportId", ANONYMOUS_OWNED_SCAN_ID) + .when() + .get("/api/v1/reports") + .then() + .statusCode(200) + .body("[0].state", equalTo("failed")) + .body("[0].completedAt", equalTo(completedAt)); } @Test From fc5f0fff099b8b7384867f57f741927b96ffe272 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Sun, 19 Jul 2026 13:33:57 +0300 Subject: [PATCH 2/2] fix: use incomplete scan fixture for failed completedAt assertion --- .../rest/ReportEndpointFailedRestTest.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointFailedRestTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointFailedRestTest.java index b970d9de..f3dc444f 100644 --- a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointFailedRestTest.java +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointFailedRestTest.java @@ -24,15 +24,18 @@ void restAssuredBase() { RestApiTestFixture.configureRestAssuredIfExternal(); } - /** Devservices {@code rpm.json}; stable anonymous owner across full test suite. */ - private static final String ANONYMOUS_OWNED_SCAN_ID = "ea2c8e14-ba86-4cc1-9dee-5deca984be81"; + /** + * Devservices {@code test-scan-no-output-info.json}: anonymous owner and null + * {@code completed_at}, so POST /failed must set completed_at to error.at. + */ + private static final String ANONYMOUS_OWNED_INCOMPLETE_SCAN_ID = "test-scan-no-output-info"; private static final String FOREIGN_OWNED_SCAN_ID = "test-single-repo-1"; private static final String NON_EXISTENT_SCAN_ID = "non-existent-scan-id-12345"; @Test void testPostFailed_returns202AndSetsCompletedAt() { Map body = Map.of( - "scanId", ANONYMOUS_OWNED_SCAN_ID, + "scanId", ANONYMOUS_OWNED_INCOMPLETE_SCAN_ID, "errorType", "processing-error", "errorMessage", "Test failure from ReportEndpointFailedTest" ); @@ -44,11 +47,11 @@ void testPostFailed_returns202AndSetsCompletedAt() { .post("/api/v1/reports/failed") .then() .statusCode(202) - .body(equalTo(ANONYMOUS_OWNED_SCAN_ID)); + .body(equalTo(ANONYMOUS_OWNED_INCOMPLETE_SCAN_ID)); ValidatableResponse byScanId = RestAssured.given() .when() - .get("/api/v1/reports/by-scan-id/" + ANONYMOUS_OWNED_SCAN_ID) + .get("/api/v1/reports/by-scan-id/" + ANONYMOUS_OWNED_INCOMPLETE_SCAN_ID) .then() .statusCode(200) .body("status", equalTo("failed")) @@ -61,7 +64,7 @@ void testPostFailed_returns202AndSetsCompletedAt() { "failed reports must persist scan.completed_at equal to error.at"); RestAssured.given() - .queryParam("reportId", ANONYMOUS_OWNED_SCAN_ID) + .queryParam("reportId", ANONYMOUS_OWNED_INCOMPLETE_SCAN_ID) .when() .get("/api/v1/reports") .then()