Repository: getsentry/sentry

Issue:
Prevent duplicate and no action alerts from being created
Alerts with no actions are useless as are alerts that are exact duplicates of another rule in the same project, so we should not allow them to be saved.


Discussion:

Resolving patch:
diff --git a/src/sentry/api/endpoints/project_rule_details.py b/src/sentry/api/endpoints/project_rule_details.py
--- a/src/sentry/api/endpoints/project_rule_details.py
+++ b/src/sentry/api/endpoints/project_rule_details.py
@@ -7,6 +7,7 @@
 from sentry import audit_log
 from sentry.api.base import region_silo_endpoint
 from sentry.api.bases.rule import RuleEndpoint
+from sentry.api.endpoints.project_rules import find_duplicate_rule
 from sentry.api.serializers import serialize
 from sentry.api.serializers.models.rule import RuleSerializer
 from sentry.api.serializers.rest_framework.rule import RuleSerializer as DrfRuleSerializer
@@ -116,6 +117,16 @@ def put(self, request: Request, project, rule) -> Response:
         if serializer.is_valid():
             data = serializer.validated_data
 
+            if not data.get("actions", []):
+                return Response(
+                    {
+                        "actions": [
+                            "You must add an action for this alert to fire.",
+                        ]
+                    },
+                    status=status.HTTP_400_BAD_REQUEST,
+                )
+
             # combine filters and conditions into one conditions criteria for the rule object
             conditions = data.get("conditions", [])
             if "filters" in data:
@@ -131,6 +142,17 @@ def put(self, request: Request, project, rule) -> Response:
                 "actions": data["actions"],
                 "frequency": data.get("frequency"),
             }
+            duplicate_rule = find_duplicate_rule(kwargs, project)
+            if duplicate_rule:
+                return Response(
+                    {
+                        "name": [
+                            f"This rule is an exact duplicate of '{duplicate_rule.label}' in this project and may not be created.",
+                        ]
+                    },
+                    status=status.HTTP_400_BAD_REQUEST,
+                )
+
             owner = data.get("owner")
             if owner:
                 try:
diff --git a/src/sentry/api/endpoints/project_rules.py b/src/sentry/api/endpoints/project_rules.py
--- a/src/sentry/api/endpoints/project_rules.py
+++ b/src/sentry/api/endpoints/project_rules.py
@@ -34,6 +34,23 @@ def pre_save_rule(instance, sender, *args, **kwargs):
     clean_rule_data(instance.data.get("actions", []))
 
 
+def find_duplicate_rule(rule_data, project):
+    matchers = [key for key in list(rule_data.keys()) if key not in ("name", "user_id")]
+    existing_rules = Rule.objects.filter(project=project, status=ObjectStatus.ACTIVE)
+    for existing_rule in existing_rules:
+        keys = 0
+        matches = 0
+        for matcher in matchers:
+            if existing_rule.data.get(matcher) and rule_data.get(matcher):
+                keys += 1
+                if existing_rule.data[matcher] == rule_data[matcher]:
+                    matches += 1
+
+        if keys == matches:
+            return existing_rule
+    return None
+
+
 @region_silo_endpoint
 class ProjectRulesEndpoint(ProjectEndpoint):
     owner = ApiOwner.ISSUES
@@ -88,6 +105,16 @@ def post(self, request: Request, project) -> Response:
             return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
         data = serializer.validated_data
+
+        if not data.get("actions", []):
+            return Response(
+                {
+                    "actions": [
+                        "You must add an action for this alert to fire.",
+                    ]
+                },
+                status=status.HTTP_400_BAD_REQUEST,
+            )
         # combine filters and conditions into one conditions criteria for the rule object
         conditions = data.get("conditions", [])
         if "filters" in data:
@@ -144,6 +171,17 @@ def post(self, request: Request, project) -> Response:
             "frequency": data.get("frequency"),
             "user_id": request.user.id,
         }
+        duplicate_rule = find_duplicate_rule(kwargs, project)
+        if duplicate_rule:
+            return Response(
+                {
+                    "name": [
+                        f"This rule is an exact duplicate of '{duplicate_rule.label}' in this project and may not be created.",
+                    ]
+                },
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
         owner = data.get("owner")
         if owner:
             try:
@@ -165,6 +203,7 @@ def post(self, request: Request, project) -> Response:
             kwargs.get("actions")
         )
         rule = project_rules.Creator.run(request=request, **kwargs)
+
         RuleActivity.objects.create(
             rule=rule, user_id=request.user.id, type=RuleActivityType.CREATED.value
         )


Test patch:
diff --git a/tests/sentry/api/endpoints/test_project_rule_details.py b/tests/sentry/api/endpoints/test_project_rule_details.py
--- a/tests/sentry/api/endpoints/test_project_rule_details.py
+++ b/tests/sentry/api/endpoints/test_project_rule_details.py
@@ -6,6 +6,7 @@
 
 import responses
 from freezegun import freeze_time
+from rest_framework import status
 
 from sentry.constants import ObjectStatus
 from sentry.integrations.slack.utils.channel import strip_channel_name
@@ -404,7 +405,55 @@ def test_update_name(self):
         )
         assert_rule_from_payload(self.rule, payload)
 
-    @responses.activate
+    def test_update_duplicate_rule(self):
+        """Test that if you edit a rule such that it's now the exact duplicate of another rule in the same project
+        we do not allow it"""
+        conditions = [
+            {
+                "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition",
+            }
+        ]
+        actions = [
+            {
+                "targetType": "IssueOwners",
+                "fallthroughType": "ActiveMembers",
+                "id": "sentry.mail.actions.NotifyEmailAction",
+                "targetIdentifier": "",
+            }
+        ]
+        rule = self.create_project_rule(
+            project=self.project, action_match=actions, condition_match=conditions
+        )
+        conditions.append(
+            {
+                "id": "sentry.rules.conditions.event_frequency.EventFrequencyPercentCondition",
+                "interval": "1h",
+                "value": "100",
+                "comparisonType": "count",
+            }
+        )
+        rule2 = self.create_project_rule(
+            project=self.project, action_match=actions, condition_match=conditions
+        )
+        conditions.pop(1)
+        payload = {
+            "name": "hello world",
+            "actionMatch": "all",
+            "actions": actions,
+            "conditions": conditions,
+        }
+        resp = self.get_error_response(
+            self.organization.slug,
+            self.project.slug,
+            rule2.id,
+            status_code=status.HTTP_400_BAD_REQUEST,
+            **payload,
+        )
+        assert (
+            resp.data["name"][0]
+            == f"This rule is an exact duplicate of '{rule.label}' in this project and may not be created."
+        )
+
     def test_with_environment(self):
         payload = {
             "name": "hello world",
diff --git a/tests/sentry/api/endpoints/test_project_rules.py b/tests/sentry/api/endpoints/test_project_rules.py
--- a/tests/sentry/api/endpoints/test_project_rules.py
+++ b/tests/sentry/api/endpoints/test_project_rules.py
@@ -139,7 +139,15 @@ def run_test(
 
     def test_simple(self):
         conditions = [{"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}]
-        actions = [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}]
+        actions = [
+            {
+                "targetType": "IssueOwners",
+                "fallthroughType": "ActiveMembers",
+                "id": "sentry.mail.actions.NotifyEmailAction",
+                "targetIdentifier": "",
+                "name": "Send a notification to IssueOwners and if none can be found then send a notification to ActiveMembers",
+            }
+        ]
 
         self.run_test(actions=actions, conditions=conditions)
 
@@ -159,6 +167,54 @@ def test_with_name(self):
 
         self.run_test(actions=actions, conditions=conditions)
 
+    def test_duplicate_rule(self):
+        conditions = [
+            {
+                "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition",
+                "name": "A new issue is created",
+            }
+        ]
+        actions = [
+            {
+                "targetType": "IssueOwners",
+                "fallthroughType": "ActiveMembers",
+                "id": "sentry.mail.actions.NotifyEmailAction",
+                "targetIdentifier": "",
+                "name": "Send a notification to IssueOwners and if none can be found then send a notification to ActiveMembers",
+            }
+        ]
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            name="hellboy",
+            frequency=1440,
+            owner=self.user.get_actor_identifier(),
+            actionMatch="any",
+            filterMatch="all",
+            actions=actions,
+            conditions=conditions,
+        )
+        rule = Rule.objects.get(id=response.data["id"])
+
+        conditions = [{"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}]
+        resp = self.get_error_response(
+            self.organization.slug,
+            self.project.slug,
+            name="test",
+            frequency=rule.data["frequency"],
+            owner=self.user.get_actor_identifier(),
+            actionMatch=rule.data["action_match"],
+            filterMatch=rule.data["filter_match"],
+            actions=rule.data["actions"],
+            conditions=conditions,
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
+        assert (
+            resp.data["name"][0]
+            == f"This rule is an exact duplicate of '{rule.label}' in this project and may not be created."
+        )
+
     def test_pre_save(self):
         """Test that a rule with name data in the conditions and actions is saved without it"""
         conditions = [
@@ -316,6 +372,14 @@ def test_exceed_limit_slow_conditions(self):
         Rule.objects.filter(project=self.project).update(status=ObjectStatus.PENDING_DELETION)
         self.run_test(conditions=conditions, actions=actions)
 
+        actions.append(
+            {
+                "targetType": "Team",
+                "fallthroughType": "ActiveMembers",
+                "id": "sentry.mail.actions.NotifyEmailAction",
+                "targetIdentifier": self.team.id,
+            }
+        )
         with self.feature("organizations:more-slow-alerts"):
             self.run_test(conditions=conditions, actions=actions)
 
@@ -372,6 +436,7 @@ def test_frequency_percent_validation(self):
         # Upper bound shouldn't be enforced when we're doing a comparison alert
         condition["comparisonType"] = "percent"
         condition["comparisonInterval"] = "1d"
+        actions = [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}]
         self.get_success_response(
             self.organization.slug,
             self.project.slug,
@@ -380,6 +445,7 @@ def test_frequency_percent_validation(self):
             owner=self.user.get_actor_identifier(),
             actionMatch="any",
             filterMatch="any",
+            actions=actions,
             conditions=[condition],
             status_code=status.HTTP_200_OK,
         )
@@ -480,7 +546,19 @@ def test_with_filters_without_match(self):
 
     def test_no_actions(self):
         conditions = [{"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}]
-        self.run_test(name="no action rule", actions=[], conditions=conditions)
+        resp = self.get_error_response(
+            self.organization.slug,
+            self.project.slug,
+            name="test",
+            frequency=30,
+            owner=self.user.get_actor_identifier(),
+            actionMatch="any",
+            filterMatch="any",
+            actions=[],
+            conditions=conditions,
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
+        assert resp.data["actions"][0] == "You must add an action for this alert to fire."
 
     @patch(
         "sentry.integrations.slack.actions.notification.get_channel_id",
@@ -567,11 +645,26 @@ def test_comparison_condition(self):
         )
 
         condition["comparisonType"] = "count"
+        actions.append(
+            {
+                "targetType": "Team",
+                "fallthroughType": "ActiveMembers",
+                "id": "sentry.mail.actions.NotifyEmailAction",
+                "targetIdentifier": self.team.id,
+            }
+        )
         self.run_test(actions=actions, conditions=[condition])
 
         condition["comparisonType"] = "percent"
         condition["comparisonInterval"] = "1d"
-
+        actions.append(
+            {
+                "targetType": "Member",
+                "fallthroughType": "ActiveMembers",
+                "id": "sentry.mail.actions.NotifyEmailAction",
+                "targetIdentifier": self.user.id,
+            }
+        )
         self.run_test(actions=actions, conditions=[condition])
 
     def test_comparison_condition_validation(self):