Browse Source

ref(issue-alerts): Move AlertRuleAction logic away from endpoint (#34027)

This PR moves a function that was in project_rules.py out of there and into an appropriate separate location. It also adds the SentryAppEventAction class to outline the required methods on creating new members of SENTRY_APP_ACTIONS (this is following a similar pattern for TicketEventAction and TICKET_ACTIONS).

This refactor was also done and for Metric Alerts in #33948.

I also added a bunch of tests for the error surfacing that was added in #33571
Leander Rodrigues 2 years ago
parent
commit
eab9ecf246

+ 2 - 2
src/sentry/api/endpoints/project_rule_details.py

@@ -3,7 +3,6 @@ from rest_framework.request import Request
 from rest_framework.response import Response
 
 from sentry.api.bases.rule import RuleEndpoint
-from sentry.api.endpoints.project_rules import trigger_alert_rule_action_creators
 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
@@ -19,6 +18,7 @@ from sentry.models import (
     Team,
     User,
 )
+from sentry.rules.actions.base import trigger_sentry_app_action_creators_for_issues
 from sentry.signals import alert_rule_edited
 from sentry.web.decorators import transaction_start
 
@@ -138,7 +138,7 @@ class ProjectRuleDetailsEndpoint(RuleEndpoint):
                 context = {"uuid": client.uuid}
                 return Response(context, status=202)
 
-            trigger_alert_rule_action_creators(kwargs.get("actions"))
+            trigger_sentry_app_action_creators_for_issues(kwargs.get("actions"))
 
             updated_rule = project_rules.Updater.run(rule=rule, request=request, **kwargs)
 

+ 4 - 29
src/sentry/api/endpoints/project_rules.py

@@ -1,6 +1,4 @@
-from typing import Mapping, Optional, Sequence
-
-from rest_framework import serializers, status
+from rest_framework import status
 from rest_framework.request import Request
 from rest_framework.response import Response
 
@@ -8,44 +6,21 @@ from sentry.api.bases.project import ProjectAlertRulePermission, ProjectEndpoint
 from sentry.api.serializers import serialize
 from sentry.api.serializers.rest_framework import RuleSerializer
 from sentry.integrations.slack import tasks
-from sentry.mediators import alert_rule_actions, project_rules
-from sentry.mediators.external_requests.alert_rule_action_requester import AlertRuleActionResult
+from sentry.mediators import project_rules
 from sentry.models import (
     AuditLogEntryEvent,
     Rule,
     RuleActivity,
     RuleActivityType,
     RuleStatus,
-    SentryAppInstallation,
     Team,
     User,
 )
+from sentry.rules.actions.base import trigger_sentry_app_action_creators_for_issues
 from sentry.signals import alert_rule_created
 from sentry.web.decorators import transaction_start
 
 
-# TODO(Leander): Move this method into a relevant abstract base class
-def trigger_alert_rule_action_creators(
-    actions: Sequence[Mapping[str, str]],
-) -> Optional[str]:
-    created = None
-    for action in actions:
-        # Only call creator for Sentry Apps with UI Components for alert rules.
-        if not action.get("hasSchemaFormConfig"):
-            continue
-
-        install = SentryAppInstallation.objects.get(uuid=action.get("sentryAppInstallationUuid"))
-        result: AlertRuleActionResult = alert_rule_actions.AlertRuleActionCreator.run(
-            install=install,
-            fields=action.get("settings"),
-        )
-        # Bubble up errors from Sentry App to the UI
-        if not result["success"]:
-            raise serializers.ValidationError({"actions": [result["message"]]})
-        created = "alert-rule-action"
-    return created
-
-
 class ProjectRulesEndpoint(ProjectEndpoint):
     permission_classes = (ProjectAlertRulePermission,)
 
@@ -128,7 +103,7 @@ class ProjectRulesEndpoint(ProjectEndpoint):
                 tasks.find_channel_id_for_rule.apply_async(kwargs=kwargs)
                 return Response(uuid_context, status=202)
 
-            created_alert_rule_ui_component = trigger_alert_rule_action_creators(
+            created_alert_rule_ui_component = trigger_sentry_app_action_creators_for_issues(
                 kwargs.get("actions")
             )
             rule = project_rules.Creator.run(request=request, **kwargs)

+ 38 - 2
src/sentry/rules/actions/base.py

@@ -6,12 +6,15 @@ from typing import Any, Callable, Generator, Mapping, Sequence
 
 from django import forms
 from django.db.models import QuerySet
+from rest_framework import serializers
 from rest_framework.response import Response
 
-from sentry.constants import ObjectStatus
+from sentry.constants import SENTRY_APP_ACTIONS, ObjectStatus
 from sentry.eventstore.models import Event
 from sentry.integrations import IntegrationInstallation
-from sentry.models import ExternalIssue, GroupLink, Integration
+from sentry.mediators import alert_rule_actions
+from sentry.mediators.external_requests.alert_rule_action_requester import AlertRuleActionResult
+from sentry.models import ExternalIssue, GroupLink, Integration, Project, SentryAppInstallation
 from sentry.rules.base import CallbackFuture, EventState, RuleBase
 from sentry.types.rules import RuleFuture
 
@@ -60,6 +63,39 @@ class EventAction(RuleBase, abc.ABC):
         pass
 
 
+class SentryAppEventAction(EventAction, abc.ABC):
+    """Abstract class to ensure that actions in SENTRY_APP_ACTIONS have all required methods"""
+
+    @abc.abstractmethod
+    def get_custom_actions(self, project: Project) -> Sequence[Mapping[str, Any]]:
+        pass
+
+    @abc.abstractmethod
+    def self_validate(self) -> None:
+        pass
+
+
+def trigger_sentry_app_action_creators_for_issues(
+    actions: Sequence[Mapping[str, str]]
+) -> str | None:
+    created = None
+    for action in actions:
+        # Only call creator for Sentry Apps with UI Components for alert rules.
+        if not action.get("id") in SENTRY_APP_ACTIONS:
+            continue
+
+        install = SentryAppInstallation.objects.get(uuid=action.get("sentryAppInstallationUuid"))
+        result: AlertRuleActionResult = alert_rule_actions.AlertRuleActionCreator.run(
+            install=install,
+            fields=action.get("settings"),
+        )
+        # Bubble up errors from Sentry App to the UI
+        if not result["success"]:
+            raise serializers.ValidationError({"actions": [result["message"]]})
+        created = "alert-rule-action"
+    return created
+
+
 class IntegrationEventAction(EventAction, abc.ABC):
     """Intermediate abstract class to help DRY some event actions code."""
 

+ 25 - 25
src/sentry/rules/actions/notify_event_sentry_app.py

@@ -9,7 +9,7 @@ from sentry.api.serializers.models.sentry_app_component import SentryAppAlertRul
 from sentry.eventstore.models import Event
 from sentry.models import Project, SentryApp, SentryAppComponent, SentryAppInstallation
 from sentry.rules import EventState
-from sentry.rules.actions.base import EventAction
+from sentry.rules.actions.base import SentryAppEventAction
 from sentry.rules.base import CallbackFuture
 from sentry.tasks.sentry_apps import notify_sentry_app
 
@@ -29,7 +29,7 @@ def validate_field(value: str | None, field: Mapping[str, Any], app_name: str) -
             )
 
 
-class NotifyEventSentryAppAction(EventAction):
+class NotifyEventSentryAppAction(SentryAppEventAction):
     """
     Used for notifying a *specific* sentry app with a custom webhook payload
     (i.e. specified UI components).
@@ -40,25 +40,7 @@ class NotifyEventSentryAppAction(EventAction):
     # Required field for EventAction, value is ignored
     label = ""
 
-    def get_custom_actions(self, project: Project) -> Sequence[Mapping[str, Any]]:
-        action_list = []
-        for install in SentryAppInstallation.objects.get_installed_for_organization(
-            project.organization_id
-        ):
-            component = install.prepare_sentry_app_components("alert-rule-action", project)
-            if component:
-                kwargs = {
-                    "install": install,
-                    "event_action": self,
-                }
-                action_details = serialize(
-                    component, None, SentryAppAlertRuleActionSerializer(), **kwargs
-                )
-                action_list.append(action_details)
-
-        return action_list
-
-    def get_sentry_app(self, event: Event) -> SentryApp | None:
+    def _get_sentry_app(self, event: Event) -> SentryApp | None:
         extra = {"event_id": event.event_id}
 
         sentry_app_installation_uuid = self.get_option("sentryAppInstallationUuid")
@@ -73,13 +55,31 @@ class NotifyEventSentryAppAction(EventAction):
 
         return None
 
-    def get_setting_value(self, field_name: str) -> str | None:
+    def _get_setting_value(self, field_name: str) -> str | None:
         incoming_settings = self.data.get("settings", [])
         return next(
             (setting["value"] for setting in incoming_settings if setting["name"] == field_name),
             None,
         )
 
+    def get_custom_actions(self, project: Project) -> Sequence[Mapping[str, Any]]:
+        action_list = []
+        for install in SentryAppInstallation.objects.get_installed_for_organization(
+            project.organization_id
+        ):
+            component = install.prepare_sentry_app_components("alert-rule-action", project)
+            if component:
+                kwargs = {
+                    "install": install,
+                    "event_action": self,
+                }
+                action_details = serialize(
+                    component, None, SentryAppAlertRuleActionSerializer(), **kwargs
+                )
+                action_list.append(action_details)
+
+        return action_list
+
     def self_validate(self) -> None:
         sentry_app_installation_uuid = self.data.get("sentryAppInstallationUuid")
         if not sentry_app_installation_uuid:
@@ -116,7 +116,7 @@ class NotifyEventSentryAppAction(EventAction):
         schema = alert_rule_component.schema.get("settings", {})
         for required_field in schema.get("required_fields", []):
             field_name = required_field.get("name")
-            field_value = self.get_setting_value(field_name)
+            field_value = self._get_setting_value(field_name)
             if not field_value:
                 raise ValidationError(
                     f"{sentry_app.name} is missing required settings field: '{field_name}'"
@@ -127,7 +127,7 @@ class NotifyEventSentryAppAction(EventAction):
         # Ensure optional fields are valid
         for optional_field in schema.get("optional_fields", []):
             field_name = optional_field.get("name")
-            field_value = self.get_setting_value(field_name)
+            field_value = self._get_setting_value(field_name)
             validate_field(field_value, optional_field, sentry_app.name)
             valid_fields.add(field_name)
 
@@ -140,7 +140,7 @@ class NotifyEventSentryAppAction(EventAction):
             )
 
     def after(self, event: Event, state: EventState) -> Generator[CallbackFuture, None, None]:
-        sentry_app = self.get_sentry_app(event)
+        sentry_app = self._get_sentry_app(event)
         yield self.future(
             notify_sentry_app,
             sentry_app=sentry_app,

+ 356 - 760
tests/sentry/api/endpoints/test_project_rule_details.py

@@ -1,11 +1,12 @@
 from datetime import datetime
+from typing import Any, Mapping
 from unittest.mock import patch
 
 import responses
-from django.urls import reverse
 from freezegun import freeze_time
 from pytz import UTC
 
+from sentry.integrations.slack.utils.channel import strip_channel_name
 from sentry.models import (
     Environment,
     Integration,
@@ -14,110 +15,104 @@ from sentry.models import (
     RuleActivityType,
     RuleFireHistory,
     RuleStatus,
-    SentryAppComponent,
+    User,
 )
 from sentry.testutils import APITestCase
+from sentry.testutils.helpers import install_slack
 from sentry.utils import json
 
 
-class ProjectRuleDetailsTest(APITestCase):
-    endpoint = "sentry-api-0-project-rule-details"
-
-    def test_simple(self):
-        self.login_as(user=self.user)
+def assert_rule_from_payload(rule: Rule, payload: Mapping[str, Any]) -> None:
+    """
+    Helper function to assert every field on a Rule was modified correctly from the incoming payload
+    """
+    rule.refresh_from_db()
+    assert rule.label == payload.get("name")
 
-        team = self.create_team()
-        project1 = self.create_project(teams=[team], name="foo", fire_project_created=True)
-        self.create_project(teams=[team], name="bar", fire_project_created=True)
-
-        rule = project1.rule_set.all()[0]
+    owner_id = payload.get("owner")
+    if owner_id:
+        assert rule.owner == User.objects.get(id=owner_id).actor
+    else:
+        assert rule.owner is None
 
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project1.organization.slug,
-                "project_slug": project1.slug,
-                "rule_id": rule.id,
-            },
+    environment = payload.get("environment")
+    if environment:
+        assert (
+            rule.environment_id
+            == Environment.objects.get(projects=rule.project, name=environment).id
         )
-        response = self.client.get(url, format="json")
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
-        assert response.data["environment"] is None
-
-    def test_non_existing_rule(self):
-        self.login_as(user=self.user)
-
-        team = self.create_team()
-        project1 = self.create_project(teams=[team], name="foo", fire_project_created=True)
-        self.create_project(teams=[team], name="bar", fire_project_created=True)
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project1.organization.slug,
-                "project_slug": project1.slug,
-                "rule_id": 12345,
-            },
+    else:
+        assert rule.environment_id is None
+    assert rule.data["action_match"] == payload.get("actionMatch")
+    assert rule.data["filter_match"] == payload.get("filterMatch")
+    # For actions/conditions/filters, payload might only have a portion of the rule data so we use
+    # any(a.items() <= b.items()) to check if the payload dict is a subset of the rule.data dict
+    # E.g. payload["actions"] = [{"name": "Test1"}], rule.data["actions"] = [{"name": "Test1", "id": 1}]
+    for payload_action in payload.get("actions", []):
+        # The Slack payload will contain '#channel' or '@user', but we save 'channel' or 'user' on the Rule
+        if (
+            payload_action["id"]
+            == "sentry.integrations.slack.notify_action.SlackNotifyServiceAction"
+        ):
+            payload_action["channel"] = strip_channel_name(payload_action["channel"])
+        assert any(
+            payload_action.items() <= rule_action.items() for rule_action in rule.data["actions"]
         )
-        response = self.client.get(url, format="json")
-
-        assert response.status_code == 404
-
-    def test_with_environment(self):
-        self.login_as(user=self.user)
-
-        team = self.create_team()
-        project1 = self.create_project(teams=[team], name="foo", fire_project_created=True)
-        self.create_project(teams=[team], name="bar", fire_project_created=True)
-
-        rule = project1.rule_set.all()[0]
-        rule.update(environment_id=Environment.get_or_create(rule.project, "production").id)
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project1.organization.slug,
-                "project_slug": project1.slug,
-                "rule_id": rule.id,
-            },
+    payload_conditions = payload.get("conditions", []) + payload.get("filters", [])
+    for payload_condition in payload_conditions:
+        assert any(
+            payload_condition.items() <= rule_condition.items()
+            for rule_condition in rule.data["conditions"]
         )
-        response = self.client.get(url, format="json")
+    assert RuleActivity.objects.filter(rule=rule, type=RuleActivityType.UPDATED.value).exists()
 
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
-        assert response.data["environment"] == "production"
 
-    def test_with_null_environment(self):
-        self.login_as(user=self.user)
+class ProjectRuleDetailsBaseTestCase(APITestCase):
+    endpoint = "sentry-api-0-project-rule-details"
 
-        team = self.create_team()
-        project1 = self.create_project(teams=[team], name="foo", fire_project_created=True)
-        self.create_project(teams=[team], name="bar", fire_project_created=True)
+    def setUp(self):
+        self.rule = self.create_project_rule(project=self.project)
+        self.environment = self.create_environment(self.project, name="production")
+        self.slack_integration = install_slack(organization=self.organization)
+        self.jira_integration = Integration.objects.create(
+            provider="jira", name="Jira", external_id="jira:1"
+        )
+        self.jira_integration.add_organization(self.organization, self.user)
+        self.sentry_app = self.create_sentry_app(
+            name="Pied Piper",
+            organization=self.organization,
+            schema={"elements": [self.create_alert_rule_action_schema()]},
+        )
+        self.sentry_app_installation = self.create_sentry_app_installation(
+            slug=self.sentry_app.slug, organization=self.organization
+        )
+        self.sentry_app_settings_payload = [
+            {"name": "title", "value": "Team Rocket"},
+            {"name": "summary", "value": "We're blasting off again."},
+        ]
+        self.login_as(self.user)
 
-        rule = project1.rule_set.all()[0]
-        rule.update(environment_id=None)
 
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project1.organization.slug,
-                "project_slug": project1.slug,
-                "rule_id": rule.id,
-            },
+class ProjectRuleDetailsTest(ProjectRuleDetailsBaseTestCase):
+    def test_simple(self):
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200
         )
-        response = self.client.get(url, format="json")
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
+        assert response.data["id"] == str(self.rule.id)
         assert response.data["environment"] is None
 
-    def test_with_filters(self):
-        self.login_as(user=self.user)
+    def test_non_existing_rule(self):
+        self.get_error_response(self.organization.slug, self.project.slug, 12345, status_code=404)
 
-        project = self.create_project()
+    def test_with_environment(self):
+        self.rule.update(environment_id=self.environment.id)
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200
+        )
+        assert response.data["id"] == str(self.rule.id)
+        assert response.data["environment"] == self.environment.name
 
+    def test_with_filters(self):
         conditions = [
             {"id": "sentry.rules.conditions.every_event.EveryEventCondition"},
             {"id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", "value": 10},
@@ -130,21 +125,12 @@ class ProjectRuleDetailsTest(APITestCase):
             "action_match": "all",
             "frequency": 30,
         }
+        self.rule.update(data=data)
 
-        rule = Rule.objects.create(project=project, label="foo", data=data)
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200
         )
-        response = self.client.get(url, format="json")
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
+        assert response.data["id"] == str(self.rule.id)
 
         # ensure that conditions and filters are split up correctly
         assert len(response.data["conditions"]) == 1
@@ -154,19 +140,6 @@ class ProjectRuleDetailsTest(APITestCase):
 
     @responses.activate
     def test_with_unresponsive_sentryapp(self):
-        self.login_as(user=self.user)
-
-        self.sentry_app = self.create_sentry_app(
-            organization=self.organization,
-            published=True,
-            verify_install=False,
-            name="Super Awesome App",
-            schema={"elements": [self.create_alert_rule_action_schema()]},
-        )
-        self.installation = self.create_sentry_app_installation(
-            slug=self.sentry_app.slug, organization=self.organization, user=self.user
-        )
-
         conditions = [
             {"id": "sentry.rules.conditions.every_event.EveryEventCondition"},
             {"id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", "value": 10},
@@ -175,7 +148,7 @@ class ProjectRuleDetailsTest(APITestCase):
         actions = [
             {
                 "id": "sentry.rules.actions.notify_event_sentry_app.NotifyEventSentryAppAction",
-                "sentryAppInstallationUuid": self.installation.uuid,
+                "sentryAppInstallationUuid": self.sentry_app_installation.uuid,
                 "settings": [
                     {"name": "title", "value": "An alert"},
                     {"summary": "Something happened here..."},
@@ -191,63 +164,47 @@ class ProjectRuleDetailsTest(APITestCase):
             "action_match": "all",
             "frequency": 30,
         }
+        self.rule.update(data=data)
 
-        rule = Rule.objects.create(project=self.project, label="foo", data=data)
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": self.project.organization.slug,
-                "project_slug": self.project.slug,
-                "rule_id": rule.id,
-            },
-        )
         responses.add(responses.GET, "http://example.com/sentry/members", json={}, status=404)
-
-        response = self.client.get(url, format="json")
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200
+        )
         assert len(responses.calls) == 1
 
         assert response.status_code == 200
         # Returns errors while fetching
         assert len(response.data["errors"]) == 1
-        assert response.data["errors"][0] == {
-            "detail": "Could not fetch details from Super Awesome App"
-        }
+        assert self.sentry_app.name in response.data["errors"][0]["detail"]
 
         # Disables the SentryApp
-        assert response.data["actions"][0]["sentryAppInstallationUuid"] == self.installation.uuid
+        assert (
+            response.data["actions"][0]["sentryAppInstallationUuid"]
+            == self.sentry_app_installation.uuid
+        )
         assert response.data["actions"][0]["disabled"] is True
 
     @freeze_time()
     def test_last_triggered(self):
-        self.login_as(user=self.user)
-        rule = self.create_project_rule()
-        resp = self.get_success_response(
-            self.organization.slug, self.project.slug, rule.id, expand=["lastTriggered"]
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, expand=["lastTriggered"]
         )
-        assert resp.data["lastTriggered"] is None
-        RuleFireHistory.objects.create(project=self.project, rule=rule, group=self.group)
-        resp = self.get_success_response(
-            self.organization.slug, self.project.slug, rule.id, expand=["lastTriggered"]
+        assert response.data["lastTriggered"] is None
+        RuleFireHistory.objects.create(project=self.project, rule=self.rule, group=self.group)
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, expand=["lastTriggered"]
         )
-        assert resp.data["lastTriggered"] == datetime.now().replace(tzinfo=UTC)
+        assert response.data["lastTriggered"] == datetime.now().replace(tzinfo=UTC)
 
     def test_with_jira_action_error(self):
-        self.login_as(user=self.user)
-        self.integration = Integration.objects.create(
-            provider="jira", name="Jira", external_id="jira:1"
-        )
-        self.integration.add_organization(self.organization, self.user)
-
         conditions = [
             {"id": "sentry.rules.conditions.every_event.EveryEventCondition"},
             {"id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", "value": 10},
         ]
-
         actions = [
             {
                 "id": "sentry.integrations.jira.notify_action.JiraCreateTicketAction",
-                "integration": self.integration.id,
+                "integration": self.jira_integration.id,
                 "customfield_epic_link": "EPIC-3",
                 "customfield_severity": "Medium",
                 "dynamic_form_fields": [
@@ -261,7 +218,7 @@ class ProjectRuleDetailsTest(APITestCase):
                         "name": "customfield_epic_link",
                         "required": False,
                         "type": "select",
-                        "url": f"/extensions/jira/search/{self.organization.slug}/{self.integration.id}/",
+                        "url": f"/extensions/jira/search/{self.organization.slug}/{self.jira_integration.id}/",
                     },
                     {
                         "choices": [
@@ -286,21 +243,11 @@ class ProjectRuleDetailsTest(APITestCase):
             "frequency": 30,
         }
 
-        rule = Rule.objects.create(project=self.project, label="foo", data=data)
+        self.rule.update(data=data)
 
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": self.project.organization.slug,
-                "project_slug": self.project.slug,
-                "rule_id": rule.id,
-            },
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200
         )
-
-        response = self.client.get(url, format="json")
-
-        assert response.status_code == 200
-
         # Expect that the choices get filtered to match the API: Array<string, string>
         assert response.data["actions"][0].get("dynamic_form_fields")[0].get("choices") == [
             ["EPIC-1", "Citizen Knope"],
@@ -308,15 +255,11 @@ class ProjectRuleDetailsTest(APITestCase):
         ]
 
 
-class UpdateProjectRuleTest(APITestCase):
+class UpdateProjectRuleTest(ProjectRuleDetailsBaseTestCase):
+    method = "PUT"
+
     @patch("sentry.signals.alert_rule_edited.send_robust")
     def test_simple(self, send_robust):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
         conditions = [
             {
                 "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition",
@@ -325,52 +268,22 @@ class UpdateProjectRuleTest(APITestCase):
                 "value": "bar",
             }
         ]
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "owner": self.user.id,
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
-                "conditions": conditions,
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "owner": self.user.id,
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
+            "conditions": conditions,
+        }
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
-
-        rule = Rule.objects.get(id=rule.id)
-        assert rule.label == "hello world"
-        assert rule.owner == self.user.actor
-        assert rule.environment_id is None
-        assert rule.data["action_match"] == "any"
-        assert rule.data["filter_match"] == "any"
-        assert rule.data["actions"] == [
-            {"id": "sentry.rules.actions.notify_event.NotifyEventAction"}
-        ]
-        assert rule.data["conditions"] == conditions
-
-        assert RuleActivity.objects.filter(rule=rule, type=RuleActivityType.UPDATED.value).exists()
+        assert response.data["id"] == str(self.rule.id)
+        assert_rule_from_payload(self.rule, payload)
         assert send_robust.called
 
     def test_no_owner(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
         conditions = [
             {
                 "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition",
@@ -379,220 +292,107 @@ class UpdateProjectRuleTest(APITestCase):
                 "value": "bar",
             }
         ]
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "owner": None,
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
-                "conditions": conditions,
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "owner": None,
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
+            "conditions": conditions,
+        }
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
-
-        rule = Rule.objects.get(id=rule.id)
-        assert rule.label == "hello world"
-        assert rule.owner is None
-        assert rule.environment_id is None
-        assert rule.data["action_match"] == "any"
-        assert rule.data["filter_match"] == "any"
-        assert rule.data["actions"] == [
-            {"id": "sentry.rules.actions.notify_event.NotifyEventAction"}
-        ]
-        assert rule.data["conditions"] == conditions
-        assert RuleActivity.objects.filter(rule=rule, type=RuleActivityType.UPDATED.value).exists()
+        assert response.data["id"] == str(self.rule.id)
+        assert_rule_from_payload(self.rule, payload)
 
     def test_update_name(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
+        conditions = [
+            {
+                "interval": "1h",
+                "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition",
+                "value": 666,
+                "name": "The issue is seen more than 30 times in 1m",
+            }
+        ]
+        actions = [
+            {
+                "id": "sentry.rules.actions.notify_event.NotifyEventAction",
+                "name": "Send a notification (for all legacy integrations)",
+            }
+        ]
+        payload = {
+            "name": "test",
+            "environment": None,
+            "actionMatch": "all",
+            "filterMatch": "all",
+            "frequency": 30,
+            "conditions": conditions,
+            "actions": actions,
+        }
 
-        response = self.client.put(
-            url,
-            data={
-                "environment": None,
-                "actionMatch": "all",
-                "filterMatch": "all",
-                "frequency": 30,
-                "name": "test",
-                "conditions": [
-                    {
-                        "interval": "1h",
-                        "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition",
-                        "value": 666,
-                        "name": "The issue is seen more than 30 times in 1m",
-                    }
-                ],
-                "id": rule.id,
-                "actions": [
-                    {
-                        "id": "sentry.rules.actions.notify_event.NotifyEventAction",
-                        "name": "Send a notification (for all legacy integrations)",
-                    }
-                ],
-                "dateCreated": "2018-04-24T23:37:21.246Z",
-            },
-            format="json",
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
-
-        assert response.status_code == 200, response.content
         assert (
             response.data["conditions"][0]["name"] == "The issue is seen more than 666 times in 1h"
         )
-
-        assert RuleActivity.objects.filter(rule=rule, type=RuleActivityType.UPDATED.value).exists()
+        assert_rule_from_payload(self.rule, payload)
 
     def test_with_environment(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        Environment.get_or_create(project, "production")
-
-        rule = Rule.objects.create(project=project, label="foo")
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "environment": "production",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
-                "conditions": [
-                    {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
-                ],
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "environment": self.environment.name,
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
+            "conditions": [
+                {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
+            ],
+        }
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
-        assert response.data["environment"] == "production"
-
-        rule = Rule.objects.get(id=rule.id)
-        assert rule.label == "hello world"
-        assert rule.environment_id == Environment.get_or_create(rule.project, "production").id
+        assert response.data["id"] == str(self.rule.id)
+        assert response.data["environment"] == self.environment.name
+        assert_rule_from_payload(self.rule, payload)
 
     def test_with_null_environment(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(
-            project=project,
-            environment_id=Environment.get_or_create(project, "production").id,
-            label="foo",
-        )
+        self.rule.update(environment_id=self.environment.id)
+
+        payload = {
+            "name": "hello world",
+            "environment": None,
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
+            "conditions": [
+                {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
+            ],
+        }
 
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "environment": None,
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
-                "conditions": [
-                    {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
-                ],
-            },
-            format="json",
-        )
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
+        assert response.data["id"] == str(self.rule.id)
         assert response.data["environment"] is None
-
-        rule = Rule.objects.get(id=rule.id)
-        assert rule.label == "hello world"
-        assert rule.environment_id is None
+        assert_rule_from_payload(self.rule, payload)
 
     @responses.activate
     def test_update_channel_slack(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-        integration = Integration.objects.create(
-            provider="slack",
-            name="Awesome Team",
-            external_id="TXXXXXXX1",
-            metadata={
-                "access_token": "xoxp-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx",
-                "installation_type": "born_as_bot",
-            },
-        )
-        integration.add_organization(project.organization, self.user)
-
         conditions = [{"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}]
-
         actions = [
             {
                 "channel_id": "old_channel_id",
-                "workspace": integration.id,
+                "workspace": str(self.slack_integration.id),
                 "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction",
                 "channel": "#old_channel_name",
             }
         ]
-
-        rule = Rule.objects.create(
-            project=project,
-            data={"conditions": [conditions], "actions": [actions]},
-        )
+        self.rule.update(data={"conditions": conditions, "actions": actions})
 
         actions[0]["channel"] = "#new_channel_name"
         actions[0]["channel_id"] = "new_channel_id"
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-
         channels = {
             "ok": "true",
             "channels": [
@@ -616,63 +416,31 @@ class UpdateProjectRuleTest(APITestCase):
             body=json.dumps({"ok": channels["ok"], "channel": channels["channels"][1]}),
         )
 
-        response = self.client.put(
-            url,
-            data={
-                "name": "#new_channel_name",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "actions": actions,
-                "conditions": conditions,
-                "frequency": 30,
-            },
-            format="json",
+        payload = {
+            "name": "#new_channel_name",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": actions,
+            "conditions": conditions,
+            "frequency": 30,
+        }
+        self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
-
-        assert response.status_code == 200, response.content
-        rule = Rule.objects.get(id=response.data["id"])
-        assert rule.label == "#new_channel_name"
-        assert rule.data["actions"][0]["channel_id"] == "new_channel_id"
+        assert_rule_from_payload(self.rule, payload)
 
     @responses.activate
     def test_update_channel_slack_workspace_fail(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-        integration = Integration.objects.create(
-            provider="slack",
-            name="Awesome Team",
-            external_id="TXXXXXXX1",
-            metadata={"access_token": "xoxp-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"},
-        )
-        integration.add_organization(project.organization, self.user)
-
         conditions = [{"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}]
-
         actions = [
             {
                 "channel_id": "old_channel_id",
-                "workspace": integration.id,
+                "workspace": str(self.slack_integration.id),
                 "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction",
                 "channel": "#old_channel_name",
             }
         ]
-
-        rule = Rule.objects.create(
-            project=project,
-            data={"conditions": [conditions], "actions": [actions]},
-        )
-
-        actions[0]["channel"] = "#new_channel_name"
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
+        self.rule.update(data={"conditions": conditions, "actions": actions})
 
         channels = {
             "ok": "true",
@@ -696,380 +464,208 @@ class UpdateProjectRuleTest(APITestCase):
             body=json.dumps({"ok": channels["ok"], "channel": channels["channels"][0]}),
         )
 
-        response = self.client.put(
-            url,
-            data={
-                "name": "#new_channel_name",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "actions": actions,
-                "conditions": conditions,
-                "frequency": 30,
-            },
-            format="json",
+        actions[0]["channel"] = "#new_channel_name"
+        payload = {
+            "name": "#new_channel_name",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": actions,
+            "conditions": conditions,
+            "frequency": 30,
+        }
+        self.get_error_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=400, **payload
         )
 
-        assert response.status_code == 400, response.content
-
     @responses.activate
     def test_slack_channel_id_saved(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(
-            project=project,
-            environment_id=Environment.get_or_create(project, "production").id,
-            label="foo",
-        )
-        integration = Integration.objects.create(
-            provider="slack",
-            name="Awesome Team",
-            external_id="TXXXXXXX1",
-            metadata={"access_token": "xoxp-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"},
-        )
-        integration.add_organization(project.organization, self.user)
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
+        channel_id = "CSVK0921"
         responses.add(
             method=responses.GET,
             url="https://slack.com/api/conversations.info",
             status=200,
             content_type="application/json",
             body=json.dumps(
-                {"ok": "true", "channel": {"name": "team-team-team", "id": "CSVK0921"}}
+                {"ok": "true", "channel": {"name": "team-team-team", "id": channel_id}}
             ),
         )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "environment": None,
-                "actionMatch": "any",
-                "actions": [
-                    {
-                        "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction",
-                        "name": "Send a notification to the funinthesun Slack workspace to #team-team-team and show tags [] in notification",
-                        "workspace": integration.id,
-                        "channel": "#team-team-team",
-                        "channel_id": "CSVK0921",
-                    }
-                ],
-                "conditions": [
-                    {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
-                ],
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "environment": None,
+            "actionMatch": "any",
+            "actions": [
+                {
+                    "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction",
+                    "name": "Send a notification to the funinthesun Slack workspace to #team-team-team and show tags [] in notification",
+                    "workspace": str(self.slack_integration.id),
+                    "channel": "#team-team-team",
+                    "channel_id": channel_id,
+                }
+            ],
+            "conditions": [
+                {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
+            ],
+        }
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
-        assert response.data["actions"][0]["channel_id"] == "CSVK0921"
+        assert response.data["id"] == str(self.rule.id)
+        assert response.data["actions"][0]["channel_id"] == channel_id
 
     def test_invalid_rule_node_type(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "conditions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
-                "actions": [],
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "conditions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
+            "actions": [],
+        }
+        self.get_error_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=400, **payload
         )
 
-        assert response.status_code == 400, response.content
-
     def test_invalid_rule_node(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "conditions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
-                "actions": [{"id": "foo"}],
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "conditions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
+            "actions": [{"id": "foo"}],
+        }
+        self.get_error_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=400, **payload
         )
 
-        assert response.status_code == 400, response.content
-
     def test_rule_form_not_valid(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "conditions": [{"id": "sentry.rules.conditions.tagged_event.TaggedEventCondition"}],
-                "actions": [],
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "conditions": [{"id": "sentry.rules.conditions.tagged_event.TaggedEventCondition"}],
+            "actions": [],
+        }
+        self.get_error_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=400, **payload
         )
 
-        assert response.status_code == 400, response.content
-
     def test_rule_form_owner_perms(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        other_user = self.create_user()
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "conditions": [{"id": "sentry.rules.conditions.tagged_event.TaggedEventCondition"}],
-                "actions": [],
-                "owner": other_user.actor.get_actor_identifier(),
-            },
-            format="json",
+        new_user = self.create_user()
+        payload = {
+            "name": "hello world",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "conditions": [{"id": "sentry.rules.conditions.tagged_event.TaggedEventCondition"}],
+            "actions": [],
+            "owner": new_user.actor.get_actor_identifier(),
+        }
+        response = self.get_error_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=400, **payload
         )
-
-        assert response.status_code == 400, response.content
         assert str(response.data["owner"][0]) == "User is not a member of this organization"
 
     def test_rule_form_missing_action(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "action": [],
-                "conditions": [{"id": "sentry.rules.conditions.tagged_event.TaggedEventCondition"}],
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "action": [],
+            "conditions": [{"id": "sentry.rules.conditions.tagged_event.TaggedEventCondition"}],
+        }
+        self.get_error_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=400, **payload
         )
 
-        assert response.status_code == 400, response.content
-
     def test_update_filters(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
         conditions = [{"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}]
         filters = [
             {"id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", "value": 10}
         ]
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        response = self.client.put(
-            url,
-            data={
-                "name": "hello world",
-                "actionMatch": "any",
-                "filterMatch": "any",
-                "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
-                "conditions": conditions,
-                "filters": filters,
-            },
-            format="json",
+        payload = {
+            "name": "hello world",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}],
+            "conditions": conditions,
+            "filters": filters,
+        }
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
+        assert response.data["id"] == str(self.rule.id)
 
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
+        assert_rule_from_payload(self.rule, payload)
 
-        rule = Rule.objects.get(id=rule.id)
-        assert rule.label == "hello world"
-        assert rule.environment_id is None
-        assert rule.data["action_match"] == "any"
-        assert rule.data["filter_match"] == "any"
-        assert rule.data["actions"] == [
-            {"id": "sentry.rules.actions.notify_event.NotifyEventAction"}
+    @responses.activate
+    def test_update_sentry_app_action_success(self):
+        responses.add(
+            method=responses.POST,
+            url="https://example.com/sentry/alert-rule",
+            status=202,
+        )
+        actions = [
+            {
+                "id": "sentry.rules.actions.notify_event_sentry_app.NotifyEventSentryAppAction",
+                "settings": self.sentry_app_settings_payload,
+                "sentryAppInstallationUuid": self.sentry_app_installation.uuid,
+                "hasSchemaFormConfig": True,
+            },
         ]
-        assert rule.data["conditions"] == conditions + filters
-
-        assert RuleActivity.objects.filter(rule=rule, type=RuleActivityType.UPDATED.value).exists()
-
-    @patch("sentry.mediators.alert_rule_actions.AlertRuleActionCreator.run")
-    def test_update_alert_rule_action(self, mock_alert_rule_action_creator):
-        """
-        Ensures that Sentry Apps with schema forms (UI components)
-        receive a payload when an alert rule is updated with them.
-        """
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="my super cool rule")
 
-        sentry_app = self.create_sentry_app(
-            name="Pied Piper",
-            organization=project.organization,
-            schema={"elements": [self.create_alert_rule_action_schema()]},
-        )
-        install = self.create_sentry_app_installation(
-            slug="pied-piper", organization=project.organization
+        payload = {
+            "name": "my super cool rule",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": actions,
+            "conditions": [],
+            "filters": [],
+        }
+        self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
         )
+        assert_rule_from_payload(self.rule, payload)
+        assert len(responses.calls) == 1
 
-        sentry_app_component = SentryAppComponent.objects.get(
-            sentry_app=sentry_app, type="alert-rule-action"
+    @responses.activate
+    def test_update_sentry_app_action_failure(self):
+        error_message = "Something is totally broken :'("
+        responses.add(
+            method=responses.POST,
+            url="https://example.com/sentry/alert-rule",
+            status=500,
+            json={"message": error_message},
         )
-
         actions = [
             {
                 "id": "sentry.rules.actions.notify_event_sentry_app.NotifyEventSentryAppAction",
-                "settings": [
-                    {"name": "title", "value": "Team Rocket"},
-                    {"name": "summary", "value": "We're blasting off again."},
-                ],
-                "sentryAppInstallationUuid": install.uuid,
+                "settings": self.sentry_app_settings_payload,
+                "sentryAppInstallationUuid": self.sentry_app_installation.uuid,
                 "hasSchemaFormConfig": True,
             },
         ]
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
-        )
-        with patch(
-            "sentry.mediators.sentry_app_components.Preparer.run", return_value=sentry_app_component
-        ):
-            response = self.client.put(
-                url,
-                data={
-                    "name": "my super cool rule",
-                    "actionMatch": "any",
-                    "filterMatch": "any",
-                    "actions": actions,
-                    "conditions": [],
-                    "filters": [],
-                },
-                format="json",
-            )
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"] == str(rule.id)
-
-        rule = Rule.objects.get(id=rule.id)
-        assert rule.data["actions"] == actions
-
-        kwargs = {
-            "install": install,
-            "fields": actions[0].get("settings"),
+        payload = {
+            "name": "my super cool rule",
+            "actionMatch": "any",
+            "filterMatch": "any",
+            "actions": actions,
+            "conditions": [],
+            "filters": [],
         }
-
-        call_kwargs = mock_alert_rule_action_creator.call_args[1]
-
-        assert call_kwargs["install"].id == kwargs["install"].id
-        assert call_kwargs["fields"] == kwargs["fields"]
-
-        assert RuleActivity.objects.filter(rule=rule, type=RuleActivityType.UPDATED.value).exists()
-
-
-class DeleteProjectRuleTest(APITestCase):
-    def test_simple(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        rule = Rule.objects.create(project=project, label="foo")
-
-        url = reverse(
-            "sentry-api-0-project-rule-details",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-                "rule_id": rule.id,
-            },
+        response = self.get_error_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=400, **payload
         )
-        response = self.client.delete(url)
+        assert len(responses.calls) == 1
+        assert error_message in response.json().get("actions")[0]
 
-        assert response.status_code == 202, response.content
 
-        rule = Rule.objects.get(id=rule.id)
-        assert rule.status == RuleStatus.PENDING_DELETION
+class DeleteProjectRuleTest(ProjectRuleDetailsBaseTestCase):
+    method = "DELETE"
 
-        assert RuleActivity.objects.filter(rule=rule, type=RuleActivityType.DELETED.value).exists()
+    def test_simple(self):
+        self.get_success_response(
+            self.organization.slug, self.project.slug, self.rule.id, status_code=202
+        )
+        self.rule.refresh_from_db()
+        assert self.rule.status == RuleStatus.PENDING_DELETION
+        assert RuleActivity.objects.filter(
+            rule=self.rule, type=RuleActivityType.DELETED.value
+        ).exists()

+ 132 - 161
tests/sentry/api/endpoints/test_project_rules.py

@@ -1,51 +1,61 @@
+from __future__ import annotations
+
 from copy import deepcopy
+from typing import Any, Mapping, Sequence
 from unittest.mock import patch
 
 import responses
-from django.urls import reverse
 
-from sentry.models import Environment, Integration, Rule, RuleActivity, RuleActivityType
+from sentry.models import Environment, Rule, RuleActivity, RuleActivityType
 from sentry.testutils import APITestCase
+from sentry.testutils.helpers import install_slack
 from sentry.utils import json
 
 
-class ProjectRuleListTest(APITestCase):
-    def test_simple(self):
-        self.login_as(user=self.user)
-
-        team = self.create_team()
-        project1 = self.create_project(teams=[team], name="foo")
-        self.create_project(teams=[team], name="bar")
+class ProjectRuleBaseTestCase(APITestCase):
+    endpoint = "sentry-api-0-project-rules"
 
-        url = reverse(
-            "sentry-api-0-project-rules",
-            kwargs={"organization_slug": project1.organization.slug, "project_slug": project1.slug},
+    def setUp(self):
+        self.rule = self.create_project_rule(project=self.project)
+        self.slack_integration = install_slack(organization=self.organization)
+        self.sentry_app = self.create_sentry_app(
+            name="Pied Piper",
+            organization=self.organization,
+            schema={"elements": [self.create_alert_rule_action_schema()]},
+        )
+        self.sentry_app_installation = self.create_sentry_app_installation(
+            slug=self.sentry_app.slug, organization=self.organization
         )
-        response = self.client.get(url, format="json")
+        self.sentry_app_settings_payload = [
+            {"name": "title", "value": "Team Rocket"},
+            {"name": "summary", "value": "We're blasting off again."},
+        ]
+        self.login_as(user=self.user)
 
-        assert response.status_code == 200, response.content
 
-        rule_count = Rule.objects.filter(project=project1).count()
-        assert len(response.data) == rule_count
+class ProjectRuleListTest(ProjectRuleBaseTestCase):
+    def test_simple(self):
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, status_code=200
+        )
+        assert len(response.data) == Rule.objects.filter(project=self.project).count()
 
 
-class CreateProjectRuleTest(APITestCase):
-    endpoint = "sentry-api-0-project-rules"
+class CreateProjectRuleTest(ProjectRuleBaseTestCase):
     method = "post"
 
     def run_test(
         self,
-        actions,
-        expected_conditions=None,
-        filters=None,
-        name="hello world",
-        action_match="any",
-        filter_match="any",
-        frequency=30,
-        conditions=None,
-        **kwargs,
+        actions: Sequence[Mapping[str, Any]] | None = None,
+        conditions: Sequence[Mapping[str, Any]] | None = None,
+        filters: Sequence[Mapping[str, Any]] | None = None,
+        expected_conditions: Sequence[Mapping[str, Any]] | None = None,
+        name: str | None = "hello world",
+        action_match: str | None = "any",
+        filter_match: str | None = "any",
+        frequency: int | None = 30,
+        **kwargs: Any,
     ):
-        self.login_as(user=self.user)
         owner = self.user.actor.get_actor_identifier()
         query_args = {}
         if "environment" in kwargs:
@@ -121,59 +131,42 @@ class CreateProjectRuleTest(APITestCase):
 
     @responses.activate
     def test_slack_channel_id_saved(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-        integration = Integration.objects.create(
-            provider="slack",
-            name="Awesome Team",
-            external_id="TXXXXXXX1",
-            metadata={"access_token": "xoxp-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"},
-        )
-        integration.add_organization(project.organization, self.user)
-
-        url = reverse(
-            "sentry-api-0-project-rules",
-            kwargs={"organization_slug": project.organization.slug, "project_slug": project.slug},
-        )
+        channel_id = "CSVK0921"
         responses.add(
             method=responses.GET,
             url="https://slack.com/api/conversations.info",
             status=200,
             content_type="application/json",
             body=json.dumps(
-                {"ok": "true", "channel": {"name": "team-team-team", "id": "CSVK0921"}}
+                {"ok": "true", "channel": {"name": "team-team-team", "id": channel_id}}
             ),
         )
-        response = self.client.post(
-            url,
-            data={
-                "name": "hello world",
-                "owner": f"user:{self.user.id}",
-                "environment": None,
-                "actionMatch": "any",
-                "frequency": 5,
-                "actions": [
-                    {
-                        "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction",
-                        "name": "Send a notification to the funinthesun Slack workspace to #team-team-team and show tags [] in notification",
-                        "workspace": integration.id,
-                        "channel": "#team-team-team",
-                        "input_channel_id": "CSVK0921",
-                    }
-                ],
-                "conditions": [
-                    {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
-                ],
-            },
-            format="json",
-        )
+        payload = {
+            "name": "hello world",
+            "owner": f"user:{self.user.id}",
+            "environment": None,
+            "actionMatch": "any",
+            "frequency": 5,
+            "actions": [
+                {
+                    "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction",
+                    "name": "Send a notification to the funinthesun Slack workspace to #team-team-team and show tags [] in notification",
+                    "workspace": str(self.slack_integration.id),
+                    "channel": "#team-team-team",
+                    "input_channel_id": channel_id,
+                }
+            ],
+            "conditions": [
+                {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
+            ],
+        }
 
-        assert response.status_code == 200, response.content
-        assert response.data["actions"][0]["channel_id"] == "CSVK0921"
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, status_code=200, **payload
+        )
+        assert response.data["actions"][0]["channel_id"] == channel_id
 
     def test_missing_name(self):
-        self.login_as(user=self.user)
         conditions = [{"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}]
         actions = [{"id": "sentry.rules.actions.notify_event.NotifyEventAction"}]
         self.get_error_response(
@@ -188,7 +181,6 @@ class CreateProjectRuleTest(APITestCase):
         )
 
     def test_owner_perms(self):
-        self.login_as(user=self.user)
         other_user = self.create_user()
         response = self.get_error_response(
             self.organization.slug,
@@ -217,7 +209,6 @@ class CreateProjectRuleTest(APITestCase):
         assert str(response.data["owner"][0]) == "Team is not a member of this organization"
 
     def test_frequency_percent_validation(self):
-        self.login_as(user=self.user)
         condition = {
             "id": "sentry.rules.conditions.event_frequency.EventFrequencyPercentCondition",
             "interval": "1h",
@@ -310,7 +301,6 @@ class CreateProjectRuleTest(APITestCase):
         )
 
     def test_with_filters_without_match(self):
-        self.login_as(user=self.user)
         conditions = [{"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}]
         filters = [
             {"id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", "value": 10}
@@ -347,27 +337,8 @@ class CreateProjectRuleTest(APITestCase):
     def test_kicks_off_slack_async_job(
         self, mock_uuid4, mock_find_channel_id_for_alert_rule, mock_get_channel_id
     ):
-        project = self.create_project()
-
         mock_uuid4.return_value = self.get_mock_uuid()
-        self.login_as(self.user)
-
-        integration = Integration.objects.create(
-            provider="slack",
-            name="Awesome Team",
-            external_id="TXXXXXXX1",
-            metadata={"access_token": "xoxp-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"},
-        )
-        integration.add_organization(project.organization, self.user)
-
-        url = reverse(
-            "sentry-api-0-project-rules",
-            kwargs={
-                "organization_slug": project.organization.slug,
-                "project_slug": project.slug,
-            },
-        )
-        data = {
+        payload = {
             "name": "hello world",
             "owner": f"user:{self.user.id}",
             "environment": None,
@@ -377,7 +348,7 @@ class CreateProjectRuleTest(APITestCase):
                 {
                     "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction",
                     "name": "Send a notification to the funinthesun Slack workspace to #team-team-team and show tags [] in notification",
-                    "workspace": str(integration.id),
+                    "workspace": str(self.slack_integration.id),
                     "channel": "#team-team-team",
                     "channel_id": "",
                     "tags": "",
@@ -387,27 +358,26 @@ class CreateProjectRuleTest(APITestCase):
                 {"id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}
             ],
         }
-        self.client.post(
-            url,
-            data=data,
-            format="json",
+
+        self.get_success_response(
+            self.organization.slug, self.project.slug, status_code=202, **payload
         )
 
-        assert not Rule.objects.filter(label="hello world").exists()
+        assert not Rule.objects.filter(label=payload["name"]).exists()
         kwargs = {
-            "name": data["name"],
+            "name": payload["name"],
             "owner": self.user.actor.id,
-            "environment": data.get("environment"),
-            "action_match": data["actionMatch"],
-            "filter_match": data.get("filterMatch"),
-            "conditions": data.get("conditions", []) + data.get("filters", []),
-            "actions": data.get("actions", []),
-            "frequency": data.get("frequency"),
+            "environment": payload.get("environment"),
+            "action_match": payload["actionMatch"],
+            "filter_match": payload.get("filterMatch"),
+            "conditions": payload.get("conditions", []) + payload.get("filters", []),
+            "actions": payload.get("actions", []),
+            "frequency": payload.get("frequency"),
             "user_id": self.user.id,
             "uuid": "abc123",
         }
         call_args = mock_find_channel_id_for_alert_rule.call_args[1]["kwargs"]
-        assert call_args.pop("project").id == project.id
+        assert call_args.pop("project").id == self.project.id
         assert call_args == kwargs
 
     def test_comparison_condition(self):
@@ -439,7 +409,6 @@ class CreateProjectRuleTest(APITestCase):
         self.run_test(actions=actions, conditions=[condition])
 
     def test_comparison_condition_validation(self):
-        self.login_as(user=self.user)
         condition = {
             "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition",
             "interval": "1h",
@@ -480,69 +449,71 @@ class CreateProjectRuleTest(APITestCase):
             == "Select a valid choice. bad data is not one of the available choices."
         )
 
-    @patch("sentry.mediators.alert_rule_actions.AlertRuleActionCreator.run")
-    def test_runs_alert_rule_action_creator(self, mock_alert_rule_action_creator):
-        """
-        Ensures that Sentry Apps with schema forms (UI components)
-        receive a payload when an alert rule is created with them.
-        """
-        self.login_as(user=self.user)
-
-        project = self.create_project()
-
-        self.create_sentry_app(
-            name="Pied Piper",
-            organization=project.organization,
-            schema={"elements": [self.create_alert_rule_action_schema()]},
-        )
-        install = self.create_sentry_app_installation(
-            slug="pied-piper", organization=project.organization
+    @responses.activate
+    def test_create_sentry_app_action_success(self):
+        responses.add(
+            method=responses.POST,
+            url="https://example.com/sentry/alert-rule",
+            status=202,
         )
-
         actions = [
             {
                 "id": "sentry.rules.actions.notify_event_sentry_app.NotifyEventSentryAppAction",
-                "settings": [
-                    {"name": "title", "value": "Team Rocket"},
-                    {"name": "summary", "value": "We're blasting off again."},
-                ],
-                "sentryAppInstallationUuid": install.uuid,
+                "settings": self.sentry_app_settings_payload,
+                "sentryAppInstallationUuid": self.sentry_app_installation.uuid,
                 "hasSchemaFormConfig": True,
             },
         ]
+        payload = {
+            "name": "my super cool rule",
+            "owner": f"user:{self.user.id}",
+            "conditions": [],
+            "filters": [],
+            "actions": actions,
+            "filterMatch": "any",
+            "actionMatch": "any",
+            "frequency": 30,
+        }
 
-        url = reverse(
-            "sentry-api-0-project-rules",
-            kwargs={"organization_slug": project.organization.slug, "project_slug": project.slug},
-        )
-
-        response = self.client.post(
-            url,
-            data={
-                "name": "my super cool rule",
-                "owner": f"user:{self.user.id}",
-                "conditions": [],
-                "filters": [],
-                "actions": actions,
-                "filterMatch": "any",
-                "actionMatch": "any",
-                "frequency": 30,
-            },
-            format="json",
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, status_code=200, **payload
         )
-
-        assert response.status_code == 200, response.content
-        assert response.data["id"]
-
-        rule = Rule.objects.get(id=response.data["id"])
+        new_rule_id = response.data["id"]
+        assert new_rule_id is not None
+        rule = Rule.objects.get(id=new_rule_id)
         assert rule.data["actions"] == actions
+        assert len(responses.calls) == 1
 
-        kwargs = {
-            "install": install,
-            "fields": actions[0].get("settings"),
+    @responses.activate
+    def test_create_sentry_app_action_failure(self):
+        error_message = "Something is totally broken :'("
+        responses.add(
+            method=responses.POST,
+            url="https://example.com/sentry/alert-rule",
+            status=500,
+            json={"message": error_message},
+        )
+        actions = [
+            {
+                "id": "sentry.rules.actions.notify_event_sentry_app.NotifyEventSentryAppAction",
+                "settings": self.sentry_app_settings_payload,
+                "sentryAppInstallationUuid": self.sentry_app_installation.uuid,
+                "hasSchemaFormConfig": True,
+            },
+        ]
+        payload = {
+            "name": "my super cool rule",
+            "owner": f"user:{self.user.id}",
+            "conditions": [],
+            "filters": [],
+            "actions": actions,
+            "filterMatch": "any",
+            "actionMatch": "any",
+            "frequency": 30,
         }
 
-        call_kwargs = mock_alert_rule_action_creator.call_args[1]
-
-        assert call_kwargs["install"].id == kwargs["install"].id
-        assert call_kwargs["fields"] == kwargs["fields"]
+        response = self.get_error_response(
+            self.organization.slug, self.project.slug, status_code=400, **payload
+        )
+        assert len(responses.calls) == 1
+        assert error_message in response.json().get("actions")[0]