diff --git a/src/main/java/org/dependencytrack/model/validation/ValidUuid.java b/src/main/java/org/dependencytrack/model/validation/ValidUuid.java index 98170cf21..80c7b1b6f 100644 --- a/src/main/java/org/dependencytrack/model/validation/ValidUuid.java +++ b/src/main/java/org/dependencytrack/model/validation/ValidUuid.java @@ -31,7 +31,7 @@ /** * @since 4.11.0 */ -@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE_USE}) @Constraint(validatedBy = {}) @Retention(RUNTIME) @ReportAsSingleViolation diff --git a/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java b/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java index 845c629ba..8a5ea5b5b 100644 --- a/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java @@ -18,20 +18,19 @@ */ package org.dependencytrack.persistence; -import alpine.model.ApiKey; import alpine.model.IConfigProperty.PropertyType; -import alpine.model.Team; -import alpine.model.UserPrincipal; import alpine.persistence.OrderDirection; import alpine.persistence.PaginatedResult; import alpine.resources.AlpineRequest; import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; +import jakarta.json.Json; +import jakarta.json.JsonArray; +import jakarta.json.JsonValue; import org.apache.commons.lang3.tuple.Pair; import org.dependencytrack.model.Component; import org.dependencytrack.model.ComponentIdentity; import org.dependencytrack.model.ComponentProperty; -import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Project; import org.dependencytrack.model.RepositoryMetaComponent; import org.dependencytrack.model.RepositoryType; @@ -42,9 +41,6 @@ import javax.jdo.PersistenceManager; import javax.jdo.Query; import javax.jdo.Transaction; -import jakarta.json.Json; -import jakarta.json.JsonArray; -import jakarta.json.JsonValue; import java.io.StringReader; import java.util.ArrayList; import java.util.HashMap; @@ -880,48 +876,6 @@ public void reconcileComponents(Project project, List existingProject } } - /** - * A similar method exists in ProjectQueryManager - */ - private void preprocessACLs(final Query query, final String inputFilter, final Map params, final boolean bypass) { - if (super.principal != null && isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && !bypass) { - final List teams; - if (super.principal instanceof UserPrincipal) { - final UserPrincipal userPrincipal = ((UserPrincipal) super.principal); - teams = userPrincipal.getTeams(); - if (super.hasAccessManagementPermission(userPrincipal)) { - query.setFilter(inputFilter); - return; - } - } else { - final ApiKey apiKey = ((ApiKey) super.principal); - teams = apiKey.getTeams(); - if (super.hasAccessManagementPermission(apiKey)) { - query.setFilter(inputFilter); - return; - } - } - if (teams != null && teams.size() > 0) { - final StringBuilder sb = new StringBuilder(); - for (int i = 0, teamsSize = teams.size(); i < teamsSize; i++) { - final Team team = super.getObjectById(Team.class, teams.get(i).getId()); - sb.append(" project.accessTeams.contains(:team").append(i).append(") "); - params.put("team" + i, team); - if (i < teamsSize - 1) { - sb.append(" || "); - } - } - if (inputFilter != null) { - query.setFilter(inputFilter + " && (" + sb.toString() + ")"); - } else { - query.setFilter(sb.toString()); - } - } - } else { - query.setFilter(inputFilter); - } - } - public Map getDependencyGraphForComponents(Project project, List components) { Map dependencyGraph = new HashMap<>(); if (project.getDirectDependencies() == null || project.getDirectDependencies().isBlank()) { diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index b631195cc..5cef23bc9 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -30,6 +30,7 @@ import com.github.packageurl.PackageURL; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.datanucleus.api.jdo.JDOQuery; import org.dependencytrack.auth.Permissions; import org.dependencytrack.event.kafka.KafkaEventDispatcher; import org.dependencytrack.model.Analysis; @@ -53,6 +54,8 @@ import javax.jdo.PersistenceManager; import javax.jdo.Query; import javax.jdo.Transaction; +import javax.jdo.metadata.MemberMetadata; +import javax.jdo.metadata.TypeMetadata; import java.security.Principal; import java.util.ArrayList; import java.util.Date; @@ -851,10 +854,35 @@ public boolean hasAccess(final Principal principal, final Project project) { } } - /** - * A similar method exists in ComponentQueryManager - */ - private void preprocessACLs(final Query query, final String inputFilter, final Map params, final boolean bypass) { + @Override + void preprocessACLs(final Query query, final String inputFilter, final Map params, final boolean bypass) { + String projectMemberFieldName = null; + final org.datanucleus.store.query.Query internalQuery = ((JDOQuery)query).getInternalQuery(); + if (!Project.class.equals(internalQuery.getCandidateClass())) { + // NB: The query does not directly target Project, but if it has a relationship + // with Project we can still make the ACL check work. If the query candidate + // has EXACTLY one persistent field of type Project, we'll use that. + // If there are more than one, or none at all, we fail to avoid unintentional behavior. + final TypeMetadata candidateTypeMetadata = pm.getPersistenceManagerFactory().getMetadata(internalQuery.getCandidateClassName()); + + for (final MemberMetadata memberMetadata : candidateTypeMetadata.getMembers()) { + if (!Project.class.getName().equals(memberMetadata.getFieldType())) { + continue; + } + + if (projectMemberFieldName != null) { + throw new IllegalArgumentException("Query candidate class %s has multiple members of type %s" + .formatted(internalQuery.getCandidateClassName(), Project.class.getName())); + } + + projectMemberFieldName = memberMetadata.getName(); + } + + if (projectMemberFieldName == null) { + throw new IllegalArgumentException("Query candidate class %s has no member of type %s" + .formatted(internalQuery.getCandidateClassName(), Project.class.getName())); + } + } if (super.principal != null && isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && !bypass) { final List teams; if (super.principal instanceof UserPrincipal userPrincipal) { @@ -871,10 +899,14 @@ private void preprocessACLs(final Query query, final String inputFilter return; } } - if (teams != null && teams.size() > 0) { + if (teams != null && !teams.isEmpty()) { final StringBuilder sb = new StringBuilder(); for (int i = 0, teamsSize = teams.size(); i < teamsSize; i++) { final Team team = super.getObjectById(Team.class, teams.get(i).getId()); + sb.append(" "); + if (projectMemberFieldName != null) { + sb.append(projectMemberFieldName).append("."); + } sb.append(" accessTeams.contains(:team").append(i).append(") "); params.put("team" + i, team); if (i < teamsSize - 1) { @@ -882,7 +914,7 @@ private void preprocessACLs(final Query query, final String inputFilter } } if (inputFilter != null && !inputFilter.isBlank()) { - query.setFilter(inputFilter + " && (" + sb.toString() + ")"); + query.setFilter(inputFilter + " && (" + sb + ")"); } else { query.setFilter(sb.toString()); } diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 4004783ca..e272f5597 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -536,6 +536,10 @@ public boolean hasAccess(final Principal principal, final Project project) { return getProjectQueryManager().hasAccess(principal, project); } + void preprocessACLs(final Query query, final String inputFilter, final Map params, final boolean bypass) { + getProjectQueryManager().preprocessACLs(query, inputFilter, params, bypass); + } + public PaginatedResult getProjects(final Tag tag, final boolean includeMetrics, final boolean excludeInactive, final boolean onlyRoot) { return getProjectQueryManager().getProjects(tag, includeMetrics, excludeInactive, onlyRoot); } @@ -1403,6 +1407,14 @@ public List getTaggedProjects(final String tag return getTagQueryManager().getTaggedProjects(tagName); } + public void tagProjects(final String tagName, final Collection projectUuids) { + getTagQueryManager().tagProjects(tagName, projectUuids); + } + + public void untagProjects(final String tagName, final Collection projectUuids) { + getTagQueryManager().untagProjects(tagName, projectUuids); + } + public List getTaggedPolicies(final String tagName) { return getTagQueryManager().getTaggedPolicies(tagName); } diff --git a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java index 214bfcb07..c05b66cbd 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -30,10 +30,12 @@ import javax.jdo.PersistenceManager; import javax.jdo.Query; import java.util.ArrayList; +import java.util.Collection; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.stream.Stream; public class TagQueryManager extends QueryManager implements IQueryManager { @@ -192,6 +194,63 @@ public List getTaggedProjects(final String tagName) { } } + /** + * @since 4.12.0 + */ + @Override + public void tagProjects(final String tagName, final Collection projectUuids) { + runInTransaction(() -> { + final Tag tag = getTagByName(tagName); + if (tag == null) { + throw new NoSuchElementException("A tag with name %s does not exist".formatted(tagName)); + } + + final Query projectsQuery = pm.newQuery(Project.class); + final var params = new HashMap(Map.of("uuids", projectUuids)); + preprocessACLs(projectsQuery, ":uuids.contains(uuid)", params, /* bypass */ false); + projectsQuery.setNamedParameters(params); + final List projects = executeAndCloseList(projectsQuery); + + for (final Project project : projects) { + if (project.getTags() == null || project.getTags().isEmpty()) { + project.setTags(List.of(tag)); + continue; + } + + if (!project.getTags().contains(tag)) { + project.getTags().add(tag); + } + } + }); + } + + /** + * @since 4.12.0 + */ + @Override + public void untagProjects(final String tagName, final Collection projectUuids) { + runInTransaction(() -> { + final Tag tag = getTagByName(tagName); + if (tag == null) { + throw new NoSuchElementException("A tag with name %s does not exist".formatted(tagName)); + } + + final Query projectsQuery = pm.newQuery(Project.class); + final var params = new HashMap(Map.of("uuids", projectUuids)); + preprocessACLs(projectsQuery, ":uuids.contains(uuid)", params, /* bypass */ false); + projectsQuery.setNamedParameters(params); + final List projects = executeAndCloseList(projectsQuery); + + for (final Project project : projects) { + if (project.getTags() == null || project.getTags().isEmpty()) { + continue; + } + + project.getTags().remove(tag); + } + }); + } + /** * @since 4.12.0 */ diff --git a/src/main/java/org/dependencytrack/resources/v1/TagResource.java b/src/main/java/org/dependencytrack/resources/v1/TagResource.java index 2b3d28cbf..874d279fe 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -31,7 +31,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import jakarta.validation.constraints.Size; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; @@ -43,11 +47,14 @@ import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.persistence.TagQueryManager; import org.dependencytrack.resources.v1.openapi.PaginatedApi; +import org.dependencytrack.resources.v1.problems.ProblemDetails; import org.dependencytrack.resources.v1.vo.TagListResponseItem; import org.dependencytrack.resources.v1.vo.TaggedPolicyListResponseItem; import org.dependencytrack.resources.v1.vo.TaggedProjectListResponseItem; import java.util.List; +import java.util.NoSuchElementException; +import java.util.Set; import java.util.UUID; @Path("/v1/tag") @@ -124,6 +131,98 @@ public Response getTaggedProjects( return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); } + @POST + @Path("/{name}/project") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Tags one or more projects.", + description = "

Requires permission PORTFOLIO_MANAGEMENT or PORTFOLIO_MANAGEMENT_UPDATE

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Projects tagged successfully." + ), + @ApiResponse( + responseCode = "404", + description = "A tag with the provided name does not exist.", + content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON) + ) + }) + @PermissionRequired({Permissions.Constants.PORTFOLIO_MANAGEMENT, Permissions.Constants.PORTFOLIO_MANAGEMENT_UPDATE}) + public Response tagProjects( + @Parameter(description = "Name of the tag to assign", required = true) + @PathParam("name") final String tagName, + @Parameter( + description = "UUIDs of projects to tag", + required = true, + array = @ArraySchema(schema = @Schema(type = "string", format = "uuid")) + ) + @Size(min = 1, max = 100) final Set<@ValidUuid String> projectUuids + ) { + try (final var qm = new QueryManager(getAlpineRequest())) { + qm.tagProjects(tagName, projectUuids); + } catch (NoSuchElementException nseException) { + // TODO: Move this to an ExceptionMapper once https://github.com/stevespringett/Alpine/pull/588 is available. + return Response + .status(404) + .header("Content-Type", ProblemDetails.MEDIA_TYPE_JSON) + .entity(new ProblemDetails(404, "Resource does not exist", nseException.getMessage())) + .build(); + } catch (RuntimeException e) { + throw e; + } + + return Response.noContent().build(); + } + + @DELETE + @Path("/{name}/project") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Untags one or more projects.", + description = "

Requires permission PORTFOLIO_MANAGEMENT or PORTFOLIO_MANAGEMENT_UPDATE

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Projects untagged successfully." + ), + @ApiResponse( + responseCode = "404", + description = "A tag with the provided name does not exist.", + content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON) + ) + }) + @PermissionRequired({Permissions.Constants.PORTFOLIO_MANAGEMENT, Permissions.Constants.PORTFOLIO_MANAGEMENT_UPDATE}) + public Response untagProjects( + @Parameter(description = "Name of the tag", required = true) + @PathParam("name") final String tagName, + @Parameter( + description = "UUIDs of projects to untag", + required = true, + array = @ArraySchema(schema = @Schema(type = "string", format = "uuid")) + ) + @Size(min = 1, max = 100) final Set<@ValidUuid String> projectUuids + ) { + try (final var qm = new QueryManager(getAlpineRequest())) { + qm.untagProjects(tagName, projectUuids); + } catch (NoSuchElementException nseException) { + // TODO: Move this to an ExceptionMapper once https://github.com/stevespringett/Alpine/pull/588 is available. + return Response + .status(404) + .header("Content-Type", ProblemDetails.MEDIA_TYPE_JSON) + .entity(new ProblemDetails(404, "Resource does not exist", nseException.getMessage())) + .build(); + } catch (RuntimeException e) { + throw e; + } + + return Response.noContent().build(); + } + @GET @Path("/{name}/policy") @Produces(MediaType.APPLICATION_JSON) diff --git a/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java b/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java index d4f3881b8..1a08704d9 100644 --- a/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java +++ b/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java @@ -69,6 +69,15 @@ public class ProblemDetails { ) private URI instance; + public ProblemDetails() { + } + + public ProblemDetails(final int status, final String title, final String detail) { + this.status = status; + this.title = title; + this.detail = detail; + } + public URI getType() { return type; } diff --git a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java index 9a8962379..ac2f0c253 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -21,6 +21,8 @@ import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; import jakarta.json.JsonArray; +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.Response; import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; @@ -28,15 +30,19 @@ import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; import org.dependencytrack.resources.v1.exception.ConstraintViolationExceptionMapper; +import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.server.ResourceConfig; import org.junit.Assert; import org.junit.ClassRule; import org.junit.Test; +import java.util.Collections; import java.util.List; +import java.util.UUID; +import java.util.stream.IntStream; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.Assertions.assertThat; import static org.dependencytrack.model.ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED; import static org.hamcrest.CoreMatchers.equalTo; @@ -369,6 +375,275 @@ public void getTaggedProjectsWithNonLowerCaseTagNameTest() { assertThat(getPlainTextBody(response)).isEqualTo("[]"); } + @Test + public void tagProjectsTest() { + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final var projectB = new Project(); + projectB.setName("acme-app-b"); + qm.persist(projectB); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(List.of(projectA.getUuid(), projectB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(projectA.getTags()).satisfiesExactly(projectTag -> assertThat(projectTag.getName()).isEqualTo("foo")); + assertThat(projectB.getTags()).satisfiesExactly(projectTag -> assertThat(projectTag.getName()).isEqualTo("foo")); + } + + @Test + public void tagProjectsWithTagNotExistsTest() { + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(List.of(projectA.getUuid()))); + assertThat(response.getStatus()).isEqualTo(404); + assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 404, + "title": "Resource does not exist", + "detail": "A tag with name foo does not exist" + } + """); + } + + @Test + public void tagProjectsWithNoProjectUuidsTest() { + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(Collections.emptyList())); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "tagProjects.projectUuids", + "invalidValue": "[]" + } + ] + """); + } + + @Test + public void tagProjectsWithAclTest() { + qm.createConfigProperty( + ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() + ); + + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final var projectB = new Project(); + projectB.setName("acme-app-b"); + qm.persist(projectB); + + qm.createTag("foo"); + + projectA.addAccessTeam(team); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(List.of(projectA.getUuid(), projectB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(projectA.getTags()).satisfiesExactly(projectTag -> assertThat(projectTag.getName()).isEqualTo("foo")); + assertThat(projectB.getTags()).isEmpty(); + } + + @Test + public void tagProjectsWhenAlreadyTaggedTest() { + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final Tag tag = qm.createTag("foo"); + qm.bind(projectA, List.of(tag)); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(List.of(projectA.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(projectA.getTags()).satisfiesExactly(projectTag -> assertThat(projectTag.getName()).isEqualTo("foo")); + } + + @Test + public void untagProjectsTest() { + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final var projectB = new Project(); + projectB.setName("acme-app-b"); + qm.persist(projectB); + + final Tag tag = qm.createTag("foo"); + qm.bind(projectA, List.of(tag)); + qm.bind(projectB, List.of(tag)); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(projectA.getUuid(), projectB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(projectA.getTags()).isEmpty(); + assertThat(projectB.getTags()).isEmpty(); + } + + @Test + public void untagProjectsWithAclTest() { + qm.createConfigProperty( + ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() + ); + + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final var projectB = new Project(); + projectB.setName("acme-app-b"); + qm.persist(projectB); + + final Tag tag = qm.createTag("foo"); + qm.bind(projectA, List.of(tag)); + qm.bind(projectB, List.of(tag)); + + projectA.addAccessTeam(team); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(projectA.getUuid(), projectB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(projectA.getTags()).isEmpty(); + assertThat(projectB.getTags()).satisfiesExactly(projectTag -> assertThat(projectTag.getName()).isEqualTo("foo")); + } + + @Test + public void untagProjectsWithTagNotExistsTest() { + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(projectA.getUuid()))); + assertThat(response.getStatus()).isEqualTo(404); + assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 404, + "title": "Resource does not exist", + "detail": "A tag with name foo does not exist" + } + """); + } + + @Test + public void untagProjectsWithNoProjectUuidsTest() { + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(Collections.emptyList())); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "untagProjects.projectUuids", + "invalidValue": "[]" + } + ] + """); + } + + @Test + public void untagProjectsWithTooManyProjectUuidsTest() { + qm.createTag("foo"); + + final List projectUuids = IntStream.range(0, 101) + .mapToObj(ignored -> UUID.randomUUID()) + .map(UUID::toString) + .toList(); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(projectUuids)); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "untagProjects.projectUuids", + "invalidValue": "${json-unit.any-string}" + } + ] + """); + } + + @Test + public void untagProjectsWhenNotTaggedTest() { + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(projectA.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(projectA.getTags()).isEmpty(); + } + @Test public void getTaggedPoliciesTest() { final Tag tagFoo = qm.createTag("foo");