From ba0ccb421e13ccd93a37f945cca2a5ded2736554 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sun, 7 Jul 2024 15:56:46 +0200 Subject: [PATCH] Support tagging of notification rules Supersedes #3506 Co-authored-by: Sebastien Delcoigne Signed-off-by: nscuro --- .../model/NotificationRule.java | 14 + .../java/org/dependencytrack/model/Tag.java | 13 + .../notification/NotificationRouter.java | 221 ++++++--- .../persistence/NotificationQueryManager.java | 85 +++- .../persistence/PolicyQueryManager.java | 8 + .../persistence/ProjectQueryManager.java | 77 --- .../persistence/QueryManager.java | 30 +- .../persistence/TagQueryManager.java | 238 +++++++++- .../resources/v1/TagResource.java | 120 ++++- .../resources/v1/vo/TagListResponseItem.java | 3 +- ...aggedNotificationRuleListResponseItem.java | 32 ++ .../notification/NotificationRouterTest.java | 135 +++++- .../v1/NotificationRuleResourceTest.java | 107 +++++ .../resources/v1/TagResourceTest.java | 444 +++++++++++++++++- 14 files changed, 1335 insertions(+), 192 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 202eacf1a5..9fdad4c536 100644 --- a/src/main/java/org/dependencytrack/model/NotificationRule.java +++ b/src/main/java/org/dependencytrack/model/NotificationRule.java @@ -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") @@ -214,6 +220,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 5e7367cfae..a681cd05e1 100644 --- a/src/main/java/org/dependencytrack/model/Tag.java +++ b/src/main/java/org/dependencytrack/model/Tag.java @@ -63,6 +63,11 @@ public class Tag implements Serializable { @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The name may only contain printable characters") private String name; + @Persistent + @JsonIgnore + @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC")) + private List notificationRules; + @Persistent @JsonIgnore @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC")) @@ -96,6 +101,14 @@ public void setName(String name) { this.name = name; } + public List getNotificationRules() { + return notificationRules; + } + + public void setNotificationRules(final List notificationRules) { + this.notificationRules = notificationRules; + } + public List getPolicies() { return policies; } diff --git a/src/main/java/org/dependencytrack/notification/NotificationRouter.java b/src/main/java/org/dependencytrack/notification/NotificationRouter.java index a5b6cf7aa5..cd9537fcb5 100644 --- a/src/main/java/org/dependencytrack/notification/NotificationRouter.java +++ b/src/main/java/org/dependencytrack/notification/NotificationRouter.java @@ -26,6 +26,7 @@ import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Project; +import org.dependencytrack.model.Tag; import org.dependencytrack.notification.publisher.PublishContext; import org.dependencytrack.notification.publisher.Publisher; import org.dependencytrack.notification.publisher.SendMailPublisher; @@ -51,8 +52,10 @@ import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.function.Predicate; import java.util.stream.Collectors; +import static java.util.Objects.requireNonNull; import static org.dependencytrack.notification.publisher.Publisher.CONFIG_TEMPLATE_KEY; import static org.dependencytrack.notification.publisher.Publisher.CONFIG_TEMPLATE_MIME_TYPE_KEY; @@ -103,30 +106,75 @@ public void inform(final Notification notification) { } } - public Notification restrictNotificationToRuleProjects(final Notification initialNotification, final NotificationRule rule) { - Notification restrictedNotification = initialNotification; - if (canRestrictNotificationToRuleProjects(initialNotification, rule)) { - Set ruleProjectsUuids = rule.getProjects().stream().map(Project::getUuid).map(UUID::toString).collect(Collectors.toSet()); - restrictedNotification = new Notification(); - restrictedNotification.setGroup(initialNotification.getGroup()); - restrictedNotification.setLevel(initialNotification.getLevel()); - restrictedNotification.scope(initialNotification.getScope()); - restrictedNotification.setContent(initialNotification.getContent()); - restrictedNotification.setTitle(initialNotification.getTitle()); - restrictedNotification.setTimestamp(initialNotification.getTimestamp()); - if (initialNotification.getSubject() instanceof final NewVulnerabilityIdentified subject) { - Set restrictedProjects = subject.getAffectedProjects().stream().filter(project -> ruleProjectsUuids.contains(project.getUuid().toString())).collect(Collectors.toSet()); - NewVulnerabilityIdentified restrictedSubject = new NewVulnerabilityIdentified(subject.getVulnerability(), subject.getComponent(), restrictedProjects, null); - restrictedNotification.setSubject(restrictedSubject); - } + private Notification restrictNotificationToRuleProjects(final Notification notification, final NotificationRule rule) { + if (!(notification.getSubject() instanceof final NewVulnerabilityIdentified subject) + || subject.getAffectedProjects() == null || subject.getAffectedProjects().isEmpty()) { + return notification; + } + + final boolean shouldFilterOnRuleProjects = rule.getProjects() != null && !rule.getProjects().isEmpty(); + final boolean shouldFilterOnRuleTags = rule.getTags() != null && !rule.getTags().isEmpty(); + if (!shouldFilterOnRuleProjects && !shouldFilterOnRuleTags) { + return notification; + } + + final Predicate projectFilterPredicate; + if (shouldFilterOnRuleProjects && shouldFilterOnRuleTags) { + projectFilterPredicate = matchesAnyProjectOfRule(rule).or(hasAnyTagOfRule(rule)); + } else if (shouldFilterOnRuleProjects) { + projectFilterPredicate = matchesAnyProjectOfRule(rule); + } else { + projectFilterPredicate = hasAnyTagOfRule(rule); + } + + final Set filteredAffectedProjects = subject.getAffectedProjects().stream() + .filter(projectFilterPredicate) + .collect(Collectors.toSet()); + if (filteredAffectedProjects.size() == subject.getAffectedProjects().size()) { + return notification; } - return restrictedNotification; + + final var filteredSubject = new NewVulnerabilityIdentified( + subject.getVulnerability(), + subject.getComponent(), + filteredAffectedProjects, + subject.getVulnerabilityAnalysisLevel() + ); + + return new Notification() + .group(notification.getGroup()) + .scope(notification.getScope()) + .level(notification.getLevel()) + .title(notification.getTitle()) + .content(notification.getContent()) + .timestamp(notification.getTimestamp()) + .subject(filteredSubject); + } + + private Predicate matchesAnyProjectOfRule(final NotificationRule rule) { + requireNonNull(rule.getProjects()); + + return project -> rule.getProjects().stream() + .map(Project::getUuid) + .anyMatch(project.getUuid()::equals); } - private boolean canRestrictNotificationToRuleProjects(final Notification initialNotification, final NotificationRule rule) { - return initialNotification.getSubject() instanceof NewVulnerabilityIdentified - && rule.getProjects() != null - && !rule.getProjects().isEmpty(); + private Predicate hasAnyTagOfRule(final NotificationRule rule) { + requireNonNull(rule.getTags()); + + return project -> { + if (project.getTags() == null || project.getTags().isEmpty()) { + return false; + } + + final Set projectTagNames = project.getTags().stream() + .map(Tag::getName) + .collect(Collectors.toSet()); + + return rule.getTags().stream() + .map(Tag::getName) + .anyMatch(projectTagNames::contains); + }; } List resolveRules(final PublishContext ctx, final Notification notification) { @@ -160,24 +208,7 @@ List resolveRules(final PublishContext ctx, final Notification if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final NewVulnerabilityIdentified subject) { - // If the rule specified one or more projects as targets, reduce the execution - // of the notification down to those projects that the rule matches and which - // also match project the component is included in. - // NOTE: This logic is slightly different from what is implemented in limitToProject() - for (final NotificationRule rule : result) { - if (rule.getNotifyOn().contains(NotificationGroup.valueOf(notification.getGroup()))) { - if (rule.getProjects() != null && !rule.getProjects().isEmpty() - && subject.getComponent() != null && subject.getComponent().getProject() != null) { - for (final Project project : rule.getProjects()) { - if (subject.getComponent().getProject().getUuid().equals(project.getUuid()) || (Boolean.TRUE.equals(rule.isNotifyChildren() && checkIfChildrenAreAffected(project, subject.getComponent().getProject().getUuid())))) { - rules.add(rule); - } - } - } else { - rules.add(rule); - } - } - } + limitToProject(ctx, rules, result, notification, subject.getComponent().getProject()); } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final NewVulnerableDependency subject) { limitToProject(ctx, rules, result, notification, subject.getComponent().getProject()); @@ -218,44 +249,92 @@ List resolveRules(final PublishContext ctx, final Notification * of the notification down to those projects that the rule matches and which * also match projects affected by the vulnerability. */ - private void limitToProject(final PublishContext ctx, final List applicableRules, - final List rules, final Notification notification, - final Project limitToProject) { + private void limitToProject( + final PublishContext ctx, + final List applicableRules, + final List rules, + final Notification notification, + final Project limitToProject + ) { + requireNonNull(limitToProject, "limitToProject must not be null"); + for (final NotificationRule rule : rules) { final PublishContext ruleCtx = ctx.withRule(rule); - if (rule.getNotifyOn().contains(NotificationGroup.valueOf(notification.getGroup()))) { - if (rule.getProjects() != null && !rule.getProjects().isEmpty()) { - for (final Project project : rule.getProjects()) { - if (project.getUuid().equals(limitToProject.getUuid())) { - LOGGER.debug("Project %s is part of the \"limit to\" list of the rule; Rule is applicable (%s)" - .formatted(limitToProject.getUuid(), ruleCtx)); - applicableRules.add(rule); - } else if (rule.isNotifyChildren()) { - final boolean isChildOfLimitToProject = checkIfChildrenAreAffected(project, limitToProject.getUuid()); - if (isChildOfLimitToProject) { - LOGGER.debug("Project %s is child of \"limit to\" project %s; Rule is applicable (%s)" - .formatted(limitToProject.getUuid(), project.getUuid(), ruleCtx)); - applicableRules.add(rule); - } else { - LOGGER.debug("Project %s is not a child of \"limit to\" project %s; Rule is not applicable (%s)" - .formatted(limitToProject.getUuid(), project.getUuid(), ruleCtx)); - } + + if (!rule.getNotifyOn().contains(NotificationGroup.valueOf(notification.getGroup()))) { + continue; + } + + final boolean isLimitedToProjects = rule.getProjects() != null && !rule.getProjects().isEmpty(); + final boolean isLimitedToTags = rule.getTags() != null && !rule.getTags().isEmpty(); + if (!isLimitedToProjects && !isLimitedToTags) { + LOGGER.debug("Rule is not limited to projects or tags; Rule is applicable (%s)".formatted(ruleCtx)); + applicableRules.add(rule); + continue; + } + + if (isLimitedToTags) { + final Predicate tagMatchPredicate = project -> (project.isActive() == null || project.isActive()) + && project.getTags() != null + && project.getTags().stream().anyMatch(rule.getTags()::contains); + + if (tagMatchPredicate.test(limitToProject)) { + LOGGER.debug(""" + Project %s is tagged with any of the "limit to" tags; \ + Rule is applicable (%s)""".formatted(limitToProject.getUuid(), ruleCtx)); + applicableRules.add(rule); + continue; + } else if (rule.isNotifyChildren() && isChildOfProjectMatching(limitToProject, tagMatchPredicate)) { + LOGGER.debug(""" + Project %s is child of a project tagged with any of the "limit to" tags; \ + Rule is applicable (%s)""".formatted(limitToProject.getUuid(), ruleCtx)); + applicableRules.add(rule); + continue; + } + } else { + LOGGER.debug("Rule is not limited to tags (%s)".formatted(ruleCtx)); + } + + if (isLimitedToProjects) { + var matched = false; + for (final Project project : rule.getProjects()) { + if (project.getUuid().equals(limitToProject.getUuid())) { + LOGGER.debug("Project %s is part of the \"limit to\" list of the rule; Rule is applicable (%s)" + .formatted(limitToProject.getUuid(), ruleCtx)); + matched = true; + break; + } else if (rule.isNotifyChildren()) { + final boolean isChildOfLimitToProject = checkIfChildrenAreAffected(project, limitToProject.getUuid()); + if (isChildOfLimitToProject) { + LOGGER.debug("Project %s is child of \"limit to\" project %s; Rule is applicable (%s)" + .formatted(limitToProject.getUuid(), project.getUuid(), ruleCtx)); + matched = true; + break; } else { - LOGGER.debug("Project %s is not part of the \"limit to\" list of the rule; Rule is not applicable (%s)" - .formatted(limitToProject.getUuid(), ruleCtx)); + LOGGER.debug("Project %s is not a child of \"limit to\" project %s (%s)" + .formatted(limitToProject.getUuid(), project.getUuid(), ruleCtx)); } } - } else { - LOGGER.debug("Rule is not limited to projects; Rule is applicable (%s)".formatted(ruleCtx)); + } + + if (matched) { applicableRules.add(rule); + } else { + LOGGER.debug("Project %s is not part of the \"limit to\" list of the rule; Rule is not applicable (%s)" + .formatted(limitToProject.getUuid(), ruleCtx)); } + } else { + LOGGER.debug("Rule is not limited to projects (%s)".formatted(ruleCtx)); } } + LOGGER.debug("Applicable rules: %s (%s)" .formatted(applicableRules.stream().map(NotificationRule::getName).collect(Collectors.joining(", ")), ctx)); } private boolean checkIfChildrenAreAffected(Project parent, UUID uuid) { + // TODO: Making this a recursive SQL query would be a lot more efficient. + boolean isChild = false; if (parent.getChildren() == null || parent.getChildren().isEmpty()) { return false; @@ -269,4 +348,20 @@ private boolean checkIfChildrenAreAffected(Project parent, UUID uuid) { } return isChild; } + + private boolean isChildOfProjectMatching(final Project childProject, final Predicate matchFunction) { + // TODO: Making this a recursive SQL query would be a lot more efficient. + + Project parent = childProject.getParent(); + while (parent != null) { + if (matchFunction.test(parent)) { + return true; + } + + parent = parent.getParent(); + } + + return false; + } + } diff --git a/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java b/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java index 81553b86b9..3f4b6e71ad 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.Publisher; 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); + }); } /** @@ -226,4 +237,42 @@ 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 2e2e77606c..aa62e36847 100644 --- a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java @@ -627,6 +627,14 @@ public boolean bind(final Policy policy, final Collection tags) { 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/ProjectQueryManager.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index a9f3bc2443..91ac6fba82 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -378,83 +378,6 @@ public PaginatedResult getProjects(final Tag tag) { return getProjects(tag, false, false, false); } - /** - * Returns a list of Tag objects what have been resolved. It resolved - * tags by querying the database to retrieve the tag. If the tag does - * not exist, the tag will be created and returned with other resolved - * tags. - * @param tags a List of Tags to resolve - * @return List of resolved Tags - */ - private synchronized List resolveTags(final List tags) { - if (tags == null) { - return new ArrayList<>(); - } - final List resolvedTags = new ArrayList<>(); - final List unresolvedTags = new ArrayList<>(); - for (final Tag tag: tags) { - final String trimmedTag = StringUtils.trimToNull(tag.getName()); - if (trimmedTag != null) { - final Tag resolvedTag = getTagByName(trimmedTag); - if (resolvedTag != null) { - resolvedTags.add(resolvedTag); - } else { - unresolvedTags.add(trimmedTag); - } - } - } - resolvedTags.addAll(createTags(unresolvedTags)); - return resolvedTags; - } - - /** - * Returns a list of Tag objects by name. - * @param name the name of the Tag - * @return a Tag object - */ - @Override - public Tag getTagByName(final String name) { - final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); - final Query query = pm.newQuery(Tag.class, "name == :name"); - query.setRange(0, 1); - return singleResult(query.execute(loweredTrimmedTag)); - } - - /** - * Creates a new Tag object with the specified name. - * @param name the name of the Tag to create - * @return the created Tag object - */ - @Override - public Tag createTag(final String name) { - final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); - final Tag resolvedTag = getTagByName(loweredTrimmedTag); - if (resolvedTag != null) { - return resolvedTag; - } - final Tag tag = new Tag(); - tag.setName(loweredTrimmedTag); - return persist(tag); - } - - /** - * Creates one or more Tag objects from the specified name(s). - * @param names the name(s) of the Tag(s) to create - * @return the created Tag object(s) - */ - private List createTags(final List names) { - final List newTags = new ArrayList<>(); - for (final String name: names) { - final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); - if (getTagByName(loweredTrimmedTag) == null) { - final Tag tag = new Tag(); - tag.setName(loweredTrimmedTag); - newTags.add(tag); - } - } - return new ArrayList<>(persist(newTags)); - } - /** * Creates a new Project. * @param name the name of the project to create diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 45f51bb50e..cc42de5c96 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -33,7 +33,6 @@ import alpine.server.util.DbUtil; import com.github.packageurl.PackageURL; import com.google.common.collect.Lists; -import jakarta.json.JsonObject; import org.apache.commons.lang3.ClassUtils; import org.datanucleus.PropertyNames; import org.datanucleus.api.jdo.JDOQuery; @@ -85,6 +84,7 @@ import org.dependencytrack.resources.v1.vo.DependencyGraphResponse; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; +import jakarta.json.JsonObject; import javax.jdo.FetchPlan; import javax.jdo.PersistenceManager; import javax.jdo.Query; @@ -466,11 +466,19 @@ public boolean doesProjectExist(final String name, final String version) { } public Tag getTagByName(final String name) { - return getProjectQueryManager().getTagByName(name); + return getTagQueryManager().getTagByName(name); } public Tag createTag(final String name) { - return getProjectQueryManager().createTag(name); + return getTagQueryManager().createTag(name); + } + + public List createTags(final List names) { + return getTagQueryManager().createTags(names); + } + + public List resolveTags(final List tags) { + return getTagQueryManager().resolveTags(tags); } public Project createProject(String name, String description, String version, List tags, Project parent, PackageURL purl, boolean active, boolean commitIndex) { @@ -1299,6 +1307,10 @@ public void clearComponentAnalysisCache(Date threshold) { getCacheQueryManager().clearComponentAnalysisCache(threshold); } + public boolean bind(final NotificationRule notificationRule, final Collection tags) { + return getNotificationQueryManager().bind(notificationRule, tags); + } + public void bind(Project project, List tags) { getProjectQueryManager().bind(project, tags); } @@ -1380,6 +1392,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 an object from the datastore by its {@link UUID}, using the provided fetch groups. *

diff --git a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java index f852144277..3ced4ec62c 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -25,8 +25,10 @@ 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; @@ -70,14 +72,105 @@ public class TagQueryManager extends QueryManager implements IQueryManager { super(pm, request); } + /** + * Returns a list of Tag objects by name. + * @param name the name of the Tag + * @return a Tag object + */ + @Override + public Tag getTagByName(final String name) { + final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); + final Query query = pm.newQuery(Tag.class, "name == :name"); + query.setRange(0, 1); + return singleResult(query.execute(loweredTrimmedTag)); + } + + /** + * Creates a new Tag object with the specified name. + * @param name the name of the Tag to create + * @return the created Tag object + */ + @Override + public Tag createTag(final String name) { + final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); + final Tag resolvedTag = getTagByName(loweredTrimmedTag); + if (resolvedTag != null) { + return resolvedTag; + } + final Tag tag = new Tag(); + tag.setName(loweredTrimmedTag); + return persist(tag); + } + + /** + * Creates one or more Tag objects from the specified name(s). + * @param names the name(s) of the Tag(s) to create + * @return the created Tag object(s) + */ + @Override + public List createTags(final List names) { + final List newTags = new ArrayList<>(); + for (final String name: names) { + final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); + if (getTagByName(loweredTrimmedTag) == null) { + final Tag tag = new Tag(); + tag.setName(loweredTrimmedTag); + newTags.add(tag); + } + } + return new ArrayList<>(persist(newTags)); + } + + /** + * Returns a list of Tag objects what have been resolved. It resolved + * tags by querying the database to retrieve the tag. If the tag does + * not exist, the tag will be created and returned with other resolved + * tags. + * @param tags a List of Tags to resolve + * @return List of resolved Tags + */ + @Override + public synchronized List resolveTags(final List tags) { + if (tags == null) { + return new ArrayList<>(); + } + final List resolvedTags = new ArrayList<>(); + final List unresolvedTags = new ArrayList<>(); + for (final Tag tag: tags) { + final String trimmedTag = StringUtils.trimToNull(tag.getName()); + if (trimmedTag != null) { + final Tag resolvedTag = getTagByName(trimmedTag); + if (resolvedTag != null) { + resolvedTags.add(resolvedTag); + } else { + unresolvedTags.add(trimmedTag); + } + } + } + resolvedTags.addAll(createTags(unresolvedTags)); + return resolvedTags; + } + /** * @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); } } @@ -99,12 +192,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); @@ -118,7 +212,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 { @@ -139,7 +236,8 @@ public record TagDeletionCandidateRow( String name, long projectCount, long accessibleProjectCount, - long policyCount + long policyCount, + long notificationRuleCount ) { @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. @@ -147,9 +245,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); } } @@ -192,6 +291,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))); @@ -216,16 +320,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); } } @@ -251,6 +359,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()) { @@ -509,4 +623,106 @@ public PaginatedResult getTagsForPolicy(String policyUuid) { return (new PaginatedResult()).objects(tagsToShow).total(tagsToShow.size()); } + /** + * @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); + return executeAndCloseResultList(query, TaggedNotificationRuleRow.class); + } + + /** + * @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 8d891f5404..08a5ae82a1 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -36,12 +36,14 @@ import org.dependencytrack.model.validation.ValidUuid; import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.persistence.TagQueryManager.TagListRow; +import org.dependencytrack.persistence.TagQueryManager.TaggedNotificationRuleRow; import org.dependencytrack.persistence.TagQueryManager.TaggedPolicyRow; import org.dependencytrack.persistence.TagQueryManager.TaggedProjectRow; import org.dependencytrack.resources.v1.openapi.PaginatedApi; 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; @@ -91,7 +93,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(); @@ -418,4 +425,115 @@ public Response getTags( return getTagsForPolicy(String.valueOf(policyUuid)); } + @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); + } + + 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); + } + + 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 2ddbfc32d0..716397f7c5 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 ) { } 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 0000000000..296221a0ec --- /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 +) { +} diff --git a/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java b/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java index 944b59bcf3..0ab6e2a3e7 100644 --- a/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java +++ b/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java @@ -27,6 +27,7 @@ import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Project; +import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vex; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; @@ -52,7 +53,6 @@ import static org.assertj.core.api.Assertions.assertThat; -@SuppressWarnings("unchecked") public class NotificationRouterTest extends PersistenceCapableTest { @Test @@ -96,7 +96,10 @@ public void testValidMatchingRule() { notification.setGroup(NotificationGroup.NEW_VULNERABILITY.name()); notification.setLevel(NotificationLevel.INFORMATIONAL); // Notification should not be limited to any projects - so set projects to null - NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), new Component(), null, null); + Project affectedProject = qm.createProject("Test Project", null, "1.0", null, null, null, true, false); + Component affectedComponent = new Component(); + affectedComponent.setProject(affectedProject); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, null, null); notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); @@ -125,7 +128,9 @@ public void testValidMatchingProjectLimitingRule() { // Notification should be limited to only specific projects - Set the projects which are affected by the notification event Set affectedProjects = new HashSet<>(); affectedProjects.add(project); - NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), new Component(), affectedProjects, null); + Component affectedComponent = new Component(); + affectedComponent.setProject(project); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, affectedProjects, null); notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); @@ -155,12 +160,14 @@ public void testValidNonMatchingProjectLimitingRule() { Set affectedProjects = new HashSet<>(); Project affectedProject = qm.createProject("Affected Project", null, "1.0", null, null, null, true, false); affectedProjects.add(affectedProject); - NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), new Component(), affectedProjects, null); + Component affectedComponent = new Component(); + affectedComponent.setProject(affectedProject); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, affectedProjects, null); notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); List rules = router.resolveRules(PublishContext.from(notification), notification); - Assert.assertEquals(1, rules.size()); + Assert.assertEquals(0, rules.size()); } @Test @@ -178,7 +185,10 @@ public void testValidMatchingRuleAndPublisherInform() { notification.setGroup(NotificationGroup.NEW_VULNERABILITY.name()); notification.setLevel(NotificationLevel.INFORMATIONAL); // Notification should not be limited to any projects - so set projects to null - NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), new Component(), null, null); + Project affectedProject = qm.createProject("Test Project", null, "1.0", null, null, null, true, false); + Component affectedComponent = new Component(); + affectedComponent.setProject(affectedProject); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, null, null); notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); @@ -213,7 +223,9 @@ public void testValidMatchingProjectLimitingRuleAndPublisherInform() { Set affectedProjects = new HashSet<>(); affectedProjects.add(firstProject); affectedProjects.add(secondProject); - NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), new Component(), affectedProjects, null); + Component affectedComponent = new Component(); + affectedComponent.setProject(firstProject); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, affectedProjects, null); notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); @@ -242,7 +254,10 @@ public void testValidNonMatchingRule() { notification.setGroup(NotificationGroup.NEW_VULNERABILITY.name()); notification.setLevel(NotificationLevel.INFORMATIONAL); // Notification should not be limited to any projects - so set projects to null - NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), new Component(), null, null); + Project affectedProject = qm.createProject("Test Project 1", null, "1.0", null, null, null, true, false); + Component affectedComponent = new Component(); + affectedComponent.setProject(affectedProject); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, null, null); notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); @@ -720,6 +735,110 @@ public void testAffectedActiveNullChild() { Assert.assertEquals(1, rules.size()); } + @Test + public void testValidMatchingTagLimitingRule() { + NotificationPublisher publisher = createSlackPublisher(); + // Creates a new rule and defines when the rule should be triggered (notifyOn) + NotificationRule rule = qm.createNotificationRule("Test Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + Set notifyOn = new HashSet<>(); + notifyOn.add(NotificationGroup.NEW_VULNERABILITY); + rule.setNotifyOn(notifyOn); + // Creates a tag which will later be matched on + Tag tag = qm.createTag("test"); + List tags = List.of(tag); + Project project = qm.createProject("Test Project", null, "1.0", tags, null, null, true, false); + qm.bind(rule, tags); + // Creates a new notification + Notification notification = new Notification(); + notification.setScope(NotificationScope.PORTFOLIO.name()); + notification.setGroup(NotificationGroup.NEW_VULNERABILITY.name()); + notification.setLevel(NotificationLevel.INFORMATIONAL); + // Notification should be limited to only specific projects - Set the projects which are affected by the notification event + Set affectedProjects = new HashSet<>(); + affectedProjects.add(project); + Component affectedComponent = new Component(); + affectedComponent.setProject(project); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, affectedProjects, null); + notification.setSubject(subject); + // Ok, let's test this + NotificationRouter router = new NotificationRouter(); + List rules = router.resolveRules(PublishContext.from(notification), notification); + Assert.assertEquals(1, rules.size()); + } + + @Test + public void testValidNonMatchingTagLimitingRule() { + NotificationPublisher publisher = createSlackPublisher(); + // Creates a new rule and defines when the rule should be triggered (notifyOn) + NotificationRule rule = qm.createNotificationRule("Test Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + Set notifyOn = new HashSet<>(); + notifyOn.add(NotificationGroup.NEW_VULNERABILITY); + rule.setNotifyOn(notifyOn); + // Creates a tag to limit the notifications + Tag tag = qm.createTag("test"); + List tags = List.of(tag); + qm.createProject("Test Project", null, "1.0", tags, null, null, true, false); + // Rule should apply only for specific tag + qm.bind(rule, tags); + // Creates a new notification + Notification notification = new Notification(); + notification.setScope(NotificationScope.PORTFOLIO.name()); + notification.setGroup(NotificationGroup.NEW_VULNERABILITY.name()); + notification.setLevel(NotificationLevel.INFORMATIONAL); + // Set the projects which are affected by the notification event + Set affectedProjects = new HashSet<>(); + Project affectedProject = qm.createProject("Affected Project", null, "1.0", null, null, null, true, false); + affectedProjects.add(affectedProject); + Component affectedComponent = new Component(); + affectedComponent.setProject(affectedProject); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, affectedProjects, null); + notification.setSubject(subject); + // Ok, let's test this + NotificationRouter router = new NotificationRouter(); + List rules = router.resolveRules(PublishContext.from(notification), notification); + // Affected project is not tagged, rule should be empty + Assert.assertEquals(0, rules.size()); + } + + @Test + public void testValidMatchingProjectAndTagLimitingRule() { + NotificationPublisher publisher = createMockPublisher(); + // Creates a new rule and defines when the rule should be triggered (notifyOn) + NotificationRule rule = qm.createNotificationRule("Test Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + Set notifyOn = new HashSet<>(); + notifyOn.add(NotificationGroup.NEW_VULNERABILITY); + rule.setNotifyOn(notifyOn); + // Creates a tag to limit the notifications + Tag tag = qm.createTag("test"); + List tags = List.of(tag); + Project taggedProject = qm.createProject("Test Project", null, "1.0", tags, null, null, true, false); + // Rule should apply for specific tag + qm.bind(rule, tags); + // Rule should also apply for specific project + Project otherAffectedProject = qm.createProject("Affected Project", null, "1.0", null, null, null, true, false); + rule.setProjects(List.of(otherAffectedProject)); + // Creates a new notification + Notification notification = new Notification(); + notification.setScope(NotificationScope.PORTFOLIO.name()); + notification.setGroup(NotificationGroup.NEW_VULNERABILITY.name()); + notification.setLevel(NotificationLevel.INFORMATIONAL); + // Set the projects which are affected by the notification event + Set affectedProjects = new HashSet<>(); + affectedProjects.add(taggedProject); + affectedProjects.add(otherAffectedProject); + Component affectedComponent = new Component(); + affectedComponent.setProject(taggedProject); + NewVulnerabilityIdentified subject = new NewVulnerabilityIdentified(new Vulnerability(), affectedComponent, affectedProjects, null); + notification.setSubject(subject); + // Ok, let's test this + NotificationRouter router = new NotificationRouter(); + router.inform(notification); + // Affected project is not tagged, rules should be empty + Notification providedNotification = MockPublisher.getNotification(); + NewVulnerabilityIdentified providedSubject = (NewVulnerabilityIdentified) providedNotification.getSubject(); + Assert.assertEquals(2, providedSubject.getAffectedProjects().size()); + } + private NotificationPublisher createSlackPublisher() { return qm.createNotificationPublisher( DefaultNotificationPublishers.SLACK.getPublisherName(), diff --git a/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java index 03d5b1fb86..9906483b4a 100644 --- a/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java @@ -175,6 +175,112 @@ public void updateNotificationRuleInvalidTest() { Assert.assertEquals("The UUID of the notification rule could not be found.", 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}" + } + """); + } + @Test public void deleteNotificationRuleTest() { NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); @@ -424,6 +530,7 @@ public void addTeamToRuleWithCustomEmailPublisherTest() { "scope": "PORTFOLIO", "notificationLevel": "INFORMATIONAL", "projects": [], + "tags": [], "teams": [ { "uuid": "${json-unit.matches:teamUuid}", diff --git a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java index cf82720a1a..6982f2d68b 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -6,9 +6,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.dependencytrack.resources.v1.exception.NoSuchElementExceptionMapper; import org.dependencytrack.resources.v1.exception.TagOperationFailedExceptionMapper; @@ -87,6 +89,19 @@ public void getTagsTest() { 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) @@ -98,12 +113,14 @@ public void getTagsTest() { { "name": "bar", "projectCount": 1, - "policyCount": 1 + "policyCount": 1, + "notificationRuleCount": 0 }, { "name": "foo", "projectCount": 2, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 1 } ] """); @@ -130,17 +147,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 } ] """); @@ -158,12 +178,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 } ] """); @@ -188,7 +210,8 @@ public void getTagsWithFilterTest() { { "name": "foo", "projectCount": 0, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 } ] """); @@ -225,12 +248,14 @@ public void getTagsSortByProjectCountTest() { { "name": "foo", "projectCount": 2, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 }, { "name": "bar", "projectCount": 1, - "policyCount": 0 + "policyCount": 0, + "notificationRuleCount": 0 } ] """); @@ -451,6 +476,69 @@ public void deleteTagsWhenAssignedToPolicyWithoutPolicyManagementPermissionTest( assertThat(qm.getTagByName("bar")).isNotNull(); } + @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 getTaggedProjectsTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); @@ -587,6 +675,8 @@ public void getTaggedProjectsWithTagNotExistsTest() { public void getTaggedProjectsWithNonLowerCaseTagNameTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + qm.createTag("foo"); + final Response response = jersey.target(V1_TAG + "/Foo/project") .request() .header(X_API_KEY, apiKey) @@ -1007,6 +1097,8 @@ public void getTaggedPoliciesWithTagNotExistsTest() { public void getTaggedPoliciesWithNonLowerCaseTagNameTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + qm.createTag("foo"); + final Response response = jersey.target(V1_TAG + "/Foo/policy") .request() .header(X_API_KEY, apiKey) @@ -1278,6 +1370,338 @@ public void getTagsForPolicyWithPolicyProjectsFilterTest() { Assert.assertEquals("tag 1", json.getJsonObject(0).getString("name")); } + @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.arg1", + "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.arg1", + "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.arg1", + "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(); + } + @Test public void getTagWithNonUuidNameTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO);