From d07abd59bb8924f3bc7055825d0f34ba887b2fc1 Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Fri, 20 Sep 2024 14:08:43 +0100 Subject: [PATCH 1/3] Support tagging of notification rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Niklas Co-Authored-By: Sébastien Delcoigne <934062+sebD@users.noreply.github.com> --- .../model/NotificationRule.java | 22 +- .../java/org/dependencytrack/model/Tag.java | 13 + .../persistence/NotificationQueryManager.java | 82 +++- .../persistence/PolicyQueryManager.java | 9 + .../persistence/QueryManager.java | 20 + .../persistence/TagQueryManager.java | 166 ++++++- .../resources/v1/TagResource.java | 136 +++++- .../resources/v1/vo/TagListResponseItem.java | 3 +- ...aggedNotificationRuleListResponseItem.java | 32 ++ .../resources/migration/changelog-v5.6.0.xml | 26 ++ .../v1/NotificationRuleResourceTest.java | 117 ++++- .../resources/v1/TagResourceTest.java | 442 +++++++++++++++++- 12 files changed, 1016 insertions(+), 52 deletions(-) create mode 100644 src/main/java/org/dependencytrack/resources/v1/vo/TaggedNotificationRuleListResponseItem.java diff --git a/src/main/java/org/dependencytrack/model/NotificationRule.java b/src/main/java/org/dependencytrack/model/NotificationRule.java index 5453d3330..18125fe55 100644 --- a/src/main/java/org/dependencytrack/model/NotificationRule.java +++ b/src/main/java/org/dependencytrack/model/NotificationRule.java @@ -26,6 +26,10 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import org.apache.commons.collections4.CollectionUtils; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationScope; @@ -40,10 +44,6 @@ import javax.jdo.annotations.Persistent; import javax.jdo.annotations.PrimaryKey; import javax.jdo.annotations.Unique; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; @@ -116,6 +116,12 @@ public class NotificationRule implements Serializable { @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC, version ASC")) private List projects; + @Persistent(table = "NOTIFICATIONRULE_TAGS", defaultFetchGroup = "true", mappedBy = "notificationRules") + @Join(column = "NOTIFICATIONRULE_ID") + @Element(column = "TAG_ID") + @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC")) + private List tags; + @Persistent(table = "NOTIFICATIONRULE_TEAMS", defaultFetchGroup = "true") @Join(column = "NOTIFICATIONRULE_ID") @Element(column = "TEAM_ID") @@ -215,6 +221,14 @@ public void setProjects(List projects) { this.projects = projects; } + public List getTags() { + return tags; + } + + public void setTags(final List tags) { + this.tags = tags; + } + public List getTeams() { return teams; } diff --git a/src/main/java/org/dependencytrack/model/Tag.java b/src/main/java/org/dependencytrack/model/Tag.java index 4626cc066..9846f709b 100644 --- a/src/main/java/org/dependencytrack/model/Tag.java +++ b/src/main/java/org/dependencytrack/model/Tag.java @@ -85,6 +85,11 @@ public Tag(final String name) { @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "vulnId ASC")) private List vulnerabilities; + @Persistent + @JsonIgnore + @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC")) + private List notificationRules; + public long getId() { return id; } @@ -125,6 +130,14 @@ public void setVulnerabilities(List vulnerabilities) { this.vulnerabilities = vulnerabilities; } + public List getNotificationRules() { + return notificationRules; + } + + public void setNotificationRules(final List notificationRules) { + this.notificationRules = notificationRules; + } + @Override public boolean equals(Object object) { if (object instanceof Tag) { diff --git a/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java b/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java index ee53a2ee0..e1a53bfd8 100644 --- a/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java @@ -25,13 +25,19 @@ import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Project; +import org.dependencytrack.model.Tag; import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.publisher.PublisherClass; import javax.jdo.PersistenceManager; import javax.jdo.Query; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import static org.dependencytrack.util.PersistenceUtil.assertPersistent; +import static org.dependencytrack.util.PersistenceUtil.assertPersistentAll; + public class NotificationQueryManager extends QueryManager implements IQueryManager { @@ -61,15 +67,17 @@ public class NotificationQueryManager extends QueryManager implements IQueryMana * @return a new NotificationRule */ public NotificationRule createNotificationRule(String name, NotificationScope scope, NotificationLevel level, NotificationPublisher publisher) { - final NotificationRule rule = new NotificationRule(); - rule.setName(name); - rule.setScope(scope); - rule.setNotificationLevel(level); - rule.setPublisher(publisher); - rule.setEnabled(true); - rule.setNotifyChildren(true); - rule.setLogSuccessfulPublish(false); - return persist(rule); + return callInTransaction(() -> { + final NotificationRule rule = new NotificationRule(); + rule.setName(name); + rule.setScope(scope); + rule.setNotificationLevel(level); + rule.setPublisher(publisher); + rule.setEnabled(true); + rule.setNotifyChildren(true); + rule.setLogSuccessfulPublish(false); + return persist(rule); + }); } /** @@ -78,15 +86,18 @@ public NotificationRule createNotificationRule(String name, NotificationScope sc * @return a NotificationRule */ public NotificationRule updateNotificationRule(NotificationRule transientRule) { - final NotificationRule rule = getObjectByUuid(NotificationRule.class, transientRule.getUuid()); - rule.setName(transientRule.getName()); - rule.setEnabled(transientRule.isEnabled()); - rule.setNotifyChildren(transientRule.isNotifyChildren()); - rule.setLogSuccessfulPublish(transientRule.isLogSuccessfulPublish()); - rule.setNotificationLevel(transientRule.getNotificationLevel()); - rule.setPublisherConfig(transientRule.getPublisherConfig()); - rule.setNotifyOn(transientRule.getNotifyOn()); - return persist(rule); + return callInTransaction(() -> { + final NotificationRule rule = getObjectByUuid(NotificationRule.class, transientRule.getUuid()); + rule.setName(transientRule.getName()); + rule.setEnabled(transientRule.isEnabled()); + rule.setNotifyChildren(transientRule.isNotifyChildren()); + rule.setLogSuccessfulPublish(transientRule.isLogSuccessfulPublish()); + rule.setNotificationLevel(transientRule.getNotificationLevel()); + rule.setPublisherConfig(transientRule.getPublisherConfig()); + rule.setNotifyOn(transientRule.getNotifyOn()); + bind(rule, resolveTags(transientRule.getTags())); + return persist(rule); + }); } /** @@ -244,4 +255,39 @@ public void deleteNotificationPublisher(final NotificationPublisher notification query.deletePersistentAll(notificationPublisher.getUuid()); delete(notificationPublisher); } + + /** + * @since 4.12.0 + */ + @Override + public boolean bind(final NotificationRule notificationRule, final Collection tags) { + assertPersistent(notificationRule, "notificationRule must be persistent"); + assertPersistentAll(tags, "tags must be persistent"); + + return callInTransaction(() -> { + boolean modified = false; + + for (final Tag existingTag : notificationRule.getTags()) { + if (!tags.contains(existingTag)) { + notificationRule.getTags().remove(existingTag); + existingTag.getNotificationRules().remove(notificationRule); + modified = true; + } + } + for (final Tag tag : tags) { + if (!notificationRule.getTags().contains(tag)) { + notificationRule.getTags().add(tag); + + if (tag.getNotificationRules() == null) { + tag.setNotificationRules(new ArrayList<>(List.of(notificationRule))); + } else if (!tag.getNotificationRules().contains(notificationRule)) { + tag.getNotificationRules().add(notificationRule); + } + + modified = true; + } + } + return modified; + }); + } } diff --git a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java index 9f246fa07..be3ce8463 100644 --- a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java @@ -684,6 +684,15 @@ public boolean bind(final Policy policy, final Collection tags) { assertPersistentAll(tags, "tags must be persistent"); return callInTransaction(() -> { boolean modified = false; + + for (final Tag existingTag : policy.getTags()) { + if (!tags.contains(existingTag)) { + policy.getTags().remove(existingTag); + existingTag.getPolicies().remove(policy); + modified = true; + } + } + for (final Tag tag : tags) { if (!policy.getTags().contains(tag)) { policy.getTags().add(tag); diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 6145f01fa..3a921fff3 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -613,6 +613,10 @@ public Tag createTag(final String name) { return getTagQueryManager().createTag(name); } + public List createTags(final List names) { + return getTagQueryManager().createTags(names); + } + public Project createProject(String name, String description, String version, List tags, Project parent, PackageURL purl, boolean active, boolean commitIndex) { return getProjectQueryManager().createProject(name, description, version, tags, parent, purl, active, commitIndex); } @@ -1418,6 +1422,10 @@ public boolean bind(final Policy policy, final Collection tags) { return getPolicyQueryManager().bind(policy, tags); } + public boolean bind(final NotificationRule notificationRule, final Collection tags) { + return getNotificationQueryManager().bind(notificationRule, tags); + } + public boolean hasAccessManagementPermission(final Object principal) { if (principal instanceof final UserPrincipal userPrincipal) { return hasAccessManagementPermission(userPrincipal); @@ -1472,6 +1480,18 @@ public PaginatedResult getTagsForPolicy(String policyUuid) { return getTagQueryManager().getTagsForPolicy(policyUuid); } + public List getTaggedNotificationRules(final String tagName) { + return getTagQueryManager().getTaggedNotificationRules(tagName); + } + + public void tagNotificationRules(final String tagName, final Collection notificationRuleUuids) { + getTagQueryManager().tagNotificationRules(tagName, notificationRuleUuids); + } + + public void untagNotificationRules(final String tagName, final Collection notificationRuleUuids) { + getTagQueryManager().untagNotificationRules(tagName, notificationRuleUuids); + } + /** * Fetch multiple objects from the data store by their ID. * diff --git a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java index f7d9d67de..2eef21525 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -21,12 +21,14 @@ import alpine.common.logging.Logger; import alpine.model.ApiKey; import alpine.model.UserPrincipal; +import alpine.persistence.NotSortableException; import alpine.persistence.OrderDirection; import alpine.persistence.PaginatedResult; import alpine.resources.AlpineRequest; import org.apache.commons.lang3.StringUtils; import org.dependencytrack.auth.Permissions; import org.dependencytrack.exception.TagOperationFailedException; +import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Policy; import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; @@ -72,11 +74,22 @@ public class TagQueryManager extends QueryManager implements IQueryManager { /** * @since 4.12.0 */ - public record TagListRow(String name, long projectCount, long policyCount, long totalCount) { - + public record TagListRow( + String name, + long projectCount, + long policyCount, + long notificationRuleCount, + long totalCount + ) { @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. - public TagListRow(String name, int projectCount, int policyCount, int totalCount) { - this(name, (long) projectCount, (long) policyCount, (long) totalCount); + public TagListRow( + final String name, + final int projectCount, + final int policyCount, + final int notificationRuleCount, + final int totalCount + ) { + this(name, (long) projectCount, (long) policyCount, (long) notificationRuleCount, (long) totalCount); } } @@ -98,12 +111,13 @@ public List getTags() { INNER JOIN "PROJECT" ON "PROJECT"."ID" = "PROJECTS_TAGS"."PROJECT_ID" WHERE "PROJECTS_TAGS"."TAG_ID" = "TAG"."ID" - AND %s - ) AS "projectCount" + AND %s) AS "projectCount" , (SELECT COUNT(*) FROM "POLICY_TAGS" - WHERE "POLICY_TAGS"."TAG_ID" = "TAG"."ID" - ) AS "policyCount" + WHERE "POLICY_TAGS"."TAG_ID" = "TAG"."ID") AS "policyCount" + , (SELECT COUNT(*) + FROM "NOTIFICATIONRULE_TAGS" + WHERE "NOTIFICATIONRULE_TAGS"."TAG_ID" = "TAG"."ID") AS "notificationRuleCount" , COUNT(*) OVER() AS "totalCount" FROM "TAG" """.formatted(projectAclCondition); @@ -117,7 +131,10 @@ public List getTags() { if (orderBy == null) { sqlQuery += " ORDER BY \"name\" ASC"; - } else if ("name".equals(orderBy) || "projectCount".equals(orderBy) || "policyCount".equals(orderBy)) { + } else if ("name".equals(orderBy) + || "projectCount".equals(orderBy) + || "policyCount".equals(orderBy) + || "notificationRuleCount".equals(orderBy)) { sqlQuery += " ORDER BY \"%s\" %s, \"ID\" ASC".formatted(orderBy, orderDirection == OrderDirection.DESCENDING ? "DESC" : "ASC"); } else { @@ -155,7 +172,8 @@ public record TagDeletionCandidateRow( String name, long projectCount, long accessibleProjectCount, - long policyCount + long policyCount, + long notificationRuleCount ) { @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. @@ -163,9 +181,10 @@ public TagDeletionCandidateRow( final String name, final int projectCount, final int accessibleProjectCount, - final int policyCount + final int policyCount, + final int notificationRuleCount ) { - this(name, (long) projectCount, (long) accessibleProjectCount, (long) policyCount); + this(name, (long) projectCount, (long) accessibleProjectCount, (long) policyCount, (long) notificationRuleCount); } } @@ -208,6 +227,11 @@ public void deleteTags(final Collection tagNames) { INNER JOIN "POLICY" ON "POLICY"."ID" = "POLICY_TAGS"."POLICY_ID" WHERE "POLICY_TAGS"."TAG_ID" = "TAG"."ID") AS "policyCount" + , (SELECT COUNT(*) + FROM "NOTIFICATIONRULE_TAGS" + INNER JOIN "NOTIFICATIONRULE" + ON "NOTIFICATIONRULE"."ID" = "NOTIFICATIONRULE_TAGS"."NOTIFICATIONRULE_ID" + WHERE "NOTIFICATIONRULE_TAGS"."TAG_ID" = "TAG"."ID") AS "notificationRuleCount" FROM "TAG" WHERE %s """.formatted(projectAclCondition, String.join(" OR ", tagNameFilters))); @@ -236,16 +260,20 @@ public void deleteTags(final Collection tagNames) { boolean hasPortfolioManagementPermission = false; boolean hasPolicyManagementPermission = false; + boolean hasSystemConfigurationPermission = false; if (principal == null) { hasPortfolioManagementPermission = true; hasPolicyManagementPermission = true; + hasSystemConfigurationPermission = true; } else { if (principal instanceof final ApiKey apiKey) { hasPortfolioManagementPermission = hasPermission(apiKey, Permissions.Constants.PORTFOLIO_MANAGEMENT); hasPolicyManagementPermission = hasPermission(apiKey, Permissions.Constants.POLICY_MANAGEMENT); + hasSystemConfigurationPermission = hasPermission(apiKey, Permissions.Constants.SYSTEM_CONFIGURATION); } else if (principal instanceof final UserPrincipal user) { hasPortfolioManagementPermission = hasPermission(user, Permissions.Constants.PORTFOLIO_MANAGEMENT, /* includeTeams */ true); hasPolicyManagementPermission = hasPermission(user, Permissions.Constants.POLICY_MANAGEMENT, /* includeTeams */ true); + hasSystemConfigurationPermission = hasPermission(user, Permissions.Constants.SYSTEM_CONFIGURATION, /* includeTeams */ true); } } @@ -271,6 +299,12 @@ public void deleteTags(final Collection tagNames) { The tag is assigned to %d policies, but the authenticated principal \ is missing the %s permission.""".formatted(row.policyCount(), Permissions.POLICY_MANAGEMENT)); } + + if (row.notificationRuleCount() > 0 && !hasSystemConfigurationPermission) { + errorByTagName.put(row.name(), """ + The tag is assigned to %d notification rules, but the authenticated principal \ + is missing the %s permission.""".formatted(row.notificationRuleCount(), Permissions.SYSTEM_CONFIGURATION)); + } } if (!errorByTagName.isEmpty()) { @@ -603,7 +637,7 @@ public Tag createTag(final String name) { * @param names the name(s) of the Tag(s) to create * @return the created Tag object(s) */ - private List createTags(final List names) { + public List createTags(final List names) { final List newTags = new ArrayList<>(); for (final String name : names) { final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); @@ -615,4 +649,110 @@ private List createTags(final List names) { } return new ArrayList<>(persist(newTags)); } + + /** + * @since 4.12.0 + */ + public record TaggedNotificationRuleRow(String uuid, String name, long totalCount) { + + @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. + public TaggedNotificationRuleRow(final String uuid, final String name, final int totalCount) { + this(uuid, name, (long) totalCount); + } + + } + + /** + * @since 4.12.0 + */ + @Override + public List getTaggedNotificationRules(final String tagName) { + // language=SQL + var sqlQuery = """ + SELECT "NOTIFICATIONRULE"."UUID" AS "uuid" + , "NOTIFICATIONRULE"."NAME" AS "name" + , COUNT(*) OVER() AS "totalCount" + FROM "NOTIFICATIONRULE" + INNER JOIN "NOTIFICATIONRULE_TAGS" + ON "NOTIFICATIONRULE_TAGS"."NOTIFICATIONRULE_ID" = "NOTIFICATIONRULE"."ID" + INNER JOIN "TAG" + ON "TAG"."ID" = "NOTIFICATIONRULE_TAGS"."TAG_ID" + WHERE "TAG"."NAME" = :tag + """; + + final var params = new HashMap(); + params.put("tag", tagName); + + if (filter != null) { + sqlQuery += " AND \"NOTIFICATIONRULE\".\"NAME\" LIKE :nameFilter"; + params.put("nameFilter", "%" + filter + "%"); + } + + if (orderBy == null) { + sqlQuery += " ORDER BY \"name\" ASC"; + } else if ("name".equals(orderBy)) { + sqlQuery += " ORDER BY \"%s\" %s".formatted(orderBy, + orderDirection == OrderDirection.DESCENDING ? "DESC" : "ASC"); + } else { + throw new NotSortableException("TaggedNotificationRule", orderBy, "Field does not exist or is not sortable"); + } + + sqlQuery += " " + getOffsetLimitSqlClause(); + + final Query query = pm.newQuery(Query.SQL, sqlQuery); + query.setNamedParameters(params); + try { + return new ArrayList<>(query.executeResultList(TaggedNotificationRuleRow.class)); + } finally { + query.closeAll(); + } + } + + /** + * @since 4.12.0 + */ + @Override + public void tagNotificationRules(final String tagName, final Collection notificationRuleUuids) { + runInTransaction(() -> { + final Tag tag = getTagByName(tagName); + if (tag == null) { + throw new NoSuchElementException("A tag with name %s does not exist".formatted(tagName)); + } + + final Query notificationRulesQuery = pm.newQuery(NotificationRule.class); + notificationRulesQuery.setFilter(":uuids.contains(uuid)"); + notificationRulesQuery.setParameters(notificationRuleUuids); + final List notificationRules = executeAndCloseList(notificationRulesQuery); + + for (final NotificationRule notificationRule : notificationRules) { + bind(notificationRule, List.of(tag)); + } + }); + } + + /** + * @since 4.12.0 + */ + @Override + public void untagNotificationRules(final String tagName, final Collection notificationRuleUuids) { + runInTransaction(() -> { + final Tag tag = getTagByName(tagName); + if (tag == null) { + throw new NoSuchElementException("A tag with name %s does not exist".formatted(tagName)); + } + + final Query notificationRulesQuery = pm.newQuery(NotificationRule.class); + notificationRulesQuery.setFilter(":uuids.contains(uuid)"); + notificationRulesQuery.setParameters(notificationRuleUuids); + final List notificationRules = executeAndCloseList(notificationRulesQuery); + + for (final NotificationRule notificationRule : notificationRules) { + if (notificationRule.getTags() == null || notificationRule.getTags().isEmpty()) { + continue; + } + + notificationRule.getTags().remove(tag); + } + }); + } } diff --git a/src/main/java/org/dependencytrack/resources/v1/TagResource.java b/src/main/java/org/dependencytrack/resources/v1/TagResource.java index 8b856e431..b702087ab 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -52,12 +52,14 @@ import org.dependencytrack.resources.v1.problems.ProblemDetails; import org.dependencytrack.resources.v1.problems.TagOperationProblemDetails; import org.dependencytrack.resources.v1.vo.TagListResponseItem; +import org.dependencytrack.resources.v1.vo.TaggedNotificationRuleListResponseItem; 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") @io.swagger.v3.oas.annotations.tags.Tag(name = "tag") @@ -90,7 +92,12 @@ public Response getAllTags() { } final List tags = tagListRows.stream() - .map(row -> new TagListResponseItem(row.name(), row.projectCount(), row.policyCount())) + .map(row -> new TagListResponseItem( + row.name(), + row.projectCount(), + row.policyCount(), + row.notificationRuleCount() + )) .toList(); final long totalCount = tagListRows.isEmpty() ? 0 : tagListRows.getFirst().totalCount(); return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); @@ -432,4 +439,131 @@ public Response getTagsForPolicy( return Response.ok(result.getObjects()).header(TOTAL_COUNT_HEADER, result.getTotal()).build(); } } + + @GET + @Path("/{name}/notificationRule") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all notification rules assigned to the given tag.", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "A list of all notification rules assigned to the given tag", + headers = @Header(name = TOTAL_COUNT_HEADER, description = "The total number of notification rules", schema = @Schema(format = "integer")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TaggedPolicyListResponseItem.class))) + ) + }) + @PaginatedApi + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response getTaggedNotificationRules( + @Parameter(description = "Name of the tag to get notification rules for", required = true) + @PathParam("name") final String tagName + ) { + // TODO: Should enforce lowercase for tagName once we are sure that + // users don't have any mixed-case tags in their system anymore. + // Will likely need a migration to cleanup existing tags for this. + + final List taggedNotificationRuleRows; + try (final var qm = new QueryManager(getAlpineRequest())) { + taggedNotificationRuleRows = qm.getTaggedNotificationRules(tagName); + } + + final List tags = taggedNotificationRuleRows.stream() + .map(row -> new TaggedNotificationRuleListResponseItem(UUID.fromString(row.uuid()), row.name())) + .toList(); + final long totalCount = taggedNotificationRuleRows.isEmpty() ? 0 : taggedNotificationRuleRows.getFirst().totalCount(); + return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); + } + + @POST + @Path("/{name}/notificationRule") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Tags one or more notification rules.", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Notification rules 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.SYSTEM_CONFIGURATION) + public Response tagNotificationRules( + @Parameter(description = "Name of the tag to assign", required = true) + @PathParam("name") final String tagName, + @Parameter( + description = "UUIDs of notification rules to tag", + required = true, + array = @ArraySchema(schema = @Schema(type = "string", format = "uuid")) + ) + @Size(min = 1, max = 100) final Set<@ValidUuid String> notificationRuleUuids + ) { + try (final var qm = new QueryManager(getAlpineRequest())) { + qm.tagNotificationRules(tagName, notificationRuleUuids); + } catch (NoSuchElementException nseException) { + 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}/notificationRule") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Untags one or more notification rules.", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Notification rules 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.SYSTEM_CONFIGURATION) + public Response untagNotificationRules( + @Parameter(description = "Name of the tag", required = true) + @PathParam("name") final String tagName, + @Parameter( + description = "UUIDs of notification rules to untag", + required = true, + array = @ArraySchema(schema = @Schema(type = "string", format = "uuid")) + ) + @Size(min = 1, max = 100) final Set<@ValidUuid String> policyUuids + ) { + try (final var qm = new QueryManager(getAlpineRequest())) { + qm.untagNotificationRules(tagName, policyUuids); + } catch (NoSuchElementException nseException) { + 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(); + } } diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java index d60e50244..505e53935 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java @@ -26,6 +26,7 @@ public record TagListResponseItem( @Parameter(description = "Name of the tag", required = true) String name, @Parameter(description = "Number of projects assigned to this tag") long projectCount, - @Parameter(description = "Number of policies assigned to this tag") long policyCount + @Parameter(description = "Number of policies assigned to this tag") long policyCount, + @Parameter(description = "Number of notification rules assigned to this tag") long notificationRuleCount ) { } \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedNotificationRuleListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedNotificationRuleListResponseItem.java new file mode 100644 index 000000000..19ea1e3a2 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedNotificationRuleListResponseItem.java @@ -0,0 +1,32 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import io.swagger.v3.oas.annotations.Parameter; + +import java.util.UUID; + +/** + * @since 4.12.0 + */ +public record TaggedNotificationRuleListResponseItem( + @Parameter(description = "UUID of the notification rule", required = true) UUID uuid, + @Parameter(description = "Name of the notification rule", required = true) String name +) { +} \ No newline at end of file diff --git a/src/main/resources/migration/changelog-v5.6.0.xml b/src/main/resources/migration/changelog-v5.6.0.xml index 62554857d..fc1bc3137 100644 --- a/src/main/resources/migration/changelog-v5.6.0.xml +++ b/src/main/resources/migration/changelog-v5.6.0.xml @@ -81,4 +81,30 @@ AND "PROPERTYNAME" = 'bom.validation.enabled'; + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java index 44870f129..c1a28784a 100644 --- a/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java @@ -23,6 +23,11 @@ import alpine.notification.NotificationLevel; import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; import org.dependencytrack.model.NotificationPublisher; @@ -39,11 +44,6 @@ import org.junit.ClassRule; import org.junit.Test; -import jakarta.json.JsonArray; -import jakarta.json.JsonObject; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -425,6 +425,7 @@ public void addTeamToRuleWithCustomEmailPublisherTest() { "scope": "PORTFOLIO", "notificationLevel": "INFORMATIONAL", "projects": [], + "tags": [], "teams": [ { "uuid": "${json-unit.matches:teamUuid}", @@ -513,4 +514,110 @@ public void removeTeamToRuleInvalidPublisherTest(){ String body = getPlainTextBody(response); Assert.assertEquals("Team subscriptions are only possible on notification rules with EMAIL publisher.", body); } + + @Test + public void updateNotificationRuleWithTagsTest() { + final NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + final NotificationRule rule = qm.createNotificationRule("Rule 1", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + + // Tag the rule with "foo" and "bar". + Response response = jersey.target(V1_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(/* language=JSON */ """ + { + "uuid": "%s", + "name": "Rule 1", + "scope": "PORTFOLIO", + "notificationLevel": "INFORMATIONAL", + "tags": [ + { + "name": "foo" + }, + { + "name": "bar" + } + ] + } + """.formatted(rule.getUuid()), MediaType.APPLICATION_JSON)); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)) + .withMatcher("ruleUuid", equalTo(rule.getUuid().toString())) + .isEqualTo(/* language=JSON */ """ + { + "name": "Rule 1", + "enabled": false, + "notifyChildren": false, + "logSuccessfulPublish": false, + "scope": "PORTFOLIO", + "notificationLevel": "INFORMATIONAL", + "projects": [], + "tags": [ + { + "name": "foo" + }, + { + "name": "bar" + } + ], + "teams": [], + "notifyOn": [], + "publisher": { + "name": "${json-unit.any-string}", + "description": "${json-unit.any-string}", + "publisherClass": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + "uuid": "${json-unit.matches:ruleUuid}" + } + """); + + // Replace the previous tags with only "baz". + response = jersey.target(V1_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(/* language=JSON */ """ + { + "uuid": "%s", + "name": "Rule 1", + "scope": "PORTFOLIO", + "notificationLevel": "INFORMATIONAL", + "tags": [ + { + "name": "baz" + } + ] + } + """.formatted(rule.getUuid()), MediaType.APPLICATION_JSON)); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)) + .withMatcher("ruleUuid", equalTo(rule.getUuid().toString())) + .isEqualTo(/* language=JSON */ """ + { + "name": "Rule 1", + "enabled": false, + "notifyChildren": false, + "logSuccessfulPublish": false, + "scope": "PORTFOLIO", + "notificationLevel": "INFORMATIONAL", + "projects": [], + "tags": [ + { + "name": "baz" + } + ], + "teams": [], + "notifyOn": [], + "publisher": { + "name": "${json-unit.any-string}", + "description": "${json-unit.any-string}", + "publisherClass": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + "uuid": "${json-unit.matches:ruleUuid}" + } + """); + } } diff --git a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java index e1d099749..66bff24ad 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -28,9 +28,11 @@ import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Policy; import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.resources.v1.exception.ConstraintViolationExceptionMapper; import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.server.ResourceConfig; @@ -99,6 +101,19 @@ public void getTagsTest() { qm.persist(policy); qm.bind(policy, List.of(tagBar)); + final var notificationRuleA = new NotificationRule(); + notificationRuleA.setName("rule-a"); + notificationRuleA.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRuleA); + + final var notificationRuleB = new NotificationRule(); + notificationRuleB.setName("rule-b"); + notificationRuleB.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRuleB); + + qm.bind(notificationRuleA, List.of(tagFoo)); + // NB: Not assigning notificationRuleB + final Response response = jersey.target(V1_TAG) .request() .header(X_API_KEY, apiKey) @@ -110,12 +125,14 @@ public void getTagsTest() { { "name": "bar", "projectCount": 1, - "policyCount": 1 + "policyCount": 1, + "notificationRuleCount": 0 }, { "name": "foo", "projectCount": 2, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 1 } ] """); @@ -141,17 +158,20 @@ public void getTagsWithPaginationTest() { { "name": "tag-1", "projectCount": 0, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 }, { "name": "tag-2", "projectCount": 0, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 }, { "name": "tag-3", "projectCount": 0, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 } ] """); @@ -169,12 +189,14 @@ public void getTagsWithPaginationTest() { { "name": "tag-4", "projectCount": 0, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 }, { "name": "tag-5", "projectCount": 0, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 } ] """); @@ -198,7 +220,8 @@ public void getTagsWithFilterTest() { { "name": "foo", "projectCount": 0, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 } ] """); @@ -234,12 +257,14 @@ public void getTagsSortByProjectCountTest() { { "name": "foo", "projectCount": 2, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 }, { "name": "bar", "projectCount": 1, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 } ] """); @@ -580,6 +605,7 @@ public void getTaggedProjectsWithPaginationTest() { @Test public void getTaggedProjectsWithTagNotExistsTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + qm.createTag("foo"); final Response response = jersey.target(V1_TAG + "/foo/project") .request() .header(X_API_KEY, apiKey) @@ -984,6 +1010,7 @@ public void getTaggedPoliciesWithPaginationTest() { @Test public void getTaggedPoliciesWithTagNotExistsTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + qm.createTag("foo"); final Response response = jersey.target(V1_TAG + "/foo/policy") .request() .header(X_API_KEY, apiKey) @@ -1280,4 +1307,399 @@ public void getTagWithNonUuidNameTest() { .get(); assertThat(response.getStatus()).isEqualTo(404); } + + @Test + public void deleteTagsWhenAssignedToNotificationRuleTest() { + initializeWithPermissions(Permissions.TAG_MANAGEMENT, Permissions.SYSTEM_CONFIGURATION); + + final Tag unusedTag = qm.createTag("foo"); + final Tag usedTag = qm.createTag("bar"); + + final var notificationRule = new NotificationRule(); + notificationRule.setName("rule"); + notificationRule.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRule); + + qm.bind(notificationRule, List.of(usedTag)); + + final Response response = jersey.target(V1_TAG) + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(unusedTag.getName(), usedTag.getName()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(qm.getTagByName("foo")).isNull(); + assertThat(qm.getTagByName("bar")).isNull(); + } + + @Test + public void deleteTagsWhenAssignedToNotificationRuleWithoutSystemConfigurationPermissionTest() { + initializeWithPermissions(Permissions.TAG_MANAGEMENT); + + final Tag unusedTag = qm.createTag("foo"); + final Tag usedTag = qm.createTag("bar"); + + final var notificationRule = new NotificationRule(); + notificationRule.setName("rule"); + notificationRule.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRule); + + qm.bind(notificationRule, List.of(usedTag)); + + final Response response = jersey.target(V1_TAG) + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(unusedTag.getName(), usedTag.getName()))); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 400, + "title": "Tag operation failed", + "detail": "The tag(s) bar could not be deleted", + "errors": { + "bar": "The tag is assigned to 1 notification rules, but the authenticated principal is missing the SYSTEM_CONFIGURATION permission." + } + } + """); + + qm.getPersistenceManager().evictAll(); + assertThat(qm.getTagByName("foo")).isNotNull(); + assertThat(qm.getTagByName("bar")).isNotNull(); + } + + @Test + public void getTaggedNotificationRulesTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + final Tag tagFoo = qm.createTag("foo"); + final Tag tagBar = qm.createTag("bar"); + + final var notificationRuleA = new NotificationRule(); + notificationRuleA.setName("rule-a"); + notificationRuleA.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRuleA); + + final var notificationRuleB = new NotificationRule(); + notificationRuleB.setName("rule-b"); + notificationRuleB.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRuleB); + + qm.bind(notificationRuleA, List.of(tagFoo)); + qm.bind(notificationRuleB, List.of(tagBar)); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("1"); + assertThatJson(getPlainTextBody(response)) + .withMatcher("notificationRuleUuidA", equalTo(notificationRuleA.getUuid().toString())) + .isEqualTo(""" + [ + { + "uuid": "${json-unit.matches:notificationRuleUuidA}", + "name": "rule-a" + } + ] + """); + } + + @Test + public void getTaggedNotificationRulesWithPaginationTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + final Tag tag = qm.createTag("foo"); + + for (int i = 0; i < 5; i++) { + final var notificationRule = new NotificationRule(); + notificationRule.setName("rule-" + (i+1)); + notificationRule.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRule); + + qm.bind(notificationRule, List.of(tag)); + } + + Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .queryParam("pageNumber", "1") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "uuid": "${json-unit.any-string}", + "name": "rule-1" + }, + { + "uuid": "${json-unit.any-string}", + "name": "rule-2" + }, + { + "uuid": "${json-unit.any-string}", + "name": "rule-3" + } + ] + """); + + response = jersey.target(V1_TAG + "/foo/notificationRule") + .queryParam("pageNumber", "2") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "uuid": "${json-unit.any-string}", + "name": "rule-4" + }, + { + "uuid": "${json-unit.any-string}", + "name": "rule-5" + } + ] + """); + } + + @Test + public void getTaggedNotificationRulesWithTagNotExistsTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); + } + + @Test + public void getTaggedNotificationRulesWithNonLowerCaseTagNameTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/Foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); + } + + @Test + public void tagNotificationRulesTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + final var notificationRuleA = new NotificationRule(); + notificationRuleA.setName("rule-a"); + notificationRuleA.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRuleA); + + final var notificationRuleB = new NotificationRule(); + notificationRuleB.setName("rule-b"); + notificationRuleB.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRuleB); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(List.of(notificationRuleA.getUuid(), notificationRuleB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(notificationRuleA.getTags()).satisfiesExactly(ruleTag -> assertThat(ruleTag.getName()).isEqualTo("foo")); + assertThat(notificationRuleB.getTags()).satisfiesExactly(ruleTag -> assertThat(ruleTag.getName()).isEqualTo("foo")); + } + + @Test + public void tagNotificationRulesWithTagNotExistsTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + final var notificationRule = new NotificationRule(); + notificationRule.setName("rule"); + notificationRule.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRule); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(List.of(notificationRule.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 tagNotificationRulesWithNoRuleUuidsTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .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": "tagNotificationRules.notificationRuleUuids", + "invalidValue": "[]" + } + ] + """); + } + + @Test + public void untagNotificationRulesTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + final var notificationRuleA = new NotificationRule(); + notificationRuleA.setName("rule-a"); + notificationRuleA.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRuleA); + + final var notificationRuleB = new NotificationRule(); + notificationRuleB.setName("rule-b"); + notificationRuleB.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRuleB); + + final Tag tag = qm.createTag("foo"); + qm.bind(notificationRuleA, List.of(tag)); + qm.bind(notificationRuleB, List.of(tag)); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(notificationRuleA.getUuid(), notificationRuleB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(notificationRuleA.getTags()).isEmpty(); + assertThat(notificationRuleB.getTags()).isEmpty(); + } + + @Test + public void untagNotificationRulesWithTagNotExistsTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + final var notificationRule = new NotificationRule(); + notificationRule.setName("rule"); + notificationRule.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRule); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(notificationRule.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 untagNotificationRulesWithNoProjectUuidsTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .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": "untagNotificationRules.policyUuids", + "invalidValue": "[]" + } + ] + """); + } + + @Test + public void untagNotificationRulesWithTooManyRuleUuidsTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + qm.createTag("foo"); + + final List policyUuids = IntStream.range(0, 101) + .mapToObj(ignored -> UUID.randomUUID()) + .map(UUID::toString) + .toList(); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(policyUuids)); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "untagNotificationRules.policyUuids", + "invalidValue": "${json-unit.any-string}" + } + ] + """); + } + + @Test + public void untagNotificationRulesWhenNotTaggedTest() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + final var notificationRule = new NotificationRule(); + notificationRule.setName("rule"); + notificationRule.setScope(NotificationScope.PORTFOLIO); + qm.persist(notificationRule); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/notificationRule") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(notificationRule.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(notificationRule.getTags()).isEmpty(); + } } From d7b0540a9b8757b45a6280746cd825428ab28ec3 Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Wed, 25 Sep 2024 10:45:10 +0100 Subject: [PATCH 2/3] fix test --- .../java/org/dependencytrack/persistence/TagQueryManager.java | 4 ++-- .../java/org/dependencytrack/resources/v1/TagResource.java | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java index 2eef21525..2eceb48af 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -653,10 +653,10 @@ public List createTags(final List names) { /** * @since 4.12.0 */ - public record TaggedNotificationRuleRow(String uuid, String name, long totalCount) { + public record TaggedNotificationRuleRow(UUID uuid, String name, long totalCount) { @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. - public TaggedNotificationRuleRow(final String uuid, final String name, final int totalCount) { + public TaggedNotificationRuleRow(final UUID uuid, final String name, final int totalCount) { this(uuid, name, (long) totalCount); } diff --git a/src/main/java/org/dependencytrack/resources/v1/TagResource.java b/src/main/java/org/dependencytrack/resources/v1/TagResource.java index b702087ab..7a7b21d35 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -59,7 +59,6 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.Set; -import java.util.UUID; @Path("/v1/tag") @io.swagger.v3.oas.annotations.tags.Tag(name = "tag") @@ -471,7 +470,7 @@ public Response getTaggedNotificationRules( } final List tags = taggedNotificationRuleRows.stream() - .map(row -> new TaggedNotificationRuleListResponseItem(UUID.fromString(row.uuid()), row.name())) + .map(row -> new TaggedNotificationRuleListResponseItem(row.uuid(), row.name())) .toList(); final long totalCount = taggedNotificationRuleRows.isEmpty() ? 0 : taggedNotificationRuleRows.getFirst().totalCount(); return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); From 7d086ddcf94a9f74b26f21b30d0061aed9b1fe30 Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Wed, 25 Sep 2024 11:14:20 +0100 Subject: [PATCH 3/3] Update TagQueryManager.java --- .../persistence/TagQueryManager.java | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java index 2eceb48af..ae98fba39 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -81,17 +81,6 @@ public record TagListRow( long notificationRuleCount, long totalCount ) { - @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. - public TagListRow( - final String name, - final int projectCount, - final int policyCount, - final int notificationRuleCount, - final int totalCount - ) { - this(name, (long) projectCount, (long) policyCount, (long) notificationRuleCount, (long) totalCount); - } - } /** @@ -157,12 +146,6 @@ public List getTags() { * @since 4.12.0 */ public record TaggedProjectRow(UUID uuid, String name, String version, long totalCount) { - - @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. - public TaggedProjectRow(UUID uuid, String name, String version, int totalCount) { - this(uuid, name, version, (long) totalCount); - } - } /** @@ -175,18 +158,6 @@ public record TagDeletionCandidateRow( long policyCount, long notificationRuleCount ) { - - @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. - public TagDeletionCandidateRow( - final String name, - final int projectCount, - final int accessibleProjectCount, - final int policyCount, - final int notificationRuleCount - ) { - this(name, (long) projectCount, (long) accessibleProjectCount, (long) policyCount, (long) notificationRuleCount); - } - } /** @@ -435,12 +406,6 @@ public void untagProjects(final String tagName, final Collection project * @since 4.12.0 */ public record TaggedPolicyRow(UUID uuid, String name, long totalCount) { - - @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. - public TaggedPolicyRow(UUID uuid, String name, int totalCount) { - this(uuid, name, (long) totalCount); - } - } /** @@ -654,12 +619,6 @@ public List createTags(final List names) { * @since 4.12.0 */ public record TaggedNotificationRuleRow(UUID uuid, String name, long totalCount) { - - @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. - public TaggedNotificationRuleRow(final UUID uuid, final String name, final int totalCount) { - this(uuid, name, (long) totalCount); - } - } /**