Skip to content

Commit

Permalink
* Fix failing unit test by enforcing parent loading
Browse files Browse the repository at this point in the history
* Add validation to prevent invalid states of collection projects (prevent collection project having Components or services), including several unit tests covering these scenarios.

Signed-off-by: Ralf King <[email protected]>
  • Loading branch information
rkg-mm committed Sep 14, 2024
1 parent 6b253e9 commit df7af89
Show file tree
Hide file tree
Showing 12 changed files with 189 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,18 @@ public long deleteComponentPropertyByUuid(final Component component, final UUID
}
}

@Override
public boolean hasComponents(final Project project) {
final Query<Component> query = pm.newQuery(Component.class, "project == :project");
query.setParameters(project);
query.setResult("count(this)");
try {
return query.executeResultUnique(Long.class) > 0;
} finally {
query.closeAll();
}
}

public void synchronizeComponentProperties(final Component component, final List<ComponentProperty> properties) {
assertPersistent(component, "component must be persistent");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -473,12 +473,21 @@ public Project updateProject(Project transientProject, boolean commitIndex) {
project.setDescription(transientProject.getDescription());
project.setVersion(transientProject.getVersion());
project.setClassifier(transientProject.getClassifier());
project.setCollectionLogic(transientProject.getCollectionLogic());
project.setCpe(transientProject.getCpe());
project.setPurl(transientProject.getPurl());
project.setSwidTagId(transientProject.getSwidTagId());
project.setExternalReferences(transientProject.getExternalReferences());

// prevent illegal states of collection projects (must not contain components or services)
if(transientProject.getCollectionLogic() != null
&& !project.getCollectionLogic().equals(transientProject.getCollectionLogic())) {
if(!transientProject.getCollectionLogic().equals(ProjectCollectionLogic.NONE)
&& (hasComponents(project) || hasServiceComponents(project))) {
throw new IllegalArgumentException("Project cannot be made a collection project while it has components or services!");
}
project.setCollectionLogic(transientProject.getCollectionLogic());
}

if (Boolean.TRUE.equals(project.isActive()) && !Boolean.TRUE.equals(transientProject.isActive()) && hasActiveChild(project)){
throw new IllegalArgumentException("Project cannot be set to inactive if active children are present.");
}
Expand Down Expand Up @@ -514,6 +523,11 @@ public Project updateProject(Project transientProject, boolean commitIndex) {
} else {
project.setCollectionTag(null);
}
// Force loading parent. This seems useless but somehow the code block above magically unloads the parent,
// making it missing in the API response. Following line enforces it to be available again.
// For reference see following Unit Test which would fail without this:
// org.dependencytrack.resources.v1.ProjectResourceTest.patchProjectParentTest
project.getParent();

final Project result = persist(project);
Event.dispatch(new IndexEvent(IndexEvent.Action.UPDATE, result));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,10 @@ public PaginatedResult getComponents(final Project project, final boolean includ
return getComponentQueryManager().getComponents(project, includeMetrics, onlyOutdated, onlyDirect);
}

public boolean hasComponents(final Project project) {
return getComponentQueryManager().hasComponents(project);
}

public ServiceComponent matchServiceIdentity(final Project project, final ComponentIdentity cid) {
return getServiceComponentQueryManager().matchServiceIdentity(project, cid);
}
Expand Down Expand Up @@ -1012,6 +1016,10 @@ public ServiceComponent updateServiceComponent(ServiceComponent transientService
return getServiceComponentQueryManager().updateServiceComponent(transientServiceComponent, commitIndex);
}

public boolean hasServiceComponents(final Project project) {
return getServiceComponentQueryManager().hasServiceComponents(project);
}

public void recursivelyDelete(ServiceComponent service, boolean commitIndex) {
getServiceComponentQueryManager().recursivelyDelete(service, commitIndex);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,18 @@ private void deleteServiceComponents(Project project) {
query.deletePersistentAll(project);
}

@Override
public boolean hasServiceComponents(final Project project) {
final Query<ServiceComponent> query = pm.newQuery(ServiceComponent.class, "project == :project");
query.setParameters(project);
query.setResult("count(this)");
try {
return query.executeResultUnique(Long.class) > 0;
} finally {
query.closeAll();
}
}

/**
* Deletes a ServiceComponent and all objects dependant on the service.
* @param service the ServiceComponent to delete
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import org.dependencytrack.model.ConfigPropertyConstants;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.Tag;
import org.dependencytrack.model.ProjectCollectionLogic;
import org.dependencytrack.model.validation.ValidUuid;
import org.dependencytrack.notification.NotificationConstants.Title;
import org.dependencytrack.notification.NotificationGroup;
Expand Down Expand Up @@ -486,6 +487,9 @@ private Response process(QueryManager qm, Project project, String encodedBomData
if (! qm.hasAccess(super.getPrincipal(), project)) {
return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build();
}
if(!project.getCollectionLogic().equals(ProjectCollectionLogic.NONE)) {
return Response.status(Response.Status.BAD_REQUEST).entity("BOM cannot be uploaded to collection project.").build();
}
final byte[] decoded = Base64.getDecoder().decode(encodedBomData);
try (final ByteArrayInputStream bain = new ByteArrayInputStream(decoded)) {
final byte[] content = IOUtils.toByteArray(BOMInputStream.builder().setInputStream(bain).get());
Expand All @@ -511,6 +515,9 @@ private Response process(QueryManager qm, Project project, List<FormDataBodyPart
if (! qm.hasAccess(super.getPrincipal(), project)) {
return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build();
}
if(!project.getCollectionLogic().equals(ProjectCollectionLogic.NONE)) {
return Response.status(Response.Status.BAD_REQUEST).entity("BOM cannot be uploaded to collection project.").build();
}
try (InputStream in = bodyPartEntity.getInputStream()) {
final byte[] content = IOUtils.toByteArray(BOMInputStream.builder().setInputStream(in).get());
validate(content, project);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import org.dependencytrack.model.RepositoryMetaComponent;
import org.dependencytrack.model.RepositoryType;
import org.dependencytrack.model.validation.ValidUuid;
import org.dependencytrack.model.ProjectCollectionLogic;
import org.dependencytrack.persistence.QueryManager;
import org.dependencytrack.resources.v1.openapi.PaginatedApi;
import org.dependencytrack.util.InternalComponentIdentificationUtil;
Expand Down Expand Up @@ -318,6 +319,9 @@ public Response createComponent(@Parameter(description = "The UUID of the projec
if (project == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build();
}
if(!project.getCollectionLogic().equals(ProjectCollectionLogic.NONE)) {
return Response.status(Response.Status.BAD_REQUEST).entity("Collection project cannot contain components.").build();
}
if (! qm.hasAccess(super.getPrincipal(), project)) {
return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import org.apache.commons.lang3.StringUtils;
import org.dependencytrack.auth.Permissions;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.ProjectCollectionLogic;
import org.dependencytrack.model.ServiceComponent;
import org.dependencytrack.model.validation.ValidUuid;
import org.dependencytrack.persistence.QueryManager;
Expand Down Expand Up @@ -181,6 +182,9 @@ public Response createService(@Parameter(description = "The UUID of the project"
if (! qm.hasAccess(super.getPrincipal(), project)) {
return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build();
}
if(!project.getCollectionLogic().equals(ProjectCollectionLogic.NONE)) {
return Response.status(Response.Status.BAD_REQUEST).entity("Collection project cannot contain services.").build();
}
ServiceComponent service = new ServiceComponent();
service.setProject(project);
service.setProvider(jsonService.getProvider());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.dependencytrack.auth.Permissions;
import org.dependencytrack.event.VexUploadEvent;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.ProjectCollectionLogic;
import org.dependencytrack.model.validation.ValidUuid;
import org.dependencytrack.parser.cyclonedx.CycloneDXExporter;
import org.dependencytrack.persistence.QueryManager;
Expand Down Expand Up @@ -260,6 +261,9 @@ private Response process(QueryManager qm, Project project, String encodedVexData
if (! qm.hasAccess(super.getPrincipal(), project)) {
return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build();
}
if(!project.getCollectionLogic().equals(ProjectCollectionLogic.NONE)) {
return Response.status(Response.Status.BAD_REQUEST).entity("VEX cannot be uploaded to collection project.").build();
}
final byte[] decoded = Base64.getDecoder().decode(encodedVexData);
BomResource.validate(decoded, project);
final VexUploadEvent vexUploadEvent = new VexUploadEvent(project.getUuid(), decoded);
Expand All @@ -280,6 +284,9 @@ private Response process(QueryManager qm, Project project, List<FormDataBodyPart
if (! qm.hasAccess(super.getPrincipal(), project)) {
return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build();
}
if(!project.getCollectionLogic().equals(ProjectCollectionLogic.NONE)) {
return Response.status(Response.Status.BAD_REQUEST).entity("VEX cannot be uploaded to collection project.").build();
}
try (InputStream in = bodyPartEntity.getInputStream()) {
final byte[] content = IOUtils.toByteArray(BOMInputStream.builder().setInputStream(in).get());
BomResource.validate(content, project);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
*/
package org.dependencytrack.persistence;

import alpine.persistence.PaginatedResult;
import org.dependencytrack.PersistenceCapableTest;
import org.dependencytrack.model.*;
import org.dependencytrack.tasks.scanners.AnalyzerIdentity;
Expand All @@ -28,7 +27,7 @@
import java.util.Date;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.*;

public class ProjectQueryManagerTest extends PersistenceCapableTest {

Expand Down Expand Up @@ -57,4 +56,42 @@ public void testCloneProjectPreservesVulnerabilityAttributionDate() throws Excep
Assert.assertEquals(new Date(1708559165229L),finding.getAttribution().get("attributedOn"));
}

@Test
public void testUpdateProjectPreventCollectionProjectWithExistingComponentsTest() {
Project project = qm.createProject("Example Project 1", "Description 1", "1.0", null, null, null, true, false);
Component comp = new Component();
comp.setId(111L);
comp.setName("name");
comp.setProject(project);
comp.setVersion("1.0");
comp.setCopyright("Copyright Acme");
qm.createComponent(comp, true);

// avoid direct persistent update in next step
final Project detached = qm.detach(Project.class, project.getId());
detached.setCollectionLogic(ProjectCollectionLogic.AGGREGATE_DIRECT_CHILDREN);

assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> qm.updateProject(detached, false))
.withMessage("Project cannot be made a collection project while it has components or services!");
}

@Test
public void testUpdateProjectPreventCollectionProjectWithExistingServiceTest() {
Project project = qm.createProject("Example Project 1", "Description 1", "1.0", null, null, null, true, false);
ServiceComponent service = new ServiceComponent();
service.setName("name");
service.setProject(project);
service.setVersion("1.0");
qm.createServiceComponent(service, false);

// avoid direct persistent update in next step
final Project detached = qm.detach(Project.class, project.getId());
detached.setCollectionLogic(ProjectCollectionLogic.AGGREGATE_DIRECT_CHILDREN);

assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> qm.updateProject(detached, false))
.withMessage("Project cannot be made a collection project while it has components or services!");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import org.dependencytrack.model.OrganizationalContact;
import org.dependencytrack.model.OrganizationalEntity;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.ProjectCollectionLogic;
import org.dependencytrack.model.ProjectMetadata;
import org.dependencytrack.model.ProjectProperty;
import org.dependencytrack.model.Severity;
Expand Down Expand Up @@ -1412,4 +1413,24 @@ public void validateCycloneDxBomWithMultipleNamespacesTest() throws Exception {
assertThatNoException().isThrownBy(() -> CycloneDxValidator.getInstance().validate(bom));
}

@Test
public void uploadBomCollectionProjectTest() throws Exception {
initializeWithPermissions(Permissions.BOM_UPLOAD);
Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false);

// make project a collection project
project.setCollectionLogic(ProjectCollectionLogic.AGGREGATE_DIRECT_CHILDREN);
qm.updateProject(project, false);

String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml"));
BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString);
Response response = jersey.target(V1_BOM).request()
.header(X_API_KEY, apiKey)
.put(Entity.entity(request, MediaType.APPLICATION_JSON));
Assert.assertEquals(400, response.getStatus(), 0);
Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER));
String body = getPlainTextBody(response);
Assert.assertEquals("BOM cannot be uploaded to collection project.", body);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.dependencytrack.model.ConfigPropertyConstants;
import org.dependencytrack.model.ExternalReference;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.ProjectCollectionLogic;
import org.dependencytrack.model.RepositoryMetaComponent;
import org.dependencytrack.model.RepositoryType;
import org.glassfish.jersey.server.ResourceConfig;
Expand Down Expand Up @@ -555,6 +556,23 @@ public void createComponentUpperCaseHashTest() {
Assert.assertEquals(component.getMd5(), json.getString("md5"));
}

@Test
public void createComponentCollectionProjectTest() {
Project project = qm.createProject("Acme Application", null, null, null, null, null, true, false);
// make project a collection project
project.setCollectionLogic(ProjectCollectionLogic.AGGREGATE_DIRECT_CHILDREN);
qm.updateProject(project, false);

Component component = new Component();
component.setProject(project);
component.setName("My Component");
component.setVersion("1.0");
Response response = jersey.target(V1_COMPONENT + "/project/" + project.getUuid().toString()).request()
.header(X_API_KEY, apiKey)
.put(Entity.entity(component, MediaType.APPLICATION_JSON));
Assert.assertEquals(400, response.getStatus(), 0);
}

@Test
public void updateComponentTest() {
Project project = qm.createProject("Acme Application", null, null, null, null, null, true, false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@
import org.dependencytrack.model.Classifier;
import org.dependencytrack.model.Component;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.ProjectCollectionLogic;
import org.dependencytrack.model.Severity;
import org.dependencytrack.model.Vulnerability;
import org.dependencytrack.parser.cyclonedx.CycloneDxValidator;
import org.dependencytrack.resources.v1.exception.JsonMappingExceptionMapper;
import org.dependencytrack.tasks.scanners.AnalyzerIdentity;
import org.glassfish.jersey.media.multipart.MultiPartFeature;
import org.glassfish.jersey.server.ResourceConfig;
import org.junit.Assert;
import org.junit.ClassRule;
import org.junit.Test;

Expand Down Expand Up @@ -755,4 +757,44 @@ public void uploadVexTooLargeViaPutTest() {
""");
}

@Test
public void uploadVexCollectionProjectTest() {
initializeWithPermissions(Permissions.BOM_UPLOAD);
Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false);

// make project a collection project
project.setCollectionLogic(ProjectCollectionLogic.AGGREGATE_DIRECT_CHILDREN);
qm.updateProject(project, false);

final String encodedVex = Base64.getEncoder().encodeToString("""
{
"bomFormat": "CycloneDX",
"specVersion": "1.2",
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
"version": 1,
"components": [
{
"type": "foo",
"name": "acme-library",
"version": "1.0.0"
}
]
}
""".getBytes());

final Response response = jersey.target(V1_VEX).request()
.header(X_API_KEY, apiKey)
.put(Entity.entity("""
{
"projectName": "Acme Example",
"projectVersion": "1.0",
"vex": "%s"
}
""".formatted(encodedVex), MediaType.APPLICATION_JSON));
Assert.assertEquals(400, response.getStatus(), 0);
Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER));
String body = getPlainTextBody(response);
Assert.assertEquals("VEX cannot be uploaded to collection project.", body);
}

}

0 comments on commit df7af89

Please sign in to comment.