Add REST endpoints for bulk tagging & un-tagging of projects

Signed-off-by: nscuro <nscuro@protonmail.com>
This commit is contained in:
nscuro 2024-06-29 19:42:44 +02:00
parent a0407b46af
commit c41717f515
No known key found for this signature in database
8 changed files with 498 additions and 50 deletions

View file

@ -31,7 +31,7 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME;
/** /**
* @since 4.11.0 * @since 4.11.0
*/ */
@Target({ElementType.FIELD, ElementType.PARAMETER}) @Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE_USE})
@Constraint(validatedBy = {}) @Constraint(validatedBy = {})
@Retention(RUNTIME) @Retention(RUNTIME)
@ReportAsSingleViolation @ReportAsSingleViolation

View file

@ -20,10 +20,7 @@ package org.dependencytrack.persistence;
import alpine.common.logging.Logger; import alpine.common.logging.Logger;
import alpine.event.framework.Event; import alpine.event.framework.Event;
import alpine.model.ApiKey;
import alpine.model.IConfigProperty; import alpine.model.IConfigProperty;
import alpine.model.Team;
import alpine.model.UserPrincipal;
import alpine.persistence.PaginatedResult; import alpine.persistence.PaginatedResult;
import alpine.resources.AlpineRequest; import alpine.resources.AlpineRequest;
import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.MalformedPackageURLException;
@ -33,7 +30,6 @@ import org.dependencytrack.event.IndexEvent;
import org.dependencytrack.model.Component; import org.dependencytrack.model.Component;
import org.dependencytrack.model.ComponentIdentity; import org.dependencytrack.model.ComponentIdentity;
import org.dependencytrack.model.ComponentProperty; import org.dependencytrack.model.ComponentProperty;
import org.dependencytrack.model.ConfigPropertyConstants;
import org.dependencytrack.model.Project; import org.dependencytrack.model.Project;
import org.dependencytrack.model.RepositoryMetaComponent; import org.dependencytrack.model.RepositoryMetaComponent;
import org.dependencytrack.model.RepositoryType; import org.dependencytrack.model.RepositoryType;
@ -680,48 +676,6 @@ final class ComponentQueryManager extends QueryManager implements IQueryManager
} }
} }
/**
* A similar method exists in ProjectQueryManager
*/
private void preprocessACLs(final Query<Component> query, final String inputFilter, final Map<String, Object> params, final boolean bypass) {
if (super.principal != null && isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && !bypass) {
final List<Team> 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<String, Component> getDependencyGraphForComponents(Project project, List<Component> components) { public Map<String, Component> getDependencyGraphForComponents(Project project, List<Component> components) {
Map<String, Component> dependencyGraph = new HashMap<>(); Map<String, Component> dependencyGraph = new HashMap<>();
if (project.getDirectDependencies() == null || project.getDirectDependencies().isBlank()) { if (project.getDirectDependencies() == null || project.getDirectDependencies().isBlank()) {

View file

@ -31,6 +31,7 @@ import alpine.resources.AlpineRequest;
import com.github.packageurl.PackageURL; import com.github.packageurl.PackageURL;
import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.datanucleus.api.jdo.JDOQuery;
import org.dependencytrack.auth.Permissions; import org.dependencytrack.auth.Permissions;
import org.dependencytrack.event.IndexEvent; import org.dependencytrack.event.IndexEvent;
import org.dependencytrack.model.Analysis; import org.dependencytrack.model.Analysis;
@ -55,6 +56,8 @@ import org.dependencytrack.util.NotificationUtil;
import javax.jdo.FetchPlan; import javax.jdo.FetchPlan;
import javax.jdo.PersistenceManager; import javax.jdo.PersistenceManager;
import javax.jdo.Query; import javax.jdo.Query;
import javax.jdo.metadata.MemberMetadata;
import javax.jdo.metadata.TypeMetadata;
import java.security.Principal; import java.security.Principal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
@ -894,7 +897,36 @@ final class ProjectQueryManager extends QueryManager implements IQueryManager {
/** /**
* A similar method exists in ComponentQueryManager * A similar method exists in ComponentQueryManager
*/ */
private void preprocessACLs(final Query<Project> query, final String inputFilter, final Map<String, Object> params, final boolean bypass) { @Override
void preprocessACLs(final Query<?> query, final String inputFilter, final Map<String, Object> 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) { if (super.principal != null && isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && !bypass) {
final List<Team> teams; final List<Team> teams;
if (super.principal instanceof final UserPrincipal userPrincipal) { if (super.principal instanceof final UserPrincipal userPrincipal) {
@ -911,10 +943,14 @@ final class ProjectQueryManager extends QueryManager implements IQueryManager {
return; return;
} }
} }
if (teams != null && teams.size() > 0) { if (teams != null && !teams.isEmpty()) {
final StringBuilder sb = new StringBuilder(); final StringBuilder sb = new StringBuilder();
for (int i = 0, teamsSize = teams.size(); i < teamsSize; i++) { for (int i = 0, teamsSize = teams.size(); i < teamsSize; i++) {
final Team team = super.getObjectById(Team.class, teams.get(i).getId()); 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(") "); sb.append(" accessTeams.contains(:team").append(i).append(") ");
params.put("team" + i, team); params.put("team" + i, team);
if (i < teamsSize-1) { if (i < teamsSize-1) {
@ -922,7 +958,7 @@ final class ProjectQueryManager extends QueryManager implements IQueryManager {
} }
} }
if (inputFilter != null && !inputFilter.isBlank()) { if (inputFilter != null && !inputFilter.isBlank()) {
query.setFilter(inputFilter + " && (" + sb.toString() + ")"); query.setFilter(inputFilter + " && (" + sb + ")");
} else { } else {
query.setFilter(sb.toString()); query.setFilter(sb.toString());
} }

View file

@ -433,6 +433,10 @@ public class QueryManager extends AlpineQueryManager {
return getProjectQueryManager().hasAccess(principal, project); return getProjectQueryManager().hasAccess(principal, project);
} }
void preprocessACLs(final Query<?> query, final String inputFilter, final Map<String, Object> 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) { public PaginatedResult getProjects(final Tag tag, final boolean includeMetrics, final boolean excludeInactive, final boolean onlyRoot) {
return getProjectQueryManager().getProjects(tag, includeMetrics, excludeInactive, onlyRoot); return getProjectQueryManager().getProjects(tag, includeMetrics, excludeInactive, onlyRoot);
} }
@ -1336,6 +1340,14 @@ public class QueryManager extends AlpineQueryManager {
return getTagQueryManager().getTaggedProjects(tagName); return getTagQueryManager().getTaggedProjects(tagName);
} }
public void tagProjects(final String tagName, final Collection<String> projectUuids) {
getTagQueryManager().tagProjects(tagName, projectUuids);
}
public void untagProjects(final String tagName, final Collection<String> projectUuids) {
getTagQueryManager().untagProjects(tagName, projectUuids);
}
public List<TagQueryManager.TaggedPolicyRow> getTaggedPolicies(final String tagName) { public List<TagQueryManager.TaggedPolicyRow> getTaggedPolicies(final String tagName) {
return getTagQueryManager().getTaggedPolicies(tagName); return getTagQueryManager().getTaggedPolicies(tagName);
} }

View file

@ -29,10 +29,12 @@ import org.dependencytrack.model.Tag;
import javax.jdo.PersistenceManager; import javax.jdo.PersistenceManager;
import javax.jdo.Query; import javax.jdo.Query;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.NoSuchElementException;
import java.util.stream.Stream; import java.util.stream.Stream;
public class TagQueryManager extends QueryManager implements IQueryManager { public class TagQueryManager extends QueryManager implements IQueryManager {
@ -191,6 +193,63 @@ public class TagQueryManager extends QueryManager implements IQueryManager {
} }
} }
/**
* @since 4.12.0
*/
@Override
public void tagProjects(final String tagName, final Collection<String> 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<Project> projectsQuery = pm.newQuery(Project.class);
final var params = new HashMap<String, Object>(Map.of("uuids", projectUuids));
preprocessACLs(projectsQuery, ":uuids.contains(uuid)", params, /* bypass */ false);
projectsQuery.setNamedParameters(params);
final List<Project> 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<String> 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<Project> projectsQuery = pm.newQuery(Project.class);
final var params = new HashMap<String, Object>(Map.of("uuids", projectUuids));
preprocessACLs(projectsQuery, ":uuids.contains(uuid)", params, /* bypass */ false);
projectsQuery.setNamedParameters(params);
final List<Project> projects = executeAndCloseList(projectsQuery);
for (final Project project : projects) {
if (project.getTags() == null || project.getTags().isEmpty()) {
continue;
}
project.getTags().remove(tag);
}
});
}
/** /**
* @since 4.12.0 * @since 4.12.0
*/ */

View file

@ -39,17 +39,24 @@ import org.dependencytrack.persistence.TagQueryManager.TagListRow;
import org.dependencytrack.persistence.TagQueryManager.TaggedPolicyRow; import org.dependencytrack.persistence.TagQueryManager.TaggedPolicyRow;
import org.dependencytrack.persistence.TagQueryManager.TaggedProjectRow; import org.dependencytrack.persistence.TagQueryManager.TaggedProjectRow;
import org.dependencytrack.resources.v1.openapi.PaginatedApi; 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.TagListResponseItem;
import org.dependencytrack.resources.v1.vo.TaggedPolicyListResponseItem; import org.dependencytrack.resources.v1.vo.TaggedPolicyListResponseItem;
import org.dependencytrack.resources.v1.vo.TaggedProjectListResponseItem; import org.dependencytrack.resources.v1.vo.TaggedProjectListResponseItem;
import jakarta.validation.constraints.Size;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.List; import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Path("/v1/tag") @Path("/v1/tag")
@ -126,6 +133,102 @@ public class TagResource extends AlpineResource {
return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); 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 = "<p>Requires permission <strong>PORTFOLIO_MANAGEMENT</strong></p>"
)
@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)
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 (RuntimeException e) {
// TODO: Move this to an ExceptionMapper once https://github.com/stevespringett/Alpine/pull/588 is available.
if (e.getCause() instanceof final NoSuchElementException nseException) {
return Response
.status(404)
.header("Content-Type", ProblemDetails.MEDIA_TYPE_JSON)
.entity(new ProblemDetails(404, "Resource does not exist", nseException.getMessage()))
.build();
}
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 = "<p>Requires permission <strong>PORTFOLIO_MANAGEMENT</strong></p>"
)
@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)
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 (RuntimeException e) {
// TODO: Move this to an ExceptionMapper once https://github.com/stevespringett/Alpine/pull/588 is available.
if (e.getCause() instanceof final NoSuchElementException nseException) {
return Response
.status(404)
.header("Content-Type", ProblemDetails.MEDIA_TYPE_JSON)
.entity(new ProblemDetails(404, "Resource does not exist", nseException.getMessage()))
.build();
}
throw e;
}
return Response.noContent().build();
}
@GET @GET
@Path("/{name}/policy") @Path("/{name}/policy")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)

View file

@ -69,6 +69,15 @@ public class ProblemDetails {
) )
private URI instance; 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() { public URI getType() {
return type; return type;
} }

View file

@ -8,14 +8,20 @@ import org.dependencytrack.model.Policy;
import org.dependencytrack.model.Project; import org.dependencytrack.model.Project;
import org.dependencytrack.model.Tag; import org.dependencytrack.model.Tag;
import org.dependencytrack.resources.v1.exception.ConstraintViolationExceptionMapper; import org.dependencytrack.resources.v1.exception.ConstraintViolationExceptionMapper;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.ResourceConfig;
import org.junit.Assert; import org.junit.Assert;
import org.junit.ClassRule; import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import jakarta.json.JsonArray; import jakarta.json.JsonArray;
import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.stream.IntStream;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -351,6 +357,275 @@ public class TagResourceTest extends ResourceTest {
assertThat(getPlainTextBody(response)).isEqualTo("[]"); 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.arg1",
"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.arg1",
"invalidValue": "[]"
}
]
""");
}
@Test
public void untagProjectsWithTooManyProjectUuidsTest() {
qm.createTag("foo");
final List<String> 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.arg1",
"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 @Test
public void getTaggedPoliciesTest() { public void getTaggedPoliciesTest() {
final Tag tagFoo = qm.createTag("foo"); final Tag tagFoo = qm.createTag("foo");