diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/model/ReceiveReportOutcome.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/model/ReceiveReportOutcome.java new file mode 100644 index 00000000..f3f9397a --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/model/ReceiveReportOutcome.java @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.redhat.ecosystemappeng.exploitiq.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Result of {@link com.redhat.ecosystemappeng.exploitiq.service.ReportService#receive(String)}. + * + * @param requestId identifies the matched or created report + * @param applied {@code false} when an existing report was in terminal failure and the callback was not applied + */ +@RegisterForReflection +public record ReceiveReportOutcome(ReportRequestId requestId, boolean applied) { + +} 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 cd818d31..202526c4 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/repository/ReportRepositoryService.java @@ -289,11 +289,40 @@ public String getStatus(Document doc, Map metadata) { return "unknown"; } - public void updateWithOutput(List ids, JsonNode report) + /** + * Returns true when the report document has an {@code error} field (expired, failed, etc.). + * Late agent callbacks must not overwrite these terminal failure states. + */ + public boolean hasTerminalError(String id) { + Document doc = getCollection().find(Filters.eq(RepositoryConstants.ID_KEY, new ObjectId(id))).first(); + return Objects.nonNull(doc) && doc.containsKey("error"); + } + + /** + * Applies agent output to the given reports. Reports already in a terminal failure state are + * skipped so late callbacks cannot resurrect timed-out or failed analyses. + * + * @return ids that were updated + */ + public List updateWithOutput(List ids, JsonNode report) throws JsonMappingException, JsonProcessingException { - - Set productIds = getProductId(ids); - + + List updatableIds = new ArrayList<>(); + for (String id : ids) { + if (hasTerminalError(id)) { + LOGGER.warnf( + "Ignoring late analysis callback for report %s: report is in terminal failure state", + id); + } else { + updatableIds.add(id); + } + } + if (updatableIds.isEmpty()) { + return List.of(); + } + + Set productIds = getProductId(updatableIds); + Document outputDoc = objectMapper.readValue(report.get("output").toPrettyString(), Document.class); var scan = report.get("input").get("scan").toPrettyString(); var info = report.get("info").toPrettyString(); @@ -301,13 +330,14 @@ public void updateWithOutput(List ids, JsonNode report) Updates.set("info", Document.parse(info)), Updates.set("output", outputDoc), Updates.unset("error")); - var bulk = ids.stream() + var bulk = updatableIds.stream() .map(id -> new UpdateOneModel(Filters.eq(RepositoryConstants.ID_KEY, new ObjectId(id)), updates)) .toList(); getCollection().bulkWrite(bulk); - + productIds.forEach(this::checkAndStoreProductCompletion); reportSseBroadcaster.publishCatalogChanged(); + return updatableIds; } public void updateWithError(String id, String errorType, String errorMessage) { diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpoint.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpoint.java index 58962637..eb03d463 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpoint.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpoint.java @@ -319,7 +319,14 @@ public Response retry( @APIResponses({ @APIResponse( responseCode = "202", - description = "Report received", + description = "Report received and stored", + content = @Content( + schema = @Schema(implementation = ReportRequestId.class) + ) + ), + @APIResponse( + responseCode = "409", + description = "Report already in terminal failure state; callback was not applied", content = @Content( schema = @Schema(implementation = ReportRequestId.class) ) @@ -353,8 +360,13 @@ public Response receive( """ ) String report) { - var reqId = reportService.receive(report); - LOGGER.debugf("Received report { id: %s | report_id: %s }", reqId.id(), reqId.reportId()); + var outcome = reportService.receive(report); + var reqId = outcome.requestId(); + LOGGER.debugf("Received report { id: %s | report_id: %s | applied: %s }", + reqId.id(), reqId.reportId(), outcome.applied()); + if (!outcome.applied()) { + return Response.status(Status.CONFLICT).entity(reqId).build(); + } return Response.accepted(reqId).build(); } diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/ReportService.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/ReportService.java index f8164715..16163110 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/ReportService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/ReportService.java @@ -53,6 +53,7 @@ import com.redhat.ecosystemappeng.exploitiq.model.ProductSummary; import com.redhat.ecosystemappeng.exploitiq.model.Report; import com.redhat.ecosystemappeng.exploitiq.model.ReportData; +import com.redhat.ecosystemappeng.exploitiq.model.ReceiveReportOutcome; import com.redhat.ecosystemappeng.exploitiq.model.ReportReceivedEvent; import com.redhat.ecosystemappeng.exploitiq.model.ReportRequest; import com.redhat.ecosystemappeng.exploitiq.model.ReportRequestId; @@ -298,9 +299,10 @@ public boolean retry(String id) throws JsonProcessingException { return true; } - public ReportRequestId receive(String report) { + public ReceiveReportOutcome receive(String report) { String scanId = null; String id = null; + boolean applied = true; try { var reportJson = objectMapper.readTree(report); var scan = reportJson.get("input").get("scan"); @@ -324,7 +326,13 @@ public ReportRequestId receive(String report) { id = created.id(); } else { LOGGER.infof("Complete existing report %s", scanId); - repository.updateWithOutput(existing, reportJson); + var updated = repository.updateWithOutput(existing, reportJson); + if (updated.isEmpty()) { + applied = false; + existing = List.of(); + } else { + existing = updated; + } } for (String existingId : existing) { @@ -341,7 +349,7 @@ public ReportRequestId receive(String report) { LOGGER.warn("Unable to emit error event", e); } } - return new ReportRequestId(id, scanId); + return new ReceiveReportOutcome(new ReportRequestId(id, scanId), applied); } /** diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointReceiveRestTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointReceiveRestTest.java new file mode 100644 index 00000000..ee41b557 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/ReportEndpointReceiveRestTest.java @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.redhat.ecosystemappeng.exploitiq.rest; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.redhat.ecosystemappeng.exploitiq.repository.ReportRepositoryService; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import jakarta.inject.Inject; + +/** + * {@code POST /api/v1/reports} receive-callback tests ({@link io.quarkus.test.junit.QuarkusTest}). + */ +@QuarkusTest +class ReportEndpointReceiveRestTest { + + private static final String SCAN_ID = "test-scan-no-output-info"; + private static final String REPORT_MONGO_ID = "507f1f77bcf86cd799439098"; + private static final String CVE_ID = "CVE-2024-88888"; + + @Inject + ReportRepositoryService repository; + + @BeforeEach + void restAssuredBase() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void receive_returns409WhenReportAlreadyFailed() { + repository.updateWithError(REPORT_MONGO_ID, "expired", "timeout after 10 seconds"); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(callbackPayload()) + .when() + .post("/api/v1/reports") + .then() + .statusCode(409) + .body("reportId", equalTo(SCAN_ID)); + + RestAssured.given() + .when() + .get("/api/v1/reports/by-scan-id/" + SCAN_ID) + .then() + .statusCode(200) + .body("status", equalTo("expired")) + .body("report.error.type", equalTo("expired")); + } + + @Test + void receive_returns202WhenReportIsActive() { + repository.setAsRetried(REPORT_MONGO_ID, "test@example.com"); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(callbackPayload()) + .when() + .post("/api/v1/reports") + .then() + .statusCode(202) + .body("reportId", equalTo(SCAN_ID)); + + RestAssured.given() + .when() + .get("/api/v1/reports/by-scan-id/" + SCAN_ID) + .then() + .statusCode(200) + .body("status", equalTo("completed")) + .body("report.error", nullValue()); + } + + private static String callbackPayload() { + return """ + { + "input": { + "scan": { + "id": "%s", + "started_at": "2025-01-15T10:00:00.000000", + "completed_at": "2026-01-26T10:05:00.000000", + "vulns": [{ "vuln_id": "%s" }] + }, + "image": { + "name": "no-output-image", + "tag": "v1.0.0" + } + }, + "output": { + "analysis": [{ + "vuln_id": "%s", + "justification": { + "status": "FALSE", + "label": "not_vulnerable" + } + }], + "vex": null + }, + "info": {} + } + """.formatted(SCAN_ID, CVE_ID, CVE_ID); + } +}