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
Expand Up @@ -226,6 +226,17 @@ public Response remove(
responseCode = "400",
description = "Validation error with field-specific error messages"
),
@APIResponse(
responseCode = "429",
description = "Per-user concurrent request limit exceeded or global queue is full",
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
type = SchemaType.OBJECT,
example = "{\"error\": \"Per-user concurrent request limit exceeded\"}"
)
)
),
@APIResponse(
responseCode = "500",
description = "Internal server error"
Expand Down Expand Up @@ -356,7 +367,7 @@ public Response mapSbomValidationException(SbomValidationException e) {
description = "Uploads an SPDX SBOM file, parses it, creates a product, and starts async processing. Requires a vulnerability ID to include in all component reports. Accepts optional credentials for private repository access.")
@APIResponses({
@APIResponse(
responseCode = "202",
responseCode = "202",
description = "Product creation request accepted",
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
Expand All @@ -366,11 +377,22 @@ public Response mapSbomValidationException(SbomValidationException e) {
)
),
@APIResponse(
responseCode = "400",
responseCode = "400",
description = "Invalid SPDX file, missing required data, missing CVE ID, or credential validation error"
),
@APIResponse(
responseCode = "500",
responseCode = "429",
description = "Per-user concurrent request limit exceeded or global queue is full",
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
type = SchemaType.OBJECT,
example = "{\"error\": \"Per-user concurrent request limit exceeded\"}"
)
)
),
@APIResponse(
responseCode = "500",
description = "Internal server error"
)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public class SbomReportService {
private ComponentProcessingService componentProcessingService;
private CredentialProcessingService credentialProcessingService;
private ObjectMapper objectMapper;
private RequestQueueService queueService;

@Inject
public void setCycloneDxParsingService(CycloneDxParsingService cycloneDxParsingService) {
Expand Down Expand Up @@ -94,6 +95,11 @@ public void setObjectMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

@Inject
public void setRequestQueueService(RequestQueueService queueService) {
this.queueService = queueService;
}


/**
* Generates a product ID from SBOM name and version.
Expand Down Expand Up @@ -221,35 +227,55 @@ public String submitSpdx(InputStream fileInputStream, String cveId, String crede
throw new ValidationException(errors);
}
LOGGER.info("Processing SPDX file upload for CVE: " + cveId);

SpdxParsingService.ProductInfo productInfo = parsed.productInfo();
Map<String, String> metadata = new HashMap<>();
// Add CPE to metadata if present
if (productInfo.cpe() != null && !productInfo.cpe().trim().isEmpty()) {
metadata.put("cpe", productInfo.cpe());
}

if (Objects.nonNull(productInfo.spdxId())) {
metadata.put(RepositoryConstants.SPDX_ID_METADATA_KEY, productInfo.spdxId());
}

int totalComponentCount = parsed.components().size() + parsed.unsupportedComponents().size();
Product product = this.createProduct(cveId, productInfo.name(), productInfo.version(), totalComponentCount, metadata);
final SpdxParsingService.ProductInfo productInfo = parsed.productInfo();

for (SpdxParsingService.UnsupportedComponentInfo unsupported : parsed.unsupportedComponents()) {
String errorMessage =
"Expects a container image purl with format pkg:oci/name@sha256:hash or pkg:oci/name@sha256%3Ahash?repository_url=...&tag=...";
String imageForDisplay = unsupported.purl() != null ? unsupported.purl() : "";
productRepository.addSubmissionFailure(product.id(), new FailedComponent(
unsupported.name(), unsupported.version(), imageForDisplay, errorMessage));
}
// Generate productId before capacity check (needed for product slot optimization)
final String productId = generateProductId(productInfo.name(), productInfo.version());
Comment on lines +233 to +234

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TamarW0 This is used for the slot reservation in the queue, but the product uses another generated productId, you need it to be the same.


// Get current user
final String user = userService.getUserName();

// Make variables final for lambda capture
final SpdxParsingService.ParsedSpdx finalParsed = parsed;
final String finalCveId = cveId;
final String finalCredentialId = credentialId;

// Start component processing (chunks run in parallel on executor)
processSpdxComponents(product.id(), parsed, cveId, credentialId);
// Check user capacity before creating product (matches RPM/CycloneDX pattern)
return queueService.runIfHasCapacity(user, productId, () -> {
try {
Map<String, String> metadata = new HashMap<>();
// Add CPE to metadata if present
if (productInfo.cpe() != null && !productInfo.cpe().trim().isEmpty()) {
metadata.put("cpe", productInfo.cpe());
}

LOGGER.infof("Created product %s, started component processing", product.id());

return product.id();
if (Objects.nonNull(productInfo.spdxId())) {
metadata.put(RepositoryConstants.SPDX_ID_METADATA_KEY, productInfo.spdxId());
}

int totalComponentCount = finalParsed.components().size() + finalParsed.unsupportedComponents().size();
Product product = this.createProduct(productId, finalCveId, productInfo.name(), productInfo.version(), totalComponentCount, metadata);

for (SpdxParsingService.UnsupportedComponentInfo unsupported : finalParsed.unsupportedComponents()) {
String errorMessage =
"Expects a container image purl with format pkg:oci/name@sha256:hash or pkg:oci/name@sha256%3Ahash?repository_url=...&tag=...";
String imageForDisplay = unsupported.purl() != null ? unsupported.purl() : "";
productRepository.addSubmissionFailure(product.id(), new FailedComponent(
unsupported.name(), unsupported.version(), imageForDisplay, errorMessage));
}

// Start component processing (chunks run in parallel on executor)
processSpdxComponents(product.id(), finalParsed, finalCveId, finalCredentialId);

LOGGER.infof("Created product %s, started component processing", product.id());

return product.id();
} catch (Exception e) {
LOGGER.errorf(e, "Failed to create product or start component processing for CVE %s", finalCveId);
throw e;
}
});
Comment on lines +245 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Consider adding context logging for non-queue failures inside the lambda.

If createProduct or processSpdxComponents throws here, the exception propagates through runIfHasCapacity (which doesn't catch or log it) to the generic @ServerExceptionMapper — resulting in a 500 with no log of which CVE/product was being processed. The RPM/CycloneDX flow logs "Unable to submit request" with report IDs in ReportService.

A lightweight option:

return queueService.runIfHasCapacity(user, productId, () -> {
    try {
        // ... existing lambda body ...
    } catch (Exception e) {
        LOGGER.errorf(e, "Failed SPDX upload for CVE %s, product %s/%s",
                      finalCveId, productInfo.name(), productInfo.version());
        throw e;
    }
});

Non-blocking — the current code works correctly, this just improves debuggability.

}

private void processSpdxComponents(String productId, SpdxParsingService.ParsedSpdx parsed, String vulnerabilityId, String credentialId) {
Expand All @@ -273,8 +299,7 @@ private void processSpdxComponents(String productId, SpdxParsingService.ParsedSp
}
}

private Product createProduct(String cveId, String sbomName, String sbomVersion, int componentCount, Map<String, String> metadata) {
String productId = generateProductId(sbomName, sbomVersion);
private Product createProduct(String productId, String cveId, String sbomName, String sbomVersion, int componentCount, Map<String, String> metadata) {
Product product = newProductDocument(cveId, productId, sbomName, sbomVersion, componentCount, metadata);
productRepository.save(product, userService.getUserName());
return product;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package com.redhat.ecosystemappeng.exploitiq.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.redhat.ecosystemappeng.exploitiq.repository.ProductRepositoryService;
import io.quarkus.test.InjectMock;
import io.quarkus.test.component.QuarkusComponentTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
* Tests that SPDX upload checks queue capacity before creating product,
* matching the pattern used by RPM and CycloneDX flows.
*/
@QuarkusComponentTest
class SbomReportServiceQueueAdmissionTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: Missing happy path test.

Both tests verify rejection (queue throws before the lambda runs). There's no test verifying the lambda actually executes when capacity is available. The RPM equivalent (ReportServiceQueueAdmissionTest) includes submitHappyPathWritesSubmittedAfterAdmission.

A happy path test would catch:

  • Lambda wiring bugs (variable capture, return value flowing through)
  • Regression if createProduct is accidentally moved outside the lambda
  • That processSpdxComponents is called on success

Suggested approach: mock runIfHasCapacity with thenAnswer that invokes the supplier (invocation.getArgument(2, Supplier.class).get()), mock productRepository.save() to succeed, verify save() is called and the product ID is returned.


@Inject
SbomReportService sbomReportService;

@InjectMock
RequestQueueService queueService;
@InjectMock
UserService userService;
@InjectMock
ProductRepositoryService productRepository;
@InjectMock
SpdxParsingService spdxParsingService;
@InjectMock
ComponentProcessingService componentProcessingService;
@InjectMock
CycloneDxParsingService cycloneDxParsingService;
@InjectMock
ReportService reportService;
@InjectMock
CredentialProcessingService credentialProcessingService;
@InjectMock
ObjectMapper objectMapper;

/**
* Test happy path: when capacity is available, the lambda executes,
* product is created, and component processing starts.
*/
@Test
void submitSpdx_HappyPath_CreatesProductAndProcessesComponents() throws Exception {
String spdxJson = """
{
"spdxVersion": "SPDX-2.3",
"name": "happy-product",
"versionInfo": "3.0.0",
"documentNamespace": "https://example.com/happy",
"packages": []
}
""";

InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8));

SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo(
"happy-product", "3.0.0", null, null
);
SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx(
productInfo,
java.util.List.of(),
java.util.List.of()
);
when(spdxParsingService.parse(any())).thenReturn(parsedSpdx);
when(userService.getUserName()).thenReturn("charlie");

// Mock queueService to execute the lambda when capacity is available
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
java.util.function.Supplier<String> action = invocation.getArgument(2);
return action.get(); // Execute the lambda
}).when(queueService).runIfHasCapacity(eq("charlie"), anyString(), any());

// Execute
String result = sbomReportService.submitSpdx(spdxStream, "CVE-2024-9999", null);

// Verify: Product created
verify(productRepository).save(any(), eq("charlie"));

// Verify: Component processing started
verify(componentProcessingService).processComponents(any(), anyString(), any(), eq("CVE-2024-9999"), eq(null));

// Verify: Returns product ID (non-null)
assertEquals(true, result != null && !result.isEmpty());
}

/**
* Test that SPDX upload throws UserQueueExceededException when user limit is exceeded,
* and does NOT create a product document. This matches the behavior of RPM analysis.
*/
@Test
void submitSpdx_UserQueueExceeded_DoesNotCreateProduct() throws Exception {
// Prepare minimal valid SPDX JSON
String spdxJson = """
{
"spdxVersion": "SPDX-2.3",
"name": "test-product",
"documentNamespace": "https://example.com/test",
"packages": [
{
"name": "test-component",
"versionInfo": "1.0.0",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:oci/test@sha256:abcd1234"
}
]
}
]
}
""";

InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8));

// Mock parsing to return valid parsed data
SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo(
"test-product", "1.0.0", null, null
);
SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx(
productInfo,
java.util.List.of(), // components
java.util.List.of() // unsupported components
);
when(spdxParsingService.parse(any())).thenReturn(parsedSpdx);
when(userService.getUserName()).thenReturn("alice");

// Mock queueService to throw UserQueueExceededException (user limit exceeded)
doThrow(new UserQueueExceededException(5))
.when(queueService).runIfHasCapacity(eq("alice"), anyString(), any());

// Assert that exception is thrown
assertThrows(UserQueueExceededException.class,
() -> sbomReportService.submitSpdx(spdxStream, "CVE-2024-1234", null));
Comment on lines +150 to +151

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: Arguments to runIfHasCapacity are not verified.

any() and nullable(String.class) match anything — the tests would still pass if the code passed null for user, hardcoded a string, or swapped the user/productId arguments.

Consider using eq() for at least the user argument:

doThrow(new UserQueueExceededException(5))
    .when(queueService).runIfHasCapacity(eq("alice"), anyString(), any());

Same applies to the second test with eq("bob").


// Verify: No product created (save never called on productRepository)
verify(productRepository, never()).save(any(), any());

// Verify: No component processing started
verify(componentProcessingService, never()).processComponents(any(), any(), any(), any(), any());
}

/**
* Test that SPDX upload throws RequestQueueExceededException when global queue is full,
* and does NOT create a product document.
*/
@Test
void submitSpdx_GlobalQueueExceeded_DoesNotCreateProduct() throws Exception {
String spdxJson = """
{
"spdxVersion": "SPDX-2.3",
"name": "test-product",
"versionInfo": "2.0.0",
"documentNamespace": "https://example.com/test2",
"packages": []
}
""";

InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8));

SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo(
"test-product", "2.0.0", null, null
);
SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx(
productInfo,
java.util.List.of(),
java.util.List.of()
);
when(spdxParsingService.parse(any())).thenReturn(parsedSpdx);
when(userService.getUserName()).thenReturn("bob");

// Mock queueService to throw RequestQueueExceededException (global queue full)
doThrow(new RequestQueueExceededException(500))
.when(queueService).runIfHasCapacity(eq("bob"), anyString(), any());

// Assert that exception is thrown
assertThrows(RequestQueueExceededException.class,
() -> sbomReportService.submitSpdx(spdxStream, "CVE-2024-5678", null));

// Verify: No product created

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add componentProcessingService never-verification here for consistency.

The first test verifies both productRepository.save and componentProcessingService.processComponents are never called, but this test only verifies save. Both exercise the same rejection path and should verify the same side effects.

verify(productRepository, never()).save(any(), any());
verify(componentProcessingService, never()).processComponents(any(), any(), any(), any(), any());

verify(productRepository, never()).save(any(), any());

// Verify: No component processing started
verify(componentProcessingService, never()).processComponents(any(), any(), any(), any(), any());
}
}