diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c773c37..e74b72c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix a bug with permissions for telegram user settings by @alexintech ([#2075](https://github.com/grafana/oncall/pull/2075)) +- Fix orphaned messages in Slack by @vadimkerr ([#2023](https://github.com/grafana/oncall/pull/2023)) ## v1.2.34 (2023-05-31) diff --git a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py index 0718bd68..5f32166d 100644 --- a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py @@ -107,7 +107,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): "name": ScenarioStep.get_step("distribute_alerts", "UnAttachGroupStep").routing_uid(), "text": "Unattach", "type": "button", - "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), + "value": self._alert_group_action_value(), } ], } @@ -180,7 +180,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): "emoji": True, }, "type": "button", - "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), + "value": self._alert_group_action_value(), "action_id": ScenarioStep.get_step( "distribute_alerts", "AcknowledgeGroupStep", @@ -196,7 +196,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): "emoji": True, }, "type": "button", - "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), + "value": self._alert_group_action_value(), "action_id": ScenarioStep.get_step( "distribute_alerts", "UnAcknowledgeGroupStep", @@ -208,7 +208,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): "text": {"type": "plain_text", "text": "Resolve", "emoji": True}, "type": "button", "style": "primary", - "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), + "value": self._alert_group_action_value(), "action_id": ScenarioStep.get_step("distribute_alerts", "ResolveGroupStep").routing_uid(), }, ) @@ -221,7 +221,10 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): if not self.alert_group.silenced: silence_options = [ - {"text": {"type": "plain_text", "text": text, "emoji": True}, "value": str(value)} + { + "text": {"type": "plain_text", "text": text, "emoji": True}, + "value": self._alert_group_action_value(delay=value), + } for value, text in AlertGroup.SILENCE_DELAY_OPTIONS ] buttons.append( @@ -230,7 +233,6 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): "type": "static_select", "options": silence_options, "action_id": ScenarioStep.get_step("distribute_alerts", "SilenceGroupStep").routing_uid(), - # "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), } ) else: @@ -238,7 +240,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): { "text": {"type": "plain_text", "text": "Unsilence", "emoji": True}, "type": "button", - "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), + "value": self._alert_group_action_value(), "action_id": ScenarioStep.get_step("distribute_alerts", "UnSilenceGroupStep").routing_uid(), }, ) @@ -247,12 +249,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): "text": {"type": "plain_text", "text": "Attach to ...", "emoji": True}, "type": "button", "action_id": ScenarioStep.get_step("distribute_alerts", "SelectAttachGroupStep").routing_uid(), - "value": json.dumps( - { - "alert_group_pk": self.alert_group.pk, - "organization_id": self.alert_group.channel.organization_id, - } - ), + "value": self._alert_group_action_value(), } buttons.append(attach_button) else: @@ -260,7 +257,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): { "text": {"type": "plain_text", "text": "Unresolve", "emoji": True}, "type": "button", - "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), + "value": self._alert_group_action_value(), "action_id": ScenarioStep.get_step("distribute_alerts", "UnResolveGroupStep").routing_uid(), }, ) @@ -270,12 +267,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): { "text": {"type": "plain_text", "text": ":mag: Format Alert", "emoji": True}, "type": "button", - "value": json.dumps( - { - "alert_group_pk": str(self.alert_group.pk), - "organization_id": self.alert_group.channel.organization_id, - } - ), + "value": self._alert_group_action_value(), "action_id": ScenarioStep.get_step( "alertgroup_appearance", "OpenAlertAppearanceDialogStep" ).routing_uid(), @@ -292,13 +284,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): }, "type": "button", "action_id": ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep").routing_uid(), - "value": json.dumps( - { - "resolution_note_window_action": "edit", - "alert_group_pk": self.alert_group.pk, - "organization_id": self.alert_group.channel.organization_id, - } - ), + "value": self._alert_group_action_value(resolution_note_window_action="edit"), } if resolution_notes_count == 0: resolution_notes_button["style"] = "primary" @@ -322,7 +308,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): "text": {"type": "plain_text", "text": "Resolve", "emoji": True}, "type": "button", "style": "primary", - "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), + "value": self._alert_group_action_value(), "action_id": ScenarioStep.get_step("distribute_alerts", "ResolveGroupStep").routing_uid(), }, ) @@ -339,13 +325,11 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): invitee_name = invitation.invitee.get_username_with_slack_verbal() buttons.append( { - "name": "{}_{}".format( - ScenarioStep.get_step("distribute_alerts", "StopInvitationProcess").routing_uid(), invitation.pk - ), + "name": ScenarioStep.get_step("distribute_alerts", "StopInvitationProcess").routing_uid(), "text": "Stop inviting {}".format(invitee_name), "type": "button", "style": "primary", - "value": json.dumps({"organization_id": self.alert_group.channel.organization_id}), + "value": self._alert_group_action_value(invitation_id=invitation.pk), }, ) return [ @@ -359,6 +343,13 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): def _get_select_user_element( self, action_id, multi_select=False, initial_user=None, initial_users_list=None, text=None ): + def get_action_value(user_id): + """ + In contrast to other buttons and select menus, self._alert_group_action_value is not used here. + It's because there could be a lot of users, and we don't want to increase the payload size too much. + """ + return json.dumps({"user_id": user_id}) + MAX_STATIC_SELECT_OPTIONS = 100 if not text: @@ -382,7 +373,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): user_verbal = f"{user.get_username_with_slack_verbal()}" if len(user_verbal) > 75: user_verbal = user_verbal[:72] + "..." - option = {"text": {"type": "plain_text", "text": user_verbal}, "value": json.dumps({"user_id": user.pk})} + option = {"text": {"type": "plain_text", "text": user_verbal}, "value": get_action_value(user.pk)} options.append(option) if users_count > MAX_STATIC_SELECT_OPTIONS: @@ -397,7 +388,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): elif users_count == 0: # strange case when there are no users to select option = { "text": {"type": "plain_text", "text": "No users to select"}, - "value": json.dumps({"user_id": None}), + "value": get_action_value(None), } options.append(option) element["options"] = options @@ -413,7 +404,7 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): user_verbal = f"{user.get_username_with_slack_verbal()}" option = { "text": {"type": "plain_text", "text": user_verbal}, - "value": json.dumps({"user_id": user.pk}), + "value": get_action_value(user.pk), } initial_options.append(option) element["initial_options"] = initial_options @@ -421,8 +412,22 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): user_verbal = f"{initial_user.get_username_with_slack_verbal()}" initial_option = { "text": {"type": "plain_text", "text": user_verbal}, - "value": json.dumps({"user_id": initial_user.pk}), + "value": get_action_value(initial_user.pk), } element["initial_option"] = initial_option return element + + def _alert_group_action_value(self, **kwargs): + """ + Store organization and alert group IDs in Slack button or select menu values. + alert_group_pk is used in apps.slack.scenarios.step_mixins.AlertGroupActionsMixin to get the right alert group + when handling AG actions in Slack. + """ + + data = { + "organization_id": self.alert_group.channel.organization_id, + "alert_group_pk": self.alert_group.pk, + **kwargs, + } + return json.dumps(data) # Slack block elements allow to pass value as string only (max 2000 chars) diff --git a/engine/apps/slack/models/slack_message.py b/engine/apps/slack/models/slack_message.py index 2d576378..ee482f3e 100644 --- a/engine/apps/slack/models/slack_message.py +++ b/engine/apps/slack/models/slack_message.py @@ -80,7 +80,8 @@ class SlackMessage(models.Model): self.alert_group.slack_message = self self.alert_group.save(update_fields=["slack_message"]) return self.alert_group - return self.alert.group + else: + raise @property def permalink(self): @@ -217,32 +218,3 @@ class SlackMessage(models.Model): pass else: raise e - - @classmethod - def get_alert_group_from_slack_message_payload(cls, slack_team_identity, payload): - - message_ts = payload.get("message_ts") or payload["container"]["message_ts"] # interactive message or block - channel_id = payload["channel"]["id"] - - try: - slack_message = cls.objects.get( - slack_id=message_ts, - _slack_team_identity=slack_team_identity, - channel_id=channel_id, - ) - alert_group = slack_message.get_alert_group() - except cls.DoesNotExist as e: - logger.error( - f"Tried to get SlackMessage from message_ts:" - f"slack_team_identity_id={slack_team_identity.pk}," - f"message_ts={message_ts}" - ) - raise e - except cls.alert.RelatedObjectDoesNotExist as e: - logger.error( - f"Tried to get AlertGroup from SlackMessage:" - f"slack_team_identity_id={slack_team_identity.pk}," - f"message_ts={message_ts}" - ) - raise e - return alert_group diff --git a/engine/apps/slack/scenarios/alertgroup_appearance.py b/engine/apps/slack/scenarios/alertgroup_appearance.py index 649f006f..dc1ea8a2 100644 --- a/engine/apps/slack/scenarios/alertgroup_appearance.py +++ b/engine/apps/slack/scenarios/alertgroup_appearance.py @@ -5,38 +5,25 @@ from django.apps import apps from apps.api.permissions import RBACPermission from apps.slack.scenarios import scenario_step -from .step_mixins import CheckAlertIsUnarchivedMixin, IncidentActionsAccessControlMixin +from .step_mixins import AlertGroupActionsMixin, CheckAlertIsUnarchivedMixin -class OpenAlertAppearanceDialogStep( - CheckAlertIsUnarchivedMixin, IncidentActionsAccessControlMixin, scenario_step.ScenarioStep -): +class OpenAlertAppearanceDialogStep(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "open Alert Appearance" def process_scenario(self, slack_user_identity, slack_team_identity, payload): - AlertGroup = apps.get_model("alerts", "AlertGroup") + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return - try: - message_ts = payload["message_ts"] - except KeyError: - message_ts = payload["container"]["message_ts"] - - try: - alert_group_pk = payload["actions"][0]["action_id"].split("_")[1] - except (KeyError, IndexError): - value = json.loads(payload["actions"][0]["value"]) - alert_group_pk = value["alert_group_pk"] - - alert_group = AlertGroup.all_objects.get(pk=alert_group_pk) if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): return - blocks = [] private_metadata = { "organization_id": self.organization.pk if self.organization else alert_group.organization.pk, - "alert_group_pk": alert_group_pk, - "message_ts": message_ts, + "alert_group_pk": alert_group.pk, + "message_ts": payload.get("message_ts") or payload["container"]["message_ts"], } alert_receive_channel = alert_group.channel diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 7b67a991..5ccb2250 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -35,7 +35,7 @@ from apps.slack.tasks import ( from apps.slack.utils import get_cache_key_update_incident_slack_message from common.utils import clean_markup, is_string_with_visible_characters -from .step_mixins import CheckAlertIsUnarchivedMixin, IncidentActionsAccessControlMixin +from .step_mixins import AlertGroupActionsMixin, CheckAlertIsUnarchivedMixin ATTACH_TO_ALERT_GROUPS_LIMIT = 20 @@ -218,17 +218,20 @@ class AlertShootingStep(scenario_step.ScenarioStep): class InviteOtherPersonToIncident( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "invite to incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload): User = apps.get_model("user_management", "User") - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + selected_user = None if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): @@ -255,21 +258,24 @@ class InviteOtherPersonToIncident( class SilenceGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "silence incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload): + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + value = payload["actions"][0]["selected_option"]["value"] try: - silence_delay = int(payload["actions"][0]["selected_options"][0]["value"]) - except KeyError: - silence_delay = int(payload["actions"][0]["selected_option"]["value"]) - - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) + silence_delay = json.loads(value)["delay"] + except TypeError: + # Deprecated handler kept for backward compatibility (so older Slack messages can still be processed) + silence_delay = int(value) if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): alert_group.silence_by_user(self.user, silence_delay, action_source=ActionSource.SLACK) @@ -281,15 +287,17 @@ class SilenceGroupStep( class UnSilenceGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "unsilence incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload): + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): alert_group.un_silence_by_user(self.user, action_source=ActionSource.SLACK) @@ -300,20 +308,20 @@ class UnSilenceGroupStep( class SelectAttachGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "Select Alert Group for Attaching to" def process_scenario(self, slack_user_identity, slack_team_identity, payload): - AlertGroup = apps.get_model("alerts", "AlertGroup") - value = json.loads(payload["actions"][0]["value"]) - alert_group_pk = value.get("alert_group_pk") - alert_group = AlertGroup.all_objects.get(pk=alert_group_pk) + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): return + blocks = [] view = { "callback_id": AttachGroupStep.routing_uid(), @@ -326,7 +334,7 @@ class SelectAttachGroupStep( "private_metadata": json.dumps( { "organization_id": self.organization.pk if self.organization else alert_group.organization.pk, - "alert_group_pk": alert_group_pk, + "alert_group_pk": alert_group.pk, } ), "close": {"type": "plain_text", "text": "Cancel", "emoji": True}, @@ -335,8 +343,8 @@ class SelectAttachGroupStep( if attached_incidents_exists: attached_incidents = alert_group.dependent_alert_groups.all() text = ( - f"Oops! This incident cannot be attached to another one because it already has " - f"attached incidents ({attached_incidents.count()}):\n" + f"Oops! This Alert Group cannot be attached to another one because it already has " + f"attached Alert Group ({attached_incidents.count()}):\n" ) for dependent_alert in attached_incidents: if dependent_alert.slack_permalink: @@ -372,7 +380,7 @@ class SelectAttachGroupStep( "type": "section", "text": { "type": "mrkdwn", - "text": "Oops! There is no incidents, available to attach.", + "text": "Oops! There are no Alert Groups available to attach.", }, } ) @@ -441,7 +449,7 @@ class SelectAttachGroupStep( }, "label": { "type": "plain_text", - "text": "Select incident:", + "text": "Select Alert Group:", "emoji": True, }, } @@ -451,11 +459,10 @@ class SelectAttachGroupStep( class AttachGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "Attach incident" + REQUIRED_PERMISSIONS = [] # Permissions are handled in SelectAttachGroupStep def process_signal(self, log_record): alert_group = log_record.alert_group @@ -497,8 +504,7 @@ class AttachGroupStep( root_alert_group_pk = int(payload["actions"][0]["selected_option"]["value"]) root_alert_group = AlertGroup.all_objects.get(pk=root_alert_group_pk) - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) - + alert_group = self.get_alert_group(slack_team_identity, payload) if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group) and self.check_alert_is_unarchived( slack_team_identity, payload, root_alert_group ): @@ -509,14 +515,17 @@ class AttachGroupStep( class UnAttachGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "Unattach incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload): - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): alert_group.un_attach_by_user(self.user, action_source=ActionSource.SLACK) @@ -525,17 +534,26 @@ class UnAttachGroupStep( self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -class StopInvitationProcess(CheckAlertIsUnarchivedMixin, IncidentActionsAccessControlMixin, scenario_step.ScenarioStep): +class StopInvitationProcess(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "stop invitation" def process_scenario(self, slack_user_identity, slack_team_identity, payload): - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): return - invitation_pk = payload["actions"][0]["name"].split("_")[1] - Invitation.stop_invitation(invitation_pk, self.user) + try: + value = json.loads(payload["actions"][0]["value"]) + invitation_id = value["invitation_id"] + except KeyError: + # Deprecated handler kept for backward compatibility (so older Slack messages can still be processed) + invitation_id = payload["actions"][0]["name"].split("_")[1] + + Invitation.stop_invitation(invitation_id, self.user) def process_signal(self, log_record): self.alert_group_slack_service.update_alert_group_slack_message(log_record.invitation.alert_group) @@ -543,16 +561,18 @@ class StopInvitationProcess(CheckAlertIsUnarchivedMixin, IncidentActionsAccessCo class CustomButtonProcessStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): - # TODO: REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "click custom button" def process_scenario(self, slack_user_identity, slack_team_identity, payload): CustomButtom = apps.get_model("alerts", "CustomButton") - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): custom_button_pk = payload["actions"][0]["name"].split("_")[1] alert_group_pk = payload["actions"][0]["name"].split("_")[2] @@ -603,16 +623,18 @@ class CustomButtonProcessStep( class ResolveGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "resolve incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload): ResolutionNoteModalStep = scenario_step.ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep") - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): return @@ -645,14 +667,17 @@ class ResolveGroupStep( class UnResolveGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "unresolve incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload): - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): alert_group.un_resolve_by_user(self.user, action_source=ActionSource.SLACK) @@ -663,36 +688,38 @@ class UnResolveGroupStep( class AcknowledgeGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "acknowledge incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload): - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) - logger.debug(f"process_scenario in AcknowledgeGroupStep for alert_group {alert_group.pk}") + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): alert_group.acknowledge_by_user(self.user, action_source=ActionSource.SLACK) def process_signal(self, log_record): alert_group = log_record.alert_group - logger.debug(f"Started process_signal in AcknowledgeGroupStep for alert_group {alert_group.pk}") self.alert_group_slack_service.update_alert_group_slack_message(alert_group) - logger.debug(f"Finished process_signal in AcknowledgeGroupStep for alert_group {alert_group.pk}") class UnAcknowledgeGroupStep( CheckAlertIsUnarchivedMixin, - IncidentActionsAccessControlMixin, + AlertGroupActionsMixin, scenario_step.ScenarioStep, ): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] - ACTION_VERBOSE = "unacknowledge incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload): - alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload) - logger.debug(f"process_scenario in UnAcknowledgeGroupStep for alert_group {alert_group.pk}") + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): alert_group.un_acknowledge_by_user(self.user, action_source=ActionSource.SLACK) @@ -717,7 +744,7 @@ class UnAcknowledgeGroupStep( ] text = ( f"{user_verbal} hasn't responded to an acknowledge timeout reminder." - f" Alert Group is unacknowledged automatically" + f" Alert Group is unacknowledged automatically." ) if alert_group.slack_message.ack_reminder_message_ts: try: @@ -749,8 +776,6 @@ class UnAcknowledgeGroupStep( class AcknowledgeConfirmationStep(AcknowledgeGroupStep): - ACTION_VERBOSE = "confirm acknowledge status" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): AlertGroup = apps.get_model("alerts", "AlertGroup") alert_group_id = payload["actions"][0]["value"].split("_")[1] @@ -762,7 +787,7 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep): if alert_group.acknowledged_by == AlertGroup.USER: if self.user == alert_group.acknowledged_by_user: user_verbal = alert_group.acknowledged_by_user.get_username_with_slack_verbal() - text = f"{user_verbal} confirmed that the incident is still acknowledged" + text = f"{user_verbal} confirmed that the Alert Group is still acknowledged." self._slack_client.api_call( "chat.update", channel=channel, @@ -776,11 +801,11 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep): "chat.postEphemeral", channel=channel, user=slack_user_identity.slack_id, - text="This alert is acknowledged by another user. Acknowledge it yourself first.", + text="This Alert Group is acknowledged by another user. Acknowledge it yourself first.", ) elif alert_group.acknowledged_by == AlertGroup.SOURCE: user_verbal = self.user.get_username_with_slack_verbal() - text = f"{user_verbal} confirmed that the incident is still acknowledged" + text = f"{user_verbal} confirmed that the Alert Group is still acknowledged." self._slack_client.api_call( "chat.update", channel=channel, @@ -799,7 +824,7 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep): "chat.postEphemeral", channel=channel, user=slack_user_identity.slack_id, - text="This alert is already unacknowledged.", + text="This Alert Group is already unacknowledged.", ) def process_signal(self, log_record): @@ -809,12 +834,12 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep): alert_group = log_record.alert_group channel_id = alert_group.slack_message.channel_id user_verbal = log_record.author.get_username_with_slack_verbal(mention=True) - text = f"{user_verbal}, please confirm that you're still working on this incident." + text = f"{user_verbal}, please confirm that you're still working on this Alert Group." if alert_group.channel.organization.unacknowledge_timeout != Organization.UNACKNOWLEDGE_TIMEOUT_NEVER: attachments = [ { - "fallback": "Are you still working on this incident?", + "fallback": "Are you still working on this Alert Group?", "text": text, "callback_id": "alert", "attachment_type": "default", @@ -883,8 +908,6 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep): class WipeGroupStep(scenario_step.ScenarioStep): - ACTION_VERBOSE = "wipe incident" - def process_signal(self, log_record): alert_group = log_record.alert_group user_verbal = log_record.author.get_username_with_slack_verbal() @@ -894,8 +917,6 @@ class WipeGroupStep(scenario_step.ScenarioStep): class DeleteGroupStep(scenario_step.ScenarioStep): - ACTION_VERBOSE = "delete incident" - def process_signal(self, log_record): alert_group = log_record.alert_group diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index e7316d40..e3c327eb 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -5,12 +5,13 @@ from django.apps import apps from django.db.models import Q from django.utils import timezone +from apps.api.permissions import RBACPermission from apps.slack.scenarios import scenario_step from apps.slack.slack_client.exceptions import SlackAPIException from apps.user_management.models import User from common.api_helpers.utils import create_engine_url -from .step_mixins import CheckAlertIsUnarchivedMixin +from .step_mixins import AlertGroupActionsMixin, CheckAlertIsUnarchivedMixin logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -372,18 +373,28 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): return blocks -class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, scenario_step.ScenarioStep): +class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixin, scenario_step.ScenarioStep): + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] RESOLUTION_NOTE_TEXT_BLOCK_ID = "resolution_note_text" RESOLUTION_NOTE_MESSAGES_MAX_COUNT = 25 def process_scenario(self, slack_user_identity, slack_team_identity, payload, data=None): - AlertGroup = apps.get_model("alerts", "AlertGroup") + if data: + # Argument "data" is used when step is called from other step, e.g. AddRemoveThreadMessageStep + AlertGroup = apps.get_model("alerts", "AlertGroup") + alert_group = AlertGroup.all_objects.get(pk=data["alert_group_pk"]) + else: + # Handle "Add Resolution notes" button click + alert_group = self.get_alert_group(slack_team_identity, payload) + + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + value = data or json.loads(payload["actions"][0]["value"]) resolution_note_window_action = value.get("resolution_note_window_action", "") or value.get("action_value", "") - alert_group_pk = value.get("alert_group_pk") action_resolve = value.get("action_resolve", False) channel_id = payload["channel"]["id"] if "channel" in payload else None - alert_group = AlertGroup.all_objects.get(pk=alert_group_pk) if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): return @@ -413,7 +424,7 @@ class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari "private_metadata": json.dumps( { "organization_id": self.organization.pk if self.organization else alert_group.organization.pk, - "alert_group_pk": alert_group_pk, + "alert_group_pk": alert_group.pk, } ), } @@ -431,7 +442,7 @@ class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari # Ignore "not_found" error, it means that the view was closed by user before the update request. # It doesn't disrupt the user experience. logger.debug( - f"API call to views.update failed for alert group {alert_group_pk}, error: not_found. " + f"API call to views.update failed for alert group {alert_group.pk}, error: not_found. " f"Most likely the view was closed by user before the request was processed. " ) else: diff --git a/engine/apps/slack/scenarios/step_mixins.py b/engine/apps/slack/scenarios/step_mixins.py index 199cde11..cf677001 100644 --- a/engine/apps/slack/scenarios/step_mixins.py +++ b/engine/apps/slack/scenarios/step_mixins.py @@ -1,72 +1,164 @@ +import json import logging -from abc import ABC, abstractmethod +from apps.alerts.models import AlertGroup from apps.api.permissions import user_is_authorized +from apps.slack.models import SlackMessage, SlackTeamIdentity logger = logging.getLogger(__name__) -class AccessControl(ABC): +class AlertGroupActionsMixin: + """ + Mixin for alert group actions (ack, resolve, etc.). Intended to be used as a mixin along with ScenarioStep. + """ + REQUIRED_PERMISSIONS = [] - ACTION_VERBOSE = "" - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - if self.check_membership(): - return super().process_scenario(slack_user_identity, slack_team_identity, payload) - else: - self.send_denied_message(payload) + def get_alert_group(self, slack_team_identity: SlackTeamIdentity, payload: dict) -> AlertGroup: + """ + Get AlertGroup instance on Slack message button click or select menu change. + """ - def check_membership(self): - return user_is_authorized(self.user, self.REQUIRED_PERMISSIONS) + alert_group = ( + self._get_alert_group_from_action(payload) # Try to get alert_group_pk from PRESSED button + or self._get_alert_group_from_message(payload) # Try to use alert_group_pk from ANY button in message + or self._get_alert_group_from_slack_message_in_db(slack_team_identity, payload) # Fetch message from DB + ) - @abstractmethod - def send_denied_message(self, payload): - pass + # Repair alert group if Slack message is orphaned + if alert_group.slack_message is None: + self._repair_alert_group(slack_team_identity, alert_group, payload) + return alert_group -class IncidentActionsAccessControlMixin(AccessControl): - """ - Mixin for auth in incident actions - """ + def is_authorized(self, alert_group: AlertGroup) -> bool: + """ + Check that user has required permissions to perform an action. + """ - def send_denied_message_to_channel(self, payload=None): - # Send denied message to thread by default - return False + return ( + self.user is not None + and self.user.organization == alert_group.channel.organization + and user_is_authorized(self.user, self.REQUIRED_PERMISSIONS) + ) - def send_denied_message(self, payload): + def open_unauthorized_warning(self, payload: dict) -> None: + self.open_warning_window( + payload, + warning_text="You do not have permission to perform this action. Ask an admin to upgrade your permissions.", + title="Permission denied", + ) + + def _repair_alert_group( + self, slack_team_identity: SlackTeamIdentity, alert_group: AlertGroup, payload: dict + ) -> None: + """ + There's a possibility that OnCall failed to create a SlackMessage instance for an AlertGroup, but the message + was sent to Slack. This method creates SlackMessage instance for such orphaned messages. + """ + + channel_id = payload["channel"]["id"] try: - thread_ts = payload["message_ts"] + message_id = payload["message"]["ts"] except KeyError: - thread_ts = payload["message"]["ts"] + message_id = payload["original_message"]["ts"] - text = "Attempted to {} by {}, but failed due to a lack of permissions.".format( - self.ACTION_VERBOSE, - self.user.get_username_with_slack_verbal(), + slack_message = SlackMessage.objects.create( + slack_id=message_id, + organization=alert_group.channel.organization, + _slack_team_identity=slack_team_identity, + channel_id=channel_id, + alert_group=alert_group, ) - self._slack_client.api_call( - "chat.postMessage", - channel=payload["channel"]["id"], - text=text, - blocks=[ - { - "type": "section", - "block_id": "alert", - "text": { - "type": "mrkdwn", - "text": text, - }, - }, - ], - thread_ts=None if self.send_denied_message_to_channel(payload) else thread_ts, - unfurl_links=True, + alert_group.slack_message = slack_message + alert_group.save(update_fields=["slack_message"]) + + def _get_alert_group_from_action(self, payload: dict) -> AlertGroup | None: + """ + Get AlertGroup instance from action data in payload. Action data is data encoded into buttons and select + menus in apps.alerts.incident_appearance.renderers.slack_renderer.AlertGroupSlackRenderer._get_buttons_blocks. + """ + + action = payload["actions"][0] + action_type = action["type"] + + if action_type == "button": + value_string = action["value"] + elif action_type == "static_select": + value_string = action["selected_option"]["value"] + else: + raise ValueError(f"Unexpected action type: {action_type}") + + try: + value = json.loads(value_string) + except (TypeError, json.JSONDecodeError): + return None + + try: + alert_group_pk = value["alert_group_pk"] + except (KeyError, TypeError): + return None + + return AlertGroup.all_objects.get(pk=alert_group_pk) + + def _get_alert_group_from_message(self, payload: dict) -> AlertGroup | None: + """ + Get AlertGroup instance from message data in payload. It's similar to _get_alert_group_from_action, + but it tries to get alert_group_pk from ANY button in the message, not just the one that was clicked. + """ + + try: + # sometimes message is in "original_message" field, not "message" + message = payload.get("message") or payload["original_message"] + elements = message["attachments"][0]["blocks"][0]["elements"] + except (KeyError, IndexError): + return None + + for element in elements: + value_string = element.get("value") + if not value_string: + continue + + try: + value = json.loads(value_string) + except (TypeError, json.JSONDecodeError): + continue + + try: + alert_group_pk = value["alert_group_pk"] + except (KeyError, TypeError): + continue + + return AlertGroup.all_objects.get(pk=alert_group_pk) + + def _get_alert_group_from_slack_message_in_db( + self, slack_team_identity: SlackTeamIdentity, payload: dict + ) -> AlertGroup: + """ + Get AlertGroup instance from SlackMessage instance. + Old messages may not have alert_group_pk encoded into buttons, so we need to query SlackMessage to figure out + the AlertGroup. + """ + + message_ts = payload.get("message_ts") or payload["container"]["message_ts"] # interactive message or block + channel_id = payload["channel"]["id"] + + # All Slack messages from OnCall should have alert_group_pk encoded into buttons, so reaching this point means + # something probably went wrong. + logger.warning(f"alert_group_pk not found in payload, fetching SlackMessage from DB. message_ts: {message_ts}") + + # Get SlackMessage from DB + slack_message = SlackMessage.objects.get( + slack_id=message_ts, + _slack_team_identity=slack_team_identity, + channel_id=channel_id, ) + return slack_message.get_alert_group() -class CheckAlertIsUnarchivedMixin(object): - REQUIRED_PERMISSIONS = [] - ACTION_VERBOSE = "" - +class CheckAlertIsUnarchivedMixin: def check_alert_is_unarchived(self, slack_team_identity, payload, alert_group, warning=True): alert_group_is_unarchived = alert_group.started_at.date() > self.organization.archive_alerts_from if not alert_group_is_unarchived: diff --git a/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py b/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py new file mode 100644 index 00000000..4d01d37d --- /dev/null +++ b/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py @@ -0,0 +1,852 @@ +import json +from unittest.mock import patch + +import pytest + +from apps.api.permissions import LegacyAccessControlRole +from apps.slack.scenarios.scenario_step import ScenarioStep +from apps.slack.scenarios.step_mixins import AlertGroupActionsMixin + + +class TestScenario(AlertGroupActionsMixin, ScenarioStep): + pass + + +# List of steps to be tested for alert group actions (getting alert group from Slack payload + user permissions check) +ALERT_GROUP_ACTIONS_STEPS = [ + # Acknowledge / Unacknowledge buttons + ScenarioStep.get_step("distribute_alerts", "AcknowledgeGroupStep"), + ScenarioStep.get_step("distribute_alerts", "UnAcknowledgeGroupStep"), + # Resolve / Unresolve buttons + ScenarioStep.get_step("distribute_alerts", "ResolveGroupStep"), + ScenarioStep.get_step("distribute_alerts", "UnResolveGroupStep"), + # Invite / Stop inviting buttons + ScenarioStep.get_step("distribute_alerts", "InviteOtherPersonToIncident"), + ScenarioStep.get_step("distribute_alerts", "StopInvitationProcess"), + # Silence / Unsilence buttons + ScenarioStep.get_step("distribute_alerts", "SilenceGroupStep"), + ScenarioStep.get_step("distribute_alerts", "UnSilenceGroupStep"), + # Attach / Unattach buttons + ScenarioStep.get_step("distribute_alerts", "SelectAttachGroupStep"), + ScenarioStep.get_step("distribute_alerts", "UnAttachGroupStep"), + # Format alert button + ScenarioStep.get_step("alertgroup_appearance", "OpenAlertAppearanceDialogStep"), + # Add resolution notes button + ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep"), +] + + +# Constants to simplify parametrized tests +ORGANIZATION_ID = 42 +ALERT_GROUP_ID = 24 +SLACK_MESSAGE_TS = "RANDOM_MESSAGE_TS" +SLACK_CHANNEL_ID = "RANDOM_CHANNEL_ID" +USER_ID = 56 +INVITATION_ID = 78 + + +def _get_payload(action_type="button", **kwargs): + """ + Utility function to generate payload to be used by scenario steps. + """ + if action_type == "button": + return { + "actions": [ + { + "type": "button", + "value": json.dumps( + {"organization_id": ORGANIZATION_ID, "alert_group_pk": ALERT_GROUP_ID, **kwargs} + ), + } + ], + } + elif action_type == "static_select": + return { + "actions": [ + { + "type": "static_select", + "selected_option": { + "value": json.dumps( + {"organization_id": ORGANIZATION_ID, "alert_group_pk": ALERT_GROUP_ID, **kwargs} + ) + }, + } + ], + } + + +@pytest.mark.parametrize("step_class", ALERT_GROUP_ACTIONS_STEPS) +@pytest.mark.django_db +def test_alert_group_actions_unauthorized( + step_class, make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group +): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities( + role=LegacyAccessControlRole.VIEWER + ) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + payload = { + "actions": [ + { + "type": "button", + "value": json.dumps({"organization_id": organization.pk, "alert_group_pk": alert_group.pk}), + } + ], + "channel": {"id": "RANDOM_CHANNEL_ID"}, + "message": {"ts": "RANDOM_MESSAGE_TS"}, + "trigger_id": "RANDOM_TRIGGER_ID", + } + + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + + with patch.object(step, "open_unauthorized_warning") as mock_open_unauthorized_warning: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + mock_open_unauthorized_warning.assert_called_once() + + +@pytest.mark.django_db +def test_get_alert_group_button( + make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group +): + organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + payload = { + "actions": [ + { + "type": "button", + "value": json.dumps({"organization_id": organization.pk, "alert_group_pk": alert_group.pk}), + } + ], + "channel": {"id": "RANDOM_CHANNEL_ID"}, + "message": {"ts": "RANDOM_MESSAGE_TS"}, + } + + step = TestScenario(organization=organization, user=user, slack_team_identity=slack_team_identity) + result = step.get_alert_group(slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group == result # check it's the right alert group + assert alert_group.slack_message is not None # check that orphaned Slack message is repaired + + +@pytest.mark.django_db +def test_get_alert_group_static_select( + make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group +): + organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + payload = { + "actions": [ + { + "type": "static_select", + "selected_option": { + "value": json.dumps({"organization_id": organization.pk, "alert_group_pk": alert_group.pk}) + }, + } + ], + "channel": {"id": "RANDOM_CHANNEL_ID"}, + "message": {"ts": "RANDOM_MESSAGE_TS"}, + } + + step = TestScenario(organization=organization, user=user, slack_team_identity=slack_team_identity) + result = step.get_alert_group(slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group == result # check it's the right alert group + assert alert_group.slack_message is not None # check that orphaned Slack message is repaired + + +@pytest.mark.django_db +def test_get_alert_group_from_message( + make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group +): + organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + payload = { + "actions": [ + { + "type": "button", + "value": "no alert_group_pk", + } + ], + "message": { + "ts": "RANDOM_MESSAGE_TS", + "attachments": [{"blocks": [{"elements": [{"value": json.dumps({"alert_group_pk": alert_group.pk})}]}]}], + }, + "channel": {"id": "RANDOM_CHANNEL_ID"}, + } + + step = TestScenario(organization=organization, user=user, slack_team_identity=slack_team_identity) + result = step.get_alert_group(slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group == result # check it's the right alert group + assert alert_group.slack_message is not None # check that orphaned Slack message is repaired + + +@pytest.mark.django_db +def test_get_alert_group_from_slack_message_in_db( + make_organization_and_user_with_slack_identities, + make_alert_receive_channel, + make_alert_group, + make_slack_channel, + make_slack_message, +): + organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + slack_channel = make_slack_channel(slack_team_identity) + slack_message = make_slack_message(alert_group=alert_group, channel_id=slack_channel.slack_id) + + payload = { + "message_ts": slack_message.slack_id, + "channel": {"id": slack_channel.slack_id}, + "actions": [{"type": "button", "value": "RANDOM_VALUE"}], + } + + step = TestScenario(organization=organization, user=user, slack_team_identity=slack_team_identity) + result = step.get_alert_group(slack_team_identity, payload) + + assert alert_group == result + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload(), + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "actions": [{"type": "button", "value": json.dumps({"organization_id": ORGANIZATION_ID})}], + }, + ], +) +@pytest.mark.django_db +def test_step_acknowledge( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, acknowledged=False, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "AcknowledgeGroupStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group.acknowledged is True + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload(), + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "actions": [{"type": "button", "value": json.dumps({"organization_id": ORGANIZATION_ID})}], + }, + ], +) +@pytest.mark.django_db +def test_step_unacknowledge( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, acknowledged=True, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "UnAcknowledgeGroupStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group.acknowledged is False + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload(), + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "actions": [{"type": "button", "value": json.dumps({"organization_id": ORGANIZATION_ID})}], + }, + ], +) +@pytest.mark.django_db +def test_step_resolve( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=False, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "ResolveGroupStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group.resolved is True + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload(), + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "actions": [{"type": "button", "value": json.dumps({"organization_id": ORGANIZATION_ID})}], + }, + ], +) +@pytest.mark.django_db +def test_step_unresolve( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "UnResolveGroupStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group.resolved is False + + +@pytest.mark.parametrize( + "payload", + [ + # Usual data such as alert_group_pk is not passed to InviteOtherPersonToIncident, so it doesn't increase + # payload size too much. + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "actions": [ + { + "type": "static_select", + "selected_option": {"value": json.dumps({"user_id": USER_ID})}, + } + ], + }, + ], +) +@pytest.mark.django_db +def test_step_invite( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + second_user = make_user(organization=organization, pk=USER_ID) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "InviteOtherPersonToIncident") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group.invitations.count() == 1 + + invitation = alert_group.invitations.first() + assert invitation.author == user + assert invitation.invitee == second_user + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload(invitation_id=INVITATION_ID), + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "actions": [ + { + "name": f"StopInvitationProcess_{INVITATION_ID}", + "type": "button", + "value": json.dumps({"organization_id": ORGANIZATION_ID}), + } + ], + }, + ], +) +@pytest.mark.django_db +def test_step_stop_invite( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, + make_invitation, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + second_user = make_user(organization=organization, pk=USER_ID) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + invitation = make_invitation(alert_group, user, second_user, pk=INVITATION_ID) + + step_class = ScenarioStep.get_step("distribute_alerts", "StopInvitationProcess") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + invitation.refresh_from_db() + assert invitation.is_active is False + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload(action_type="static_select", delay=1800), + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "actions": [ + { + "type": "static_select", + "selected_option": {"value": "1800"}, + } + ], + }, + ], +) +@pytest.mark.django_db +def test_step_silence( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, silenced=False, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "SilenceGroupStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group.silenced is True + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload(action_type="static_select", delay=1800), + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "actions": [ + { + "type": "button", + "value": json.dumps({"organization_id": ORGANIZATION_ID}), + } + ], + }, + ], +) +@pytest.mark.django_db +def test_step_unsilence( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, silenced=True, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "UnSilenceGroupStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group.silenced is False + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload() | {"trigger_id": "RANDOM_TRIGGER_ID"}, + ], +) +@pytest.mark.django_db +def test_step_select_attach( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "SelectAttachGroupStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + + with patch.object(step._slack_client, "api_call") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_slack_api_call.call_args.args == ("views.open",) + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload() | {"trigger_id": "RANDOM_TRIGGER_ID"}, + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "trigger_id": "RANDOM_TRIGGER_ID", + "actions": [ + { + "type": "button", + "value": json.dumps({"organization_id": ORGANIZATION_ID}), + } + ], + }, + ], +) +@pytest.mark.django_db +def test_step_unattach( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + root_alert_group = make_alert_group(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, root_alert_group=root_alert_group, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("distribute_alerts", "UnAttachGroupStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + alert_group.refresh_from_db() + assert alert_group.root_alert_group is None + + +@pytest.mark.parametrize( + "payload", + [ + _get_payload() | {"message_ts": "RANDOM_TS", "trigger_id": "RANDOM_TRIGGER_ID"}, + # deprecated payload shape, but still supported to handle older Slack messages + { + "message_ts": SLACK_MESSAGE_TS, + "channel": {"id": SLACK_CHANNEL_ID}, + "trigger_id": "RANDOM_TRIGGER_ID", + "actions": [ + { + "type": "button", + "value": json.dumps({"organization_id": ORGANIZATION_ID, "alert_group_pk": str(ALERT_GROUP_ID)}), + } + ], + }, + ], +) +@pytest.mark.django_db +def test_step_format_alert( + payload, + make_organization, + make_slack_team_identity, + make_user, + make_slack_user_identity, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + slack_team_identity = make_slack_team_identity() + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) + + organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + user = make_user(organization=organization, slack_user_identity=slack_user_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_message = make_slack_message( + alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=SLACK_MESSAGE_TS + ) + slack_message.get_alert_group() # fix FKs + + step_class = ScenarioStep.get_step("alertgroup_appearance", "OpenAlertAppearanceDialogStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + + with patch.object(step._slack_client, "api_call") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_slack_api_call.call_args.args == ("views.open",) + + +@pytest.mark.django_db +def test_step_resolution_note( + make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group, make_alert +): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group, raw_request_data={}) + + payload = { + "trigger_id": "RANDOM_TRIGGER_ID", + "actions": [ + { + "type": "button", + "value": json.dumps( + { + "organization_id": organization.pk, + "alert_group_pk": alert_group.pk, + "resolution_note_window_action": "edit", + } + ), + } + ], + "channel": {"id": "RANDOM_CHANNEL_ID"}, + "message": {"ts": "RANDOM_MESSAGE_TS"}, + } + + step_class = ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep") + step = step_class(organization=organization, user=user, slack_team_identity=slack_team_identity) + + with patch.object(step._slack_client, "api_call") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_slack_api_call.call_args.args == ("views.open",) diff --git a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py index fb1ac4f3..cc463b01 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py @@ -189,26 +189,43 @@ def test_get_resolution_notes_blocks_latest_limit( side_effect=SlackAPIException(response={"ok": False, "error": "not_found"}), ) def test_resolution_notes_modal_closed_before_update( - mock_slack_api_call, make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group + mock_slack_api_call, + make_organization_and_user_with_slack_identities, + make_alert_receive_channel, + make_alert_group, + make_slack_message, ): ResolutionNoteModalStep = ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep") - organization, _, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) + slack_message = make_slack_message( + alert_group=alert_group, channel_id="RANDOM_CHANNEL_ID", slack_id="RANDOM_MESSAGE_ID" + ) + slack_message.get_alert_group() # fix FKs payload = { "trigger_id": "TEST", "view": {"id": "TEST"}, "actions": [ - {"value": json.dumps({"alert_group_pk": alert_group.pk, "resolution_note_window_action": "update"})} + { + "type": "button", + "value": json.dumps( + { + "organization_id": organization.pk, + "alert_group_pk": alert_group.pk, + "resolution_note_window_action": "update", + } + ), + } ], } # Check that no error is raised even if the Slack API call fails - step = ResolutionNoteModalStep(organization=organization, slack_team_identity=slack_team_identity) + step = ResolutionNoteModalStep(organization=organization, user=user, slack_team_identity=slack_team_identity) step.process_scenario(slack_user_identity, slack_team_identity, payload) # Check that "views.update" API call was made diff --git a/engine/apps/slack/tests/test_slack_renderer.py b/engine/apps/slack/tests/test_slack_renderer.py new file mode 100644 index 00000000..9c2f15ef --- /dev/null +++ b/engine/apps/slack/tests/test_slack_renderer.py @@ -0,0 +1,213 @@ +import json + +import pytest + +from apps.alerts.incident_appearance.renderers.slack_renderer import AlertGroupSlackRenderer +from apps.alerts.models import AlertGroup + + +@pytest.mark.django_db +def test_slack_renderer_acknowledge_button(make_organization, make_alert_receive_channel, make_alert_group, make_alert): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + + button = elements[0] + assert button["text"]["text"] == "Acknowledge" + assert json.loads(button["value"]) == {"organization_id": organization.pk, "alert_group_pk": alert_group.pk} + + +@pytest.mark.django_db +def test_slack_renderer_unacknowledge_button( + make_organization, make_alert_receive_channel, make_alert_group, make_alert +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, acknowledged=True) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + + button = elements[0] + assert button["text"]["text"] == "Unacknowledge" + assert json.loads(button["value"]) == {"organization_id": organization.pk, "alert_group_pk": alert_group.pk} + + +@pytest.mark.django_db +def test_slack_renderer_resolve_button(make_organization, make_alert_receive_channel, make_alert_group, make_alert): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + + button = elements[1] + assert button["text"]["text"] == "Resolve" + assert json.loads(button["value"]) == {"organization_id": organization.pk, "alert_group_pk": alert_group.pk} + + +@pytest.mark.django_db +def test_slack_renderer_unresolve_button(make_organization, make_alert_receive_channel, make_alert_group, make_alert): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + + button = elements[0] + assert button["text"]["text"] == "Unresolve" + assert json.loads(button["value"]) == {"organization_id": organization.pk, "alert_group_pk": alert_group.pk} + + +@pytest.mark.django_db +def test_slack_renderer_invite_action( + make_organization, make_user, make_alert_receive_channel, make_alert_group, make_alert +): + organization = make_organization() + user = make_user(organization=organization) + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + + ack_button = elements[2] + assert ack_button["placeholder"]["text"] == "Invite..." + + # Check only user_id is passed. Otherwise, if there are a lot of users, the payload could be unnecessarily large. + assert json.loads(ack_button["options"][0]["value"]) == {"user_id": user.pk} + + +@pytest.mark.django_db +def test_slack_renderer_stop_invite_button( + make_organization, make_user, make_alert_receive_channel, make_alert_group, make_alert, make_invitation +): + organization = make_organization() + user = make_user(organization=organization) + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + invitation = make_invitation(alert_group, user, user) + + action = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[1]["actions"][0] + + assert action["text"] == f"Stop inviting {user.username}" + assert json.loads(action["value"]) == { + "organization_id": organization.pk, + "alert_group_pk": alert_group.pk, + "invitation_id": invitation.pk, + } + + +@pytest.mark.django_db +def test_slack_renderer_silence_button(make_organization, make_alert_receive_channel, make_alert_group, make_alert): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + + button = elements[3] + assert button["placeholder"]["text"] == "Silence" + + values = [json.loads(option["value"]) for option in button["options"]] + assert values == [ + {"organization_id": organization.pk, "alert_group_pk": alert_group.pk, "delay": delay} + for delay, _ in AlertGroup.SILENCE_DELAY_OPTIONS + ] + + +@pytest.mark.django_db +def test_slack_renderer_unsilence_button(make_organization, make_alert_receive_channel, make_alert_group, make_alert): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, silenced=True) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + button = elements[3] + + assert button["text"]["text"] == "Unsilence" + assert json.loads(button["value"]) == { + "organization_id": organization.pk, + "alert_group_pk": alert_group.pk, + } + + +@pytest.mark.django_db +def test_slack_renderer_attach_button(make_organization, make_alert_receive_channel, make_alert_group, make_alert): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, silenced=True) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + button = elements[4] + + assert button["text"]["text"] == "Attach to ..." + assert json.loads(button["value"]) == { + "organization_id": organization.pk, + "alert_group_pk": alert_group.pk, + } + + +@pytest.mark.django_db +def test_slack_renderer_unattach_button(make_organization, make_alert_receive_channel, make_alert_group, make_alert): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + + root_alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=root_alert_group, raw_request_data={}) + + alert_group = make_alert_group(alert_receive_channel, root_alert_group=root_alert_group) + make_alert(alert_group=alert_group, raw_request_data={}) + + action = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["actions"][0] + + assert action["text"] == "Unattach" + assert json.loads(action["value"]) == { + "organization_id": organization.pk, + "alert_group_pk": alert_group.pk, + } + + +@pytest.mark.django_db +def test_slack_renderer_format_alert_button( + make_organization, make_alert_receive_channel, make_alert_group, make_alert +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + + button = elements[5] + assert button["text"]["text"] == ":mag: Format Alert" + assert json.loads(button["value"]) == {"organization_id": organization.pk, "alert_group_pk": alert_group.pk} + + +@pytest.mark.django_db +def test_slack_renderer_resolution_notes_button( + make_organization, make_alert_receive_channel, make_alert_group, make_alert +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] + + button = elements[6] + assert button["text"]["text"] == "Add Resolution notes" + assert json.loads(button["value"]) == { + "organization_id": organization.pk, + "alert_group_pk": alert_group.pk, + "resolution_note_window_action": "edit", + }