Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -289,25 +289,55 @@ public String getStatus(Document doc, Map<String, String> metadata) {
return "unknown";
}

public void updateWithOutput(List<String> 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<String> updateWithOutput(List<String> ids, JsonNode report)
throws JsonMappingException, JsonProcessingException {

Set<String> productIds = getProductId(ids);


List<String> 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<String> 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();
var updates = Updates.combine(Updates.set("input.scan", Document.parse(scan)),
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<Document>(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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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) {
Expand All @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}