From 7e6488eea9700a5e2cb3e60739f4b436fc3761ef Mon Sep 17 00:00:00 2001 From: Christoffer Rumohr Date: Tue, 7 Jan 2025 11:38:10 +0100 Subject: [PATCH] Add option to deactivate old project versions on BOM upload Fixes DependencyTrack/dependency-track#4532 Signed-off-by: Christoffer Rumohr --- .../resources/v1/BomResource.java | 22 +++- .../resources/v1/BomResourceTest.java | 109 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index f29f7e351f..60ae5f6782 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -404,6 +404,7 @@ public Response uploadBom( @FormDataParam("parentVersion") String parentVersion, @FormDataParam("parentUUID") String parentUUID, @DefaultValue("false") @FormDataParam("isLatest") boolean isLatest, + @DefaultValue("false") @FormDataParam("isActiveExclusively") boolean isActiveExclusively, @Parameter(schema = @Schema(type = "string")) @FormDataParam("bom") final List artifactParts ) { if (projectUuid != null) { // behavior in v3.0.0 @@ -454,7 +455,26 @@ public Response uploadBom( return Response.status(Response.Status.UNAUTHORIZED).entity("The principal does not have permission to create project.").build(); } } - return process(qm, project, artifactParts); + final var response = process(qm, project, artifactParts); + if (response.getStatusInfo() == Response.Status.OK && isActiveExclusively && trimmedProjectName != null) { + LOGGER.info("Deactivating old versions for project: " + trimmedProjectName); + if (!isLatest) { + var message = "Value \"isLatest=true\" required when \"isActiveExclusively=true\"."; + LOGGER.error(message); + return Response.status(Response.Status.NOT_ACCEPTABLE).entity(message).build(); + } + qm.getProjects(trimmedProjectName, true, false, null).getList(Project.class).forEach(p -> { + if (p.isLatest()) { + return; + } else if (!qm.hasAccess(super.getPrincipal(), p)) { + LOGGER.warn("Could not deactivate project, no access: " + p.getUuid() + " / " + p.getName()); + return; + } + p.setActive(false); + qm.updateProject(p, true); + }); + } + return response; } } } diff --git a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index a2cf7a90b4..8054dca588 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -923,6 +923,115 @@ public void uploadBomAutoCreateWithTagsMultipartTest() throws Exception { .containsExactlyInAnyOrder("tag1", "tag2"); } + @Test + public void uploadBomAutoCreateIsLatestMultipartTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + + final var multiPart = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "1.0") + .field("projectTags", "tag1,tag2") + .field("autoCreate", "true") + .field("isLatest", "true"); + + final var client = ClientBuilder.newClient(new ClientConfig() + .connectorProvider(new HttpUrlConnectorProvider())); + + final Response response = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multiPart, multiPart.getMediaType())); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "token": "${json-unit.any-string}" + } + """); + + final Project project = qm.getProject("Acme Example", "1.0"); + assertThat(project).isNotNull(); + assertThat(project.getTags()) + .extracting(Tag::getName) + .containsExactlyInAnyOrder("tag1", "tag2"); + assertThat(project.isLatest()).isTrue(); + } + + @Test + public void uploadBomAutoCreateIsLatestIsActiveExclusivelyMultipartTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + + final var multipartV1 = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "1.0") + .field("autoCreate", "true") + .field("isLatest", "true") + .field("isActiveExclusively", "true"); + + final var multipartV2 = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "2.0") + .field("autoCreate", "true") + .field("isLatest", "true") + .field("isActiveExclusively", "true"); + + final var client = ClientBuilder.newClient(new ClientConfig() + .connectorProvider(new HttpUrlConnectorProvider())); + + final Response responseV1 = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multipartV1, multipartV1.getMediaType())); + assertThat(responseV1.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(responseV1)).isEqualTo(""" + { + "token": "${json-unit.any-string}" + } + """); + + final Response responseV2 = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multipartV2, multipartV2.getMediaType())); + assertThat(responseV2.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(responseV2)).isEqualTo(""" + { + "token": "${json-unit.any-string}" + } + """); + + final Project projectV1 = qm.getProject("Acme Example", "1.0"); + assertThat(projectV1).isNotNull(); + assertThat(projectV1.isLatest()).isFalse(); + assertThat(projectV1.isActive()).isFalse(); + + final Project projectV2 = qm.getProject("Acme Example", "2.0"); + assertThat(projectV2).isNotNull(); + assertThat(projectV2.isLatest()).isTrue(); + assertThat(projectV2.isActive()).isTrue(); + } + + @Test + public void uploadBomAutoCreateIsNotLatestIsActiveExclusivelyMultipartTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + + final var multiPart = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "1.0") + .field("autoCreate", "true") + //.field("isLatest", "false") // this is implied with @DefaultValue("false") + .field("isActiveExclusively", "true"); + + final var client = ClientBuilder.newClient(new ClientConfig() + .connectorProvider(new HttpUrlConnectorProvider())); + + final Response response = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multiPart, multiPart.getMediaType())); + assertThat(response.getStatus()).isEqualTo(406); + assertThat(getPlainTextBody(response)).isEqualTo("Value \"isLatest=true\" required when \"isActiveExclusively=true\"."); + } + @Test public void uploadBomUnauthorizedTest() throws Exception { String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml"));