From e66fe6717469455a71638dc881ea769cae50dd50 Mon Sep 17 00:00:00 2001 From: Alexander Cherepanov Date: Thu, 1 Jun 2023 16:08:39 +0600 Subject: [PATCH 1/7] Fix a bug #1617 with permissions for telegram user settings (#2075) # What this PR does Changes a required role for telegram user settings from Admin to Editor. ## Which issue(s) this PR fixes closes https://github.com/grafana/oncall/issues/1617 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 6 ++++++ .../UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d79034ed..0c773c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Fix a bug with permissions for telegram user settings by @alexintech ([#2075](https://github.com/grafana/oncall/pull/2075)) + ## v1.2.34 (2023-05-31) ### Added diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx index d90eb7a7..9eb68b46 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo.tsx @@ -40,7 +40,7 @@ const TelegramInfo = observer((_props: TelegramInfoProps) => { return ( {telegramConfigured || !store.hasFeature(AppFeature.LiveSettings) ? ( From d1373b58d2e8811edff9edfc457c3e3f28193462 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Thu, 1 Jun 2023 11:21:30 +0100 Subject: [PATCH 2/7] Fix orphaned messages in Slack (#2023) # What this PR does Reworks Slack handlers for buttons and select menus for AG Slack messages. Screenshot 2023-05-31 at 19 34 05 ## Current implementation - It's possible to end up with orphaned Slack messages that are posted to Slack but have no `SlackMessage` instance in the DB. For such messages, clicking buttons will result in an exception and HTTP 500. See private repo [issue](https://github.com/grafana/oncall-private/issues/1841) for more info. - Bug in authorization system, which effectively bypasses any permission checks. For example, it's possible to resolve an alert group while being a Viewer. - No tests covering most buttons. ## Changes in this PR - Make the system more robust, don't use `SlackMessage` model to figure out the alert group being interacted on, instead embed `alert_group_pk` to every button and use it when receiving interaction requests from Slack. - Existing orphaned Slack messages will be repaired. Clicking buttons under orphaned messages will work (and missing `SlackMessage` instance will be created on interaction). This is possible because some buttons already have `alert_group_pk` embedded, and it's possible to get this data on button clicks (even if the clicked button itself doesn't have `alert_group_pk` embedded). - Fix authorization. Show warning window when unauthorized: Screenshot 2023-05-31 at 19 40 02 - Added tests for all the buttons under AG message. Add tests checking authorization, actual execution of scenario steps, orphan message repairing, backward compatibility, etc. Also add tests on `AlertGroupSlackRenderer` checking that correct data is embedded into buttons. - Cosmetic changes such as renaming `incident` to `Alert Group`. ## Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/1841 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 1 + .../renderers/slack_renderer.py | 77 +- engine/apps/slack/models/slack_message.py | 32 +- .../slack/scenarios/alertgroup_appearance.py | 29 +- .../apps/slack/scenarios/distribute_alerts.py | 163 ++-- .../apps/slack/scenarios/resolution_note.py | 25 +- engine/apps/slack/scenarios/step_mixins.py | 184 +++- .../test_alert_group_actions.py | 852 ++++++++++++++++++ .../test_resolution_note.py | 25 +- .../apps/slack/tests/test_slack_renderer.py | 213 +++++ 10 files changed, 1386 insertions(+), 215 deletions(-) create mode 100644 engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py create mode 100644 engine/apps/slack/tests/test_slack_renderer.py 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", + } From f6184118492b98fe3a5ab22a44e33e3ac5d4c118 Mon Sep 17 00:00:00 2001 From: chrisharbro <102977229+chrisharbro@users.noreply.github.com> Date: Thu, 1 Jun 2023 07:21:07 -0500 Subject: [PATCH 3/7] Fix relative link to resolve 404 error (#2069) Link to the jinja2-templating page was not stepping backwards one level in the directory, therefore linking to a 404. --- docs/sources/get-started/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/get-started/_index.md b/docs/sources/get-started/_index.md index 89b71b38..d7ba8562 100644 --- a/docs/sources/get-started/_index.md +++ b/docs/sources/get-started/_index.md @@ -68,7 +68,7 @@ For more information on Grafana OnCall integrations and further configuration gu ### Learn Alert Flow -All Alerts in OnCall are grouped to Alert Groups ([read more about Grouping ID]({{< relref "jinja2-templating" >}})). Alert Group could have mutually +All Alerts in OnCall are grouped to Alert Groups ([read more about Grouping ID]({{< relref "../jinja2-templating" >}})). Alert Group could have mutually exclusive states: - **Firing:** Once Alert Group is registered, Escalation Policy associated with it is getting started. Escalation policy will work while Alert Group is in this status. From eee5065e7438587266bccd98113780ffc7ec0a1e Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 1 Jun 2023 09:31:33 -0300 Subject: [PATCH 4/7] Add initial setup for local dev prometheus exporter (#2039) --- Makefile | 1 + dev/.gitignore | 1 + dev/README.md | 8 ++++++++ dev/prometheus.yml | 8 ++++++++ docker-compose-developer.yml | 12 ++++++++++++ engine/engine/urls.py | 5 +++++ engine/settings/base.py | 1 + provisioning/datasources/automatic.yml | 11 +++++++++++ 8 files changed, 47 insertions(+) create mode 100644 dev/prometheus.yml create mode 100644 provisioning/datasources/automatic.yml diff --git a/Makefile b/Makefile index c92c50cd..40b2cafc 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ ENGINE_PROFILE = engine UI_PROFILE = oncall_ui REDIS_PROFILE = redis RABBITMQ_PROFILE = rabbitmq +PROMETHEUS_PROFILE = prometheus GRAFANA_PROFILE = grafana DEV_ENV_DIR = ./dev diff --git a/dev/.gitignore b/dev/.gitignore index 28202bb3..fe088061 100644 --- a/dev/.gitignore +++ b/dev/.gitignore @@ -7,4 +7,5 @@ !.env.postgres.dev !.env.sqlite.dev !add_env_var.sh +!prometheus.yml !README.md diff --git a/dev/README.md b/dev/README.md index 2a0eae94..82aa0567 100644 --- a/dev/README.md +++ b/dev/README.md @@ -68,6 +68,7 @@ make start COMPOSE_PROFILES=postgres,engine,grafana,rabbitmq The possible profiles values are: - `grafana` +- `prometheus` - `engine` - `oncall_ui` - `redis` @@ -138,6 +139,13 @@ license_text = (_Note_: you may need to restart your `grafana` container after modifying its configuration) +### Enabling OnCall prometheus exporter for local development + +Add `prometheus` to your `COMPOSE_PROFILES` and set `FEATURE_PROMETHEUS_EXPORTER_ENABLED=True` in your +`dev/.env.dev` file. You may need to restart your `grafana` container to make sure the new datasource +is added (or add it manually using the UI; Prometheus will be running in `host.docker.internal:9090` +by default, using default settings). + ### Django Silk Profiling In order to setup [`django-silk`](https://github.com/jazzband/django-silk) for local profiling, perform the following diff --git a/dev/prometheus.yml b/dev/prometheus.yml new file mode 100644 index 00000000..5d1148d4 --- /dev/null +++ b/dev/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["host.docker.internal:8080"] diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index 815b8813..080dd6fb 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -270,6 +270,17 @@ services: profiles: - postgres + prometheus: + container_name: prometheus + labels: *oncall-labels + image: prom/prometheus + volumes: + - ./dev/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + profiles: + - prometheus + grafana: container_name: grafana labels: *oncall-labels @@ -294,6 +305,7 @@ services: volumes: - grafanadata_dev:/var/lib/grafana - ./grafana-plugin:/var/lib/grafana/plugins/grafana-plugin + - ./provisioning:/etc/grafana/provisioning - ${GRAFANA_DEV_PROVISIONING:-/dev/null}:/etc/grafana/grafana.ini depends_on: postgres: diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 3cb8073a..7f288adb 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -44,6 +44,11 @@ urlpatterns = [ path("api/internal/v1/mobile_app/", include("apps.mobile_app.urls", namespace="mobile_app_tmp")), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +if settings.FEATURE_PROMETHEUS_EXPORTER_ENABLED: + urlpatterns += [ + path("metrics/", include("apps.metrics_exporter.urls")), + ] + if settings.FEATURE_SLACK_INTEGRATION_ENABLED: urlpatterns += [ path("api/internal/v1/slack/", include("apps.slack.urls")), diff --git a/engine/settings/base.py b/engine/settings/base.py index 60005f0a..ce6ec554 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -62,6 +62,7 @@ FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_EN FEATURE_WEB_SCHEDULES_ENABLED = getenv_boolean("FEATURE_WEB_SCHEDULES_ENABLED", default=False) FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", default=False) FEATURE_INBOUND_EMAIL_ENABLED = getenv_boolean("FEATURE_INBOUND_EMAIL_ENABLED", default=False) +FEATURE_PROMETHEUS_EXPORTER_ENABLED = getenv_boolean("FEATURE_PROMETHEUS_EXPORTER_ENABLED", default=False) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) diff --git a/provisioning/datasources/automatic.yml b/provisioning/datasources/automatic.yml new file mode 100644 index 00000000..48d1ef27 --- /dev/null +++ b/provisioning/datasources/automatic.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://host.docker.internal:9090 + jsonData: + httpMethod: POST + manageAlerts: true + prometheusType: Prometheus From 44b105343ab01dbe0dd19bb37749dc6ba8885135 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Thu, 1 Jun 2023 15:37:37 +0300 Subject: [PATCH 5/7] Better english (#1959) Not sure if this breaks anything. it probably breaks backwards compat. Co-authored-by: Joey Orlando --- engine/apps/api/tests/test_channel_filter.py | 6 +++--- engine/config_integrations/alertmanager.py | 2 +- engine/config_integrations/elastalert.py | 2 +- engine/config_integrations/formatted_webhook.py | 2 +- engine/config_integrations/grafana.py | 2 +- engine/config_integrations/grafana_alerting.py | 2 +- engine/config_integrations/kapacitor.py | 2 +- engine/config_integrations/webhook.py | 2 +- engine/config_integrations/zabbix.py | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/engine/apps/api/tests/test_channel_filter.py b/engine/apps/api/tests/test_channel_filter.py index d22b7040..8e9fb39e 100644 --- a/engine/apps/api/tests/test_channel_filter.py +++ b/engine/apps/api/tests/test_channel_filter.py @@ -527,9 +527,9 @@ def test_channel_filter_convert_from_regex_to_jinja2( make_channel_filter(alert_receive_channel, is_default=True) # r"..." used to keep this string as raw string - regex_filtering_term = r"\".*\": \"This alert was sent by user for the demonstration purposes\"" - final_filtering_term = r'{{ payload | json_dumps | regex_search("\".*\": \"This alert was sent by user for the demonstration purposes\"") }}' - payload = {"description": "This alert was sent by user for the demonstration purposes"} + regex_filtering_term = r"\".*\": \"This alert was sent by user for demonstration purposes\"" + final_filtering_term = r'{{ payload | json_dumps | regex_search("\".*\": \"This alert was sent by user for demonstration purposes\"") }}' + payload = {"description": "This alert was sent by user for demonstration purposes"} regex_channel_filter = make_channel_filter( alert_receive_channel, diff --git a/engine/config_integrations/alertmanager.py b/engine/config_integrations/alertmanager.py index 579174de..a7b47527 100644 --- a/engine/config_integrations/alertmanager.py +++ b/engine/config_integrations/alertmanager.py @@ -276,7 +276,7 @@ example_payload = { "labels": {"alertname": "TestAlert", "region": "eu-1", "severity": "critical"}, "annotations": { "message": "This is test alert", - "description": "This alert was sent by user for the demonstration purposes", + "description": "This alert was sent by user for demonstration purposes", "runbook_url": "https://grafana.com/", }, "startsAt": "2018-12-25T15:47:47.377363608Z", diff --git a/engine/config_integrations/elastalert.py b/engine/config_integrations/elastalert.py index bb5dc8db..2cd00b1a 100644 --- a/engine/config_integrations/elastalert.py +++ b/engine/config_integrations/elastalert.py @@ -57,4 +57,4 @@ resolve_condition = """\ acknowledge_condition = None -example_payload = {"message": "This alert was sent by user for the demonstration purposes"} +example_payload = {"message": "This alert was sent by user for demonstration purposes"} diff --git a/engine/config_integrations/formatted_webhook.py b/engine/config_integrations/formatted_webhook.py index b653e837..ad42578b 100644 --- a/engine/config_integrations/formatted_webhook.py +++ b/engine/config_integrations/formatted_webhook.py @@ -52,5 +52,5 @@ example_payload = { "image_url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/Grumpy_Cat_by_Gage_Skidmore.jpg", "state": "alerting", "link_to_upstream_details": "https://en.wikipedia.org/wiki/Downtime", - "message": "This alert was sent by user for the demonstration purposes\nSmth happened. Oh no!", + "message": "This alert was sent by user for demonstration purposes\nSmth happened. Oh no!", } diff --git a/engine/config_integrations/grafana.py b/engine/config_integrations/grafana.py index 1d90341d..5e6b6a81 100644 --- a/engine/config_integrations/grafana.py +++ b/engine/config_integrations/grafana.py @@ -224,7 +224,7 @@ example_payload = { "alertname": "TestAlert", "region": "eu-1", }, - "annotations": {"description": "This alert was sent by user for the demonstration purposes"}, + "annotations": {"description": "This alert was sent by user for demonstration purposes"}, "startsAt": "2018-12-25T15:47:47.377363608Z", "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", diff --git a/engine/config_integrations/grafana_alerting.py b/engine/config_integrations/grafana_alerting.py index 5b0deb0c..5427fc22 100644 --- a/engine/config_integrations/grafana_alerting.py +++ b/engine/config_integrations/grafana_alerting.py @@ -115,7 +115,7 @@ example_payload = { "alertname": "TestAlert", "region": "eu-1", }, - "annotations": {"description": "This alert was sent by user for the demonstration purposes"}, + "annotations": {"description": "This alert was sent by user for demonstration purposes"}, "startsAt": "2018-12-25T15:47:47.377363608Z", "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", diff --git a/engine/config_integrations/kapacitor.py b/engine/config_integrations/kapacitor.py index fd76c695..2e6f541b 100644 --- a/engine/config_integrations/kapacitor.py +++ b/engine/config_integrations/kapacitor.py @@ -54,6 +54,6 @@ acknowledge_condition = None example_payload = { "id": "TestAlert", - "message": "This alert was sent by user for the demonstration purposes", + "message": "This alert was sent by user for demonstration purposes", "data": "{foo: bar}", } diff --git a/engine/config_integrations/webhook.py b/engine/config_integrations/webhook.py index 0041c8c1..945cbb5f 100644 --- a/engine/config_integrations/webhook.py +++ b/engine/config_integrations/webhook.py @@ -57,4 +57,4 @@ resolve_condition = """\ {%- endif %}""" acknowledge_condition = None -example_payload = {"message": "This alert was sent by user for the demonstration purposes"} +example_payload = {"message": "This alert was sent by user for demonstration purposes"} diff --git a/engine/config_integrations/zabbix.py b/engine/config_integrations/zabbix.py index 9cce4030..87dbfae4 100644 --- a/engine/config_integrations/zabbix.py +++ b/engine/config_integrations/zabbix.py @@ -54,5 +54,5 @@ example_payload = { "image_url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/Grumpy_Cat_by_Gage_Skidmore.jpg", "state": "alerting", "link_to_upstream_details": "https://en.wikipedia.org/wiki/Downtime", - "message": "This alert was sent by user for the demonstration purposes\nSmth happened. Oh no!", + "message": "This alert was sent by user for demonstration purposes\nSmth happened. Oh no!", } From 5d383c7d1d8b350fb7f71fa28844a7309e5c0974 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 1 Jun 2023 13:27:14 -0300 Subject: [PATCH 6/7] Trigger slack shift notifications on current shift change (#2080) Before this change, a diff ical check (which happens with frequency with imported ical), particularly with overrides in an API/terraform schedule would trigger unexpected slack notifications because the prev vs current ical comparison will flag a diff, but when comparing current and previous shifts, `current_shifts` will have the shift in progress while the `prev_shifts` calculated from the overrides-only diff will most of the time be empty (unless you set/change an override at current time). Simplified the checks to always compare previous current shifts (ie. the ones in the schedule from the DB) vs the recalculated ones using the (refreshed) ical data from the schedule. --- CHANGELOG.md | 1 + .../tasks/notify_ical_schedule_shift.py | 70 +----- .../tests/test_notify_ical_schedule_shift.py | 203 +++++++++++++++++- 3 files changed, 210 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e74b72c0..8d095db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) +- Fix duplicated slack shift-changed notifications ([#2080](https://github.com/grafana/oncall/pull/2080)) ## v1.2.34 (2023-05-31) diff --git a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py index 93e11209..dac6838a 100644 --- a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py @@ -2,7 +2,6 @@ import datetime import json from copy import copy -import icalendar from django.apps import apps from django.utils import timezone @@ -12,7 +11,6 @@ from apps.schedules.ical_utils import ( event_start_end_all_day_with_respect_to_type, get_icalendar_tz_or_utc, get_usernames_from_ical_event, - is_icals_equal, memoized_users_in_ical, ) from apps.slack.scenarios import scenario_step @@ -191,8 +189,6 @@ def notify_ical_schedule_shift(schedule_pk): MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT = 3 - ical_changed = False - now = timezone.datetime.now(timezone.utc) # get list of iCalendars from current iCal files. If there is more than one calendar, primary calendar will always # be the first @@ -240,60 +236,15 @@ def notify_ical_schedule_shift(schedule_pk): for item in drop: current_shifts.pop(item) - is_prev_ical_diff = False - prev_overrides_priority = 0 - prev_shifts = {} - prev_users = {} + # compare events from prev and current shifts + prev_shifts = json.loads(schedule.current_shifts) if not schedule.empty_oncall else {} + # convert datetimes which was dumped to str back to datetime to calculate shift diff correct + str_format = "%Y-%m-%d %X%z" + for prev_shift in prev_shifts.values(): + prev_shift["start"] = datetime.datetime.strptime(prev_shift["start"], str_format) + prev_shift["end"] = datetime.datetime.strptime(prev_shift["end"], str_format) - # Get list of tuples with prev and current ical file for each calendar. If there is more than one calendar, primary - # calendar will be the first. - # example result for ical calendar: - # [(prev_ical_file_primary, current_ical_file_primary), (prev_ical_file_overrides, current_ical_file_overrides)] - # example result for calendar with custom events: - # [(prev_ical_file, current_ical_file)] - prev_and_current_ical_files = schedule.get_prev_and_current_ical_files() - - for prev_ical_file, current_ical_file in prev_and_current_ical_files: - if prev_ical_file and (not current_ical_file or not is_icals_equal(current_ical_file, prev_ical_file)): - task_logger.info(f"ical files are different") - # If icals are not equal then compare current_events from them - is_prev_ical_diff = True - prev_calendar = icalendar.Calendar.from_ical(prev_ical_file) - - prev_shifts_result, prev_users_result = get_current_shifts_from_ical( - prev_calendar, - schedule, - prev_overrides_priority, - ) - if prev_overrides_priority == 0 and prev_shifts_result: - prev_overrides_priority = max([prev_shifts_result[uid]["priority"] for uid in prev_shifts_result]) + 1 - - prev_shifts.update(prev_shifts_result) - prev_users.update(prev_users_result) - - recalculate_shifts_with_respect_to_priority(prev_shifts, prev_users) - - if is_prev_ical_diff: - # drop events that don't intersection with current time - drop = [] - for uid, prev_shift in prev_shifts.items(): - if not prev_shift["start"] < now < prev_shift["end"]: - drop.append(uid) - for item in drop: - prev_shifts.pop(item) - - shift_changed, diff_uids = calculate_shift_diff(current_shifts, prev_shifts) - - else: - # Else comparing events from prev and current shifts - prev_shifts = json.loads(schedule.current_shifts) if not schedule.empty_oncall else {} - # convert datetimes which was dumped to str back to datetime to calculate shift diff correct - str_format = "%Y-%m-%d %X%z" - for prev_shift in prev_shifts.values(): - prev_shift["start"] = datetime.datetime.strptime(prev_shift["start"], str_format) - prev_shift["end"] = datetime.datetime.strptime(prev_shift["end"], str_format) - - shift_changed, diff_uids = calculate_shift_diff(current_shifts, prev_shifts) + shift_changed, diff_uids = calculate_shift_diff(current_shifts, prev_shifts) if shift_changed: task_logger.info(f"shifts_changed: {diff_uids}") @@ -370,11 +321,6 @@ def notify_ical_schedule_shift(schedule_pk): if schedule.notify_oncall_shift_freq != OnCallSchedule.NotifyOnCallShiftFreq.NEVER: try: - if ical_changed: - slack_client.api_call( - "chat.postMessage", channel=schedule.channel, text=f"Schedule {schedule.name} was changed" - ) - slack_client.api_call( "chat.postMessage", channel=schedule.channel, diff --git a/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py b/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py index a2d8fa9b..a3009f49 100644 --- a/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py @@ -1,13 +1,16 @@ +import json +import textwrap from datetime import datetime from unittest.mock import Mock, patch +import icalendar import pytest import pytz from django.utils import timezone -from apps.alerts.tasks.notify_ical_schedule_shift import notify_ical_schedule_shift +from apps.alerts.tasks.notify_ical_schedule_shift import get_current_shifts_from_ical, notify_ical_schedule_shift from apps.schedules.ical_utils import memoized_users_in_ical -from apps.schedules.models import OnCallScheduleICal +from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal ICAL_DATA = """ BEGIN:VCALENDAR @@ -105,3 +108,199 @@ def test_next_shift_notification_long_shifts( notification = slack_blocks[0]["text"]["text"] assert "*New on-call shift:*\nuser2" in notification assert "*Next on-call shift:*\nuser1" in notification + + +@pytest.mark.django_db +def test_overrides_changes_no_current_no_triggering_notification( + make_organization_and_user_with_slack_identities, + make_user, + make_schedule, + make_on_call_shift, +): + organization, _, _, _ = make_organization_and_user_with_slack_identities() + user1 = make_user(organization=organization, username="user1") + # clear users pks <-> organization cache (persisting between tests) + memoized_users_in_ical.cache_clear() + + ical_before = textwrap.dedent( + """ + BEGIN:VCALENDAR + PRODID:-//Google Inc//Google Calendar 70.9054//EN + VERSION:2.0 + CALSCALE:GREGORIAN + METHOD:PUBLISH + BEGIN:VEVENT + DTSTART:20230101T020000 + DTEND:20230101T170000 + DTSTAMP:20230101T000000 + UID:id1@google.com + CREATED:20230101T000000 + DESCRIPTION: + LAST-MODIFIED:20230101T000000 + LOCATION: + SEQUENCE:1 + STATUS:CONFIRMED + SUMMARY:user1 + TRANSP:TRANSPARENT + END:VEVENT + END:VCALENDAR""" + ) + + # event outside current time is changed + ical_after = textwrap.dedent( + """ + BEGIN:VCALENDAR + PRODID:-//Google Inc//Google Calendar 70.9054//EN + VERSION:2.0 + CALSCALE:GREGORIAN + METHOD:PUBLISH + BEGIN:VEVENT + DTSTART:20230101T020000 + DTEND:20230101T210000 + DTSTAMP:20230101T000000 + UID:id1@google.com + CREATED:20230101T000000 + DESCRIPTION: + LAST-MODIFIED:20230101T000000 + LOCATION: + SEQUENCE:2 + STATUS:CONFIRMED + SUMMARY:user1 + TRANSP:TRANSPARENT + END:VEVENT + END:VCALENDAR""" + ) + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + name="test_schedule", + channel="channel", + prev_ical_file_overrides=ical_before, + cached_ical_file_overrides=ical_after, + ) + + now = timezone.now().replace(microsecond=0) + start_date = now - timezone.timedelta(days=7) + + data = { + "start": start_date, + "rotation_start": start_date, + "duration": timezone.timedelta(seconds=3600 * 24), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user1]]) + on_call_shift.schedules.add(schedule) + + # setup current shifts before checking/triggering for notifications + calendar = icalendar.Calendar.from_ical(schedule._ical_file_primary) + current_shifts, _ = get_current_shifts_from_ical(calendar, schedule, 0) + schedule.current_shifts = json.dumps(current_shifts, default=str) + schedule.empty_oncall = False + schedule.save() + + with patch("apps.slack.slack_client.SlackClientWithErrorHandling.api_call") as mock_slack_api_call: + notify_ical_schedule_shift(schedule.oncallschedule_ptr_id) + + assert not mock_slack_api_call.called + + +@pytest.mark.django_db +def test_no_changes_no_triggering_notification( + make_organization_and_user_with_slack_identities, + make_user, + make_schedule, + make_on_call_shift, +): + organization, _, _, _ = make_organization_and_user_with_slack_identities() + user1 = make_user(organization=organization, username="user1") + # clear users pks <-> organization cache (persisting between tests) + memoized_users_in_ical.cache_clear() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + name="test_schedule", + channel="channel", + prev_ical_file_overrides=None, + cached_ical_file_overrides=None, + ) + + now = timezone.now().replace(microsecond=0) + start_date = now - timezone.timedelta(days=7) + data = { + "start": start_date, + "rotation_start": start_date, + "duration": timezone.timedelta(seconds=3600 * 24), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user1]]) + on_call_shift.schedules.add(schedule) + + # setup current shifts before checking/triggering for notifications + calendar = icalendar.Calendar.from_ical(schedule._ical_file_primary) + current_shifts, _ = get_current_shifts_from_ical(calendar, schedule, 0) + schedule.current_shifts = json.dumps(current_shifts, default=str) + schedule.empty_oncall = False + schedule.save() + + with patch("apps.slack.slack_client.SlackClientWithErrorHandling.api_call") as mock_slack_api_call: + notify_ical_schedule_shift(schedule.oncallschedule_ptr_id) + + assert not mock_slack_api_call.called + + +@pytest.mark.django_db +def test_current_shift_changes_trigger_notification( + make_organization_and_user_with_slack_identities, + make_user, + make_schedule, + make_on_call_shift, +): + organization, _, _, _ = make_organization_and_user_with_slack_identities() + user1 = make_user(organization=organization, username="user1") + # clear users pks <-> organization cache (persisting between tests) + memoized_users_in_ical.cache_clear() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + name="test_schedule", + channel="channel", + prev_ical_file_overrides=None, + cached_ical_file_overrides=None, + ) + + now = timezone.now().replace(microsecond=0) + start_date = now - timezone.timedelta(days=7) + data = { + "start": start_date, + "rotation_start": start_date, + "duration": timezone.timedelta(seconds=3600 * 24), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user1]]) + on_call_shift.schedules.add(schedule) + schedule.refresh_ical_file() + + # setup empty current shifts before checking/triggering for notifications + schedule.current_shifts = json.dumps({}, default=str) + schedule.empty_oncall = False + schedule.save() + + with patch("apps.slack.slack_client.SlackClientWithErrorHandling.api_call") as mock_slack_api_call: + notify_ical_schedule_shift(schedule.oncallschedule_ptr_id) + + assert mock_slack_api_call.called From 362c382df2e287c97ec28d8a5425937ee3e66fe1 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 1 Jun 2023 15:10:52 -0300 Subject: [PATCH 7/7] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d095db4..340b87e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## v1.2.35 (2023-06-01) ### Fixed