Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions openspec/specs/repository-reports-table/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public class ReportRepositoryService {
private static final Collection<String> METADATA_DATES = List.of(SUBMITTED_AT, SENT_AT);
private static final String COLLECTION = "reports";
private static final Map<String, Bson> 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)),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -341,11 +348,34 @@ public List<String> updateWithOutput(List<String> 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);
}
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}).
Expand All @@ -22,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_returns202AndScanIdInBody() {
void testPostFailed_returns202AndSetsCompletedAt() {
Map<String, String> body = Map.of(
"scanId", ANONYMOUS_OWNED_SCAN_ID,
"scanId", ANONYMOUS_OWNED_INCOMPLETE_SCAN_ID,
"errorType", "processing-error",
"errorMessage", "Test failure from ReportEndpointFailedTest"
);
Expand All @@ -42,7 +47,30 @@ void testPostFailed_returns202AndScanIdInBody() {
.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_INCOMPLETE_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_INCOMPLETE_SCAN_ID)
.when()
.get("/api/v1/reports")
.then()
.statusCode(200)
.body("[0].state", equalTo("failed"))
.body("[0].completedAt", equalTo(completedAt));
}

@Test
Expand Down