From c5cd6757387f31516abc965663d707e05a6c527a Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Thu, 28 Mar 2024 11:37:22 -0400 Subject: [PATCH] cleanup `CustomButton` backend code + add `ngrok`/`express` outgoing webhook e2e test (#2544) # What this PR does - removes unused "custom button" backend code now that we've migrated to outgoing webhooks - adds new e2e test for webhooks asserting that an `ngrok`/`express` webhook handler receives the call as expected + payload is as expected (related to https://github.com/grafana/oncall/issues/2691) - skipped for now, the test passes locally but fails on GitHub Actions CI, seems to be networking related ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) --------- Co-authored-by: Michael Derynck --- dev/helm-local.yml | 3 + .../escalation_policies.md | 4 +- .../escalation_snapshot_mixin.py | 56 -- .../serializers/escalation_policy_snapshot.py | 3 - .../escalation_policy_snapshot.py | 30 +- .../incident_log_builder.py | 13 +- .../0050_alter_alertgrouplogrecord_type.py | 18 + .../alerts/models/alert_group_log_record.py | 14 +- engine/apps/alerts/models/custom_button.py | 144 ----- .../apps/alerts/models/escalation_policy.py | 32 +- engine/apps/alerts/tasks/__init__.py | 1 - .../apps/alerts/tasks/custom_button_result.py | 84 --- .../apps/alerts/tests/test_custom_button.py | 61 --- .../tests/test_escalation_policy_snapshot.py | 41 +- .../alerts/tests/test_escalation_snapshot.py | 3 - engine/apps/alerts/tests/test_utils.py | 22 - engine/apps/alerts/utils.py | 85 --- .../alert_group_escalation_snapshot.py | 3 - engine/apps/api/serializers/custom_button.py | 79 --- .../apps/api/serializers/escalation_policy.py | 14 +- .../test_alert_group_escalation_snapshot.py | 2 - engine/apps/api/tests/test_custom_button.py | 499 ------------------ .../apps/api/tests/test_escalation_policy.py | 6 - engine/apps/api/tests/test_schedules.py | 20 - engine/apps/api/tests/test_team.py | 9 - engine/apps/api/urls.py | 2 - engine/apps/api/views/custom_button.py | 125 ----- engine/apps/api/views/escalation_policy.py | 3 - .../serializers/escalation_policies.py | 21 +- .../tests/test_escalation_policies.py | 84 +-- engine/apps/public_api/views/action.py | 5 + .../alert_group_representative.py | 5 - .../apps/slack/scenarios/distribute_alerts.py | 73 --- .../migrations/0008_auto_20230712_1613.py | 7 +- engine/apps/webhooks/tasks/trigger_webhook.py | 3 +- .../webhooks/tests/test_trigger_webhook.py | 4 +- engine/apps/webhooks/utils.py | 8 +- engine/common/api_helpers/utils.py | 30 -- .../tests/test_urlvalidator_without_tld.py | 20 - engine/settings/celery_task_routes.py | 1 - .../escalationChains/escalationPolicy.test.ts | 8 +- ...ebhook.test.ts => advancedWebhook.test.ts} | 0 .../createSimpleWebhook.test.ts | 24 - .../outgoingWebhooks/simpleWebhook.test.ts | 82 +++ .../e2e-tests/utils/escalationChain.ts | 2 + grafana-plugin/package.json | 2 + grafana-plugin/yarn.lock | 416 ++++++++++++++- 47 files changed, 562 insertions(+), 1609 deletions(-) create mode 100644 engine/apps/alerts/migrations/0050_alter_alertgrouplogrecord_type.py delete mode 100644 engine/apps/alerts/tasks/custom_button_result.py delete mode 100644 engine/apps/alerts/tests/test_custom_button.py delete mode 100644 engine/apps/alerts/tests/test_utils.py delete mode 100644 engine/apps/api/serializers/custom_button.py delete mode 100644 engine/apps/api/tests/test_custom_button.py delete mode 100644 engine/apps/api/views/custom_button.py delete mode 100644 engine/common/tests/test_urlvalidator_without_tld.py rename grafana-plugin/e2e-tests/outgoingWebhooks/{createAdvancedWebhook.test.ts => advancedWebhook.test.ts} (100%) delete mode 100644 grafana-plugin/e2e-tests/outgoingWebhooks/createSimpleWebhook.test.ts create mode 100644 grafana-plugin/e2e-tests/outgoingWebhooks/simpleWebhook.test.ts diff --git a/dev/helm-local.yml b/dev/helm-local.yml index 938b387a..721bd3c6 100644 --- a/dev/helm-local.yml +++ b/dev/helm-local.yml @@ -5,6 +5,9 @@ env: value: "False" - name: FEATURE_PROMETHEUS_EXPORTER_ENABLED value: "True" + # enabled to be able to test docker.host.internal in the webhook e2e tests + - name: DANGEROUS_WEBHOOKS_ENABLED + value: "True" image: repository: localhost:63628/oncall/engine tag: dev diff --git a/docs/sources/oncall-api-reference/escalation_policies.md b/docs/sources/oncall-api-reference/escalation_policies.md index 12ce328a..d574a016 100644 --- a/docs/sources/oncall-api-reference/escalation_policies.md +++ b/docs/sources/oncall-api-reference/escalation_policies.md @@ -34,10 +34,10 @@ The above command returns JSON structured in the following way: | ---------------------------------- | :--------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `escalation_chain_id` | Yes | Each escalation policy is assigned to a specific escalation chain. | | `position` | Optional | Escalation policies execute one after another starting from `position=0`. `Position=-1` will put the escalation policy to the end of the list. A new escalation policy created with a position of an existing escalation policy will move the old one (and all following) down in the list. | -| `type` | Yes | One of: `wait`, `notify_persons`, `notify_person_next_each_time`, `notify_on_call_from_schedule`, `notify_user_group`, `trigger_action`, `resolve`, `notify_whole_channel`, `notify_if_time_from_to`. | +| `type` | Yes | One of: `wait`, `notify_persons`, `notify_person_next_each_time`, `notify_on_call_from_schedule`, `notify_user_group`, `trigger_webhook`, `resolve`, `notify_whole_channel`, `notify_if_time_from_to`. | | `important` | Optional | Default is `false`. Will assign "important" to personal notification rules if `true`. This can be used to distinguish alerts on which you want to be notified immediately by phone. Applicable for types `notify_persons`, `notify_team_members`, `notify_on_call_from_schedule`, and `notify_user_group`. | | `duration` | If type = `wait` | The duration, in seconds, when type `wait` is chosen. Valid values are: `60`, `300`, `900`, `1800`, `3600`. | -| `action_to_trigger` | If type = `trigger_action` | ID of a webhook. | +| `action_to_trigger` | If type = `trigger_webhook` | ID of a webhook. | | `group_to_notify` | If type = `notify_user_group` | ID of a `User Group`. | | `persons_to_notify` | If type = `notify_persons` | List of user IDs. | | `persons_to_notify_next_each_time` | If type = `notify_person_next_each_time` | List of user IDs. | diff --git a/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py b/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py index 8cf137b3..834214dc 100644 --- a/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py +++ b/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py @@ -42,62 +42,6 @@ class EscalationSnapshotMixin: """ Builds new escalation chain in a json serializable format (dict). Use this method to prepare escalation chain data for saving to alert group before start new escalation. - - Example result: - { - 'channel_filter_snapshot': { - 'id': 1, - 'notify_in_slack': True, - 'str_for_clients': 'default', - 'notify_in_telegram': True - }, - 'escalation_chain_snapshot': { - 'id': 1, - 'name': 'Test' - }, - 'escalation_policies_snapshots': [ - { - 'id': 1, - 'step': 14, - 'order': 0, - 'to_time': None, - 'from_time': None, - 'num_alerts_in_window': None, - 'num_minutes_in_window': None, - 'wait_delay': None, - 'notify_schedule': None, - 'notify_to_group': None, - 'notify_to_team_members': None, - 'passed_last_time': None, - 'escalation_counter': 0, - 'last_notified_user': None, - 'custom_button_trigger': None, - 'notify_to_users_queue': [1,2,3] - }, - { - 'id': 2, - 'step': 0, - 'order': 1, - 'to_time': None, - 'from_time': None, - 'num_alerts_in_window': None, - 'num_minutes_in_window': None, - 'wait_delay': '00:05:00', - 'notify_schedule': None, - 'notify_to_group': None, - 'notify_to_team_members': None, - 'passed_last_time': None, - 'escalation_counter': 0, - 'last_notified_user': None, - 'custom_button_trigger': None, - 'notify_to_users_queue': [] - }, - ], - 'slack_channel_id': 'SLACK_CHANNEL_ID', - 'last_active_escalation_policy_order': None, - 'pause_escalation': False, - 'next_step_eta': '2021-10-18T10:28:28.890369Z - } """ data = {} diff --git a/engine/apps/alerts/escalation_snapshot/serializers/escalation_policy_snapshot.py b/engine/apps/alerts/escalation_snapshot/serializers/escalation_policy_snapshot.py index b676d6be..62dfe18f 100644 --- a/engine/apps/alerts/escalation_snapshot/serializers/escalation_policy_snapshot.py +++ b/engine/apps/alerts/escalation_snapshot/serializers/escalation_policy_snapshot.py @@ -1,6 +1,5 @@ from rest_framework import serializers -from apps.alerts.models.custom_button import CustomButton from apps.alerts.models.escalation_policy import EscalationPolicy from apps.schedules.models import OnCallSchedule from apps.user_management.models import User @@ -58,7 +57,6 @@ class EscalationPolicySnapshotSerializer(serializers.ModelSerializer): ) escalation_counter = serializers.IntegerField(default=0) passed_last_time = serializers.DateTimeField(allow_null=True, default=None) - custom_button_trigger = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=CustomButton.objects) custom_webhook = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=Webhook.objects, default=None) notify_schedule = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=OnCallSchedule.objects) num_alerts_in_window = serializers.IntegerField(allow_null=True, default=None) @@ -79,7 +77,6 @@ class EscalationPolicySnapshotSerializer(serializers.ModelSerializer): "to_time", "num_alerts_in_window", "num_minutes_in_window", - "custom_button_trigger", "custom_webhook", "notify_schedule", "notify_to_group", diff --git a/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py b/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py index 10a0c4c9..98d894a4 100644 --- a/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py +++ b/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py @@ -11,7 +11,6 @@ from apps.alerts.escalation_snapshot.utils import eta_for_escalation_step_notify from apps.alerts.models.alert_group_log_record import AlertGroupLogRecord from apps.alerts.models.escalation_policy import EscalationPolicy from apps.alerts.tasks import ( - custom_button_result, custom_webhook_result, notify_all_task, notify_group_task, @@ -37,7 +36,6 @@ class EscalationPolicySnapshot: "to_time", "num_alerts_in_window", "num_minutes_in_window", - "custom_button_trigger", "custom_webhook", "notify_schedule", "notify_to_group", @@ -66,7 +64,6 @@ class EscalationPolicySnapshot: to_time, num_alerts_in_window, num_minutes_in_window, - custom_button_trigger, custom_webhook, notify_schedule, notify_to_group, @@ -85,7 +82,6 @@ class EscalationPolicySnapshot: self.to_time = to_time self.num_alerts_in_window = num_alerts_in_window self.num_minutes_in_window = num_minutes_in_window - self.custom_button_trigger = custom_button_trigger self.custom_webhook = custom_webhook self.notify_schedule = notify_schedule self.notify_to_group = notify_to_group @@ -131,7 +127,6 @@ class EscalationPolicySnapshot: EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT: self._escalation_step_notify_team_members, EscalationPolicy.STEP_NOTIFY_SCHEDULE: self._escalation_step_notify_on_call_schedule, EscalationPolicy.STEP_NOTIFY_SCHEDULE_IMPORTANT: self._escalation_step_notify_on_call_schedule, - EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: self._escalation_step_trigger_custom_button, EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: self._escalation_step_trigger_custom_webhook, EscalationPolicy.STEP_NOTIFY_USERS_QUEUE: self._escalation_step_notify_users_queue, EscalationPolicy.STEP_NOTIFY_IF_TIME: self._escalation_step_notify_if_time, @@ -474,29 +469,6 @@ class EscalationPolicySnapshot: return self._get_result_tuple(pause_escalation=True) return None - def _escalation_step_trigger_custom_button(self, alert_group: "AlertGroup", _reason: str) -> None: - tasks = [] - custom_button = self.custom_button_trigger - if custom_button is not None: - custom_button_task = custom_button_result.signature( - (custom_button.pk, alert_group.pk), - { - "escalation_policy_pk": self.id, - }, - immutable=True, - ) - tasks.append(custom_button_task) - else: - log_record = AlertGroupLogRecord( - type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED, - alert_group=alert_group, - escalation_policy=self.escalation_policy, - escalation_error_code=AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED, - escalation_policy_step=self.step, - ) - log_record.save() - self._execute_tasks(tasks) - def _escalation_step_trigger_custom_webhook(self, alert_group: "AlertGroup", _reason: str) -> None: tasks = [] webhook = self.custom_webhook @@ -514,7 +486,7 @@ class EscalationPolicySnapshot: type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED, alert_group=alert_group, escalation_policy=self.escalation_policy, - escalation_error_code=AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED, + escalation_error_code=AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_WEBHOOK_STEP_IS_NOT_CONFIGURED, escalation_policy_step=self.step, ) log_record.save() diff --git a/engine/apps/alerts/incident_log_builder/incident_log_builder.py b/engine/apps/alerts/incident_log_builder/incident_log_builder.py index 652dc8ae..72ba3fe9 100644 --- a/engine/apps/alerts/incident_log_builder/incident_log_builder.py +++ b/engine/apps/alerts/incident_log_builder/incident_log_builder.py @@ -554,18 +554,7 @@ class IncidentLogBuilder: # notification_plan_dict structure - {timedelta: [{"user_id": user.pk, "plan_lines": []}] for timedelta, notification_plan in notification_plan_dict.items(): escalation_plan_dict.setdefault(timedelta, []).extend(notification_plan) - elif escalation_policy_snapshot.step == EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: - if future_step: - custom_button = escalation_policy_snapshot.custom_button_trigger - if custom_button is not None: - plan_line = f"trigger outgoing webhook `{custom_button.name}`" - else: - plan_line = ( - f'escalation step "{escalation_policy_snapshot.step_display}", ' - f"but outgoing webhook is unspecified. Skipping" - ) - plan = {"plan_lines": [plan_line]} - escalation_plan_dict.setdefault(timedelta, []).append(plan) + # TODO: should we add logic here for new webhooks? elif escalation_policy_snapshot.step == EscalationPolicy.STEP_NOTIFY_IF_TIME: if future_step: if escalation_policy_snapshot.from_time is not None and escalation_policy_snapshot.to_time is not None: diff --git a/engine/apps/alerts/migrations/0050_alter_alertgrouplogrecord_type.py b/engine/apps/alerts/migrations/0050_alter_alertgrouplogrecord_type.py new file mode 100644 index 00000000..7430e0c7 --- /dev/null +++ b/engine/apps/alerts/migrations/0050_alter_alertgrouplogrecord_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-03-07 15:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0049_alter_alertgrouplogrecord_action_source'), + ] + + operations = [ + migrations.AlterField( + model_name='alertgrouplogrecord', + name='type', + field=models.IntegerField(choices=[(0, 'Acknowledged'), (1, 'Unacknowledged'), (2, 'Invite'), (3, 'Stop invitation'), (4, 'Re-invite'), (5, 'Escalation triggered'), (6, 'Invitation triggered'), (16, 'Escalation finished'), (7, 'Silenced'), (15, 'Unsilenced'), (8, 'Attached'), (9, 'Unattached'), (10, 'Custom webhook triggered'), (11, 'Unacknowledged by timeout'), (12, 'Failed attachment'), (13, 'Incident resolved'), (14, 'Incident unresolved'), (17, 'Escalation failed'), (18, 'Acknowledge reminder triggered'), (19, 'Wiped'), (20, 'Deleted'), (21, 'Incident registered'), (22, 'A route is assigned to the incident'), (23, 'Trigger direct paging escalation'), (24, 'Unpage a user'), (25, 'Restricted')]), + ), + ] diff --git a/engine/apps/alerts/models/alert_group_log_record.py b/engine/apps/alerts/models/alert_group_log_record.py index 1ea9cefc..e76e41f5 100644 --- a/engine/apps/alerts/models/alert_group_log_record.py +++ b/engine/apps/alerts/models/alert_group_log_record.py @@ -43,7 +43,7 @@ class AlertGroupLogRecord(models.Model): TYPE_SILENCE, TYPE_ATTACHED, TYPE_UNATTACHED, - TYPE_CUSTOM_BUTTON_TRIGGERED, + TYPE_CUSTOM_WEBHOOK_TRIGGERED, TYPE_AUTO_UN_ACK, TYPE_FAILED_ATTACHMENT, TYPE_RESOLVED, @@ -77,7 +77,7 @@ class AlertGroupLogRecord(models.Model): TYPE_SILENCE, TYPE_ATTACHED, TYPE_UNATTACHED, - TYPE_CUSTOM_BUTTON_TRIGGERED, + TYPE_CUSTOM_WEBHOOK_TRIGGERED, TYPE_FAILED_ATTACHMENT, TYPE_RESOLVED, TYPE_UN_RESOLVED, @@ -98,7 +98,7 @@ class AlertGroupLogRecord(models.Model): (TYPE_UN_SILENCE, "Unsilenced"), (TYPE_ATTACHED, "Attached"), (TYPE_UNATTACHED, "Unattached"), - (TYPE_CUSTOM_BUTTON_TRIGGERED, "Custom button triggered"), + (TYPE_CUSTOM_WEBHOOK_TRIGGERED, "Custom webhook triggered"), (TYPE_AUTO_UN_ACK, "Unacknowledged by timeout"), (TYPE_FAILED_ATTACHMENT, "Failed attachment"), (TYPE_RESOLVED, "Incident resolved"), @@ -127,7 +127,7 @@ class AlertGroupLogRecord(models.Model): TYPE_UN_SILENCE: "un_silence", TYPE_ATTACHED: "attach", TYPE_UNATTACHED: "un_attach", - TYPE_CUSTOM_BUTTON_TRIGGERED: "custom_button_triggered", + TYPE_CUSTOM_WEBHOOK_TRIGGERED: "custom_webhook_triggered", TYPE_AUTO_UN_ACK: "auto_un_acknowledge", TYPE_FAILED_ATTACHMENT: "fail_attach", TYPE_RESOLVED: "resolve", @@ -155,7 +155,7 @@ class AlertGroupLogRecord(models.Model): ERROR_ESCALATION_NOTIFY_GROUP_STEP_IS_NOT_CONFIGURED, ERROR_ESCALATION_USER_GROUP_IS_EMPTY, ERROR_ESCALATION_USER_GROUP_DOES_NOT_EXIST, - ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED, + ERROR_ESCALATION_TRIGGER_WEBHOOK_STEP_IS_NOT_CONFIGURED, ERROR_ESCALATION_NOTIFY_IN_SLACK, ERROR_ESCALATION_NOTIFY_IF_NUM_ALERTS_IN_WINDOW_STEP_IS_NOT_CONFIGURED, ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR, @@ -471,7 +471,7 @@ class AlertGroupLogRecord(models.Model): f"{self.dependent_alert_group.long_verbose_name} has been unattached from this alert" f"{f' by {author_name}' if author_name else ''}" ) - elif self.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED: + elif self.type == AlertGroupLogRecord.TYPE_CUSTOM_WEBHOOK_TRIGGERED: webhook_name = "" trigger = None if step_specific_info is not None: @@ -529,7 +529,7 @@ class AlertGroupLogRecord(models.Model): result += 'skipped escalation step "Notify Team Members" because it is not configured' elif ( self.escalation_error_code - == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED + == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_WEBHOOK_STEP_IS_NOT_CONFIGURED ): result += 'skipped escalation step "Trigger Outgoing Webhook" because it is not configured' elif self.escalation_error_code == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR: diff --git a/engine/apps/alerts/models/custom_button.py b/engine/apps/alerts/models/custom_button.py index 626e7f79..be3779f5 100644 --- a/engine/apps/alerts/models/custom_button.py +++ b/engine/apps/alerts/models/custom_button.py @@ -1,18 +1,11 @@ -import json -import logging -import re import typing -from json import JSONDecodeError from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models from django.db.models import F from django.utils import timezone -from requests.auth import HTTPBasicAuth -from common.jinja_templater import apply_jinja_template -from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: @@ -21,10 +14,6 @@ if typing.TYPE_CHECKING: from apps.alerts.models import EscalationPolicy -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - - def generate_public_primary_key_for_custom_button(): prefix = "K" new_public_primary_key = generate_public_primary_key(prefix) @@ -48,15 +37,11 @@ class CustomButtonManager(models.Manager): def get_queryset(self): return CustomButtonQueryset(self.model, using=self._db).filter(deleted_at=None) - def hard_delete(self): - return self.get_queryset().hard_delete() - class CustomButton(models.Model): escalation_policies: "RelatedManager['EscalationPolicy']" objects = CustomButtonManager() - objects_with_deleted = models.Manager() public_primary_key = models.CharField( max_length=20, @@ -90,132 +75,3 @@ class CustomButton(models.Model): def __str__(self): return str(self.name) - - def delete(self): - logger.info(f"Soft delete of custom button {self}") - self.escalation_policies.all().delete() - self.deleted_at = timezone.now() - # 100 - 22 = 78. 100 is max len of name field, and 22 is len of suffix _deleted_ - # So for case when user created button with maximum length name it is needed to trim it to 78 chars to be - # able to add suffix. - self.name = f"{self.name[:78]}_deleted_{self.public_primary_key}" - self.save() - - def hard_delete(self): - super().delete() - - def build_post_kwargs(self, alert): - post_kwargs = {} - if self.user and self.password: - post_kwargs["auth"] = HTTPBasicAuth(self.user, self.password) - if self.authorization_header: - post_kwargs["headers"] = {"Authorization": self.authorization_header} - if self.forward_whole_payload: - post_kwargs["json"] = alert.raw_request_data - elif self.data: - try: - rendered_data = apply_jinja_template( - self.data, - alert_payload=self._escape_alert_payload(alert.raw_request_data), - alert_group_id=alert.group.public_primary_key, - ) - try: - post_kwargs["json"] = json.loads(rendered_data) - except JSONDecodeError: - post_kwargs["data"] = rendered_data - except (JinjaTemplateError, JinjaTemplateWarning) as e: - post_kwargs["json"] = {"error": e.fallback_message} - return post_kwargs - - def _escape_alert_payload(self, payload: dict): - if isinstance(payload, dict): - escaped_data = EscapeDoubleQuotesDict() - for key in payload.keys(): - escaped_data[key] = self._escape_alert_payload(payload[key]) - elif isinstance(payload, list): - escaped_data = [] - for value in payload: - escaped_data.append(self._escape_alert_payload(value)) - elif isinstance(payload, str): - escaped_data = self._escape_string(payload) - else: - escaped_data = payload - return escaped_data - - def _escape_string(self, string: str): - """ - Escapes string to use in json.loads() method. - json.dumps is the simples way to escape all special characters in string. - First and last chars are quotes from json.dumps(), we don't need them, only escaping. - """ - return json.dumps(string)[1:-1] - - # Insight logs - @property - def insight_logs_type_verbal(self): - return "outgoing_webhook" - - @property - def insight_logs_verbal(self): - return self.name - - @property - def insight_logs_serialized(self): - result = { - "name": self.name, - "webhook": self.webhook, - "user": self.user, - "password": self.password, - "authorization_header": self.authorization_header, - "data": self.data, - "forward_whole_payload": self.forward_whole_payload, - } - - if self.team: - result["team"] = self.team.name - result["team_id"] = self.team.public_primary_key - else: - result["team"] = "General" - return result - - @property - def insight_logs_metadata(self): - result = {} - if self.team: - result["team"] = self.team.name - result["team_id"] = self.team.public_primary_key - else: - result["team"] = "General" - return result - - -class EscapeDoubleQuotesDict(dict): - """ - Warning: Please, do not use this dict anywhere except CustomButton._escape_alert_payload. - This custom dict escapes double quotes to produce string which is safe to pass to json.loads() - It fixes case when CustomButton.build_post_kwargs failing on payloads which contains string with single quote. - In this case built-in dict's str method will surround value with double quotes. - - For example: - - alert_payload = { - "text": "Hi, it's alert", - } - template = '{"data" : "{{ alert_payload }}"}' - rendered = '{"data" : "{\'text\': "Hi, it\'s alert"}"}' - # and json.loads(rendered) will fail due to unescaped double quotes - - # Now with EscapeDoubleQuotesDict. - - alert_payload = EscapeDoubleQuotesDict({ - "text": "Hi, it's alert", - }) - rendered = '{"data" : "{\'text\': \\"Hi, it\'s alert\\"}"}' - # and json.loads(rendered) works. - """ - - def __str__(self): - original_str = super().__str__() - if '"' in original_str: - return re.sub('(? result.eta > expected_eta - timezone.timedelta(seconds=15) - assert result == expected_result - assert mocked_execute_tasks.called - - @patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None) @pytest.mark.django_db def test_escalation_step_trigger_custom_webhook( @@ -493,7 +462,7 @@ def test_escalation_step_trigger_custom_webhook( trigger_custom_webhook_step = make_escalation_policy( escalation_chain=channel_filter.escalation_chain, - escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, + escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, custom_webhook=custom_webhook, ) escalation_policy_snapshot = get_escalation_policy_snapshot_from_model(trigger_custom_webhook_step) @@ -509,6 +478,13 @@ def test_escalation_step_trigger_custom_webhook( assert result == expected_result assert mocked_execute_tasks.called + with patch( + "apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._escalation_step_trigger_custom_webhook" + ) as mock_webhook_escalation_step: + escalation_policy_snapshot.execute(alert_group, reason) + + mock_webhook_escalation_step.assert_called_once_with(alert_group, reason) + @patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None) @pytest.mark.django_db @@ -626,7 +602,6 @@ def test_escalation_step_with_deleted_user( "to_time": None, "num_alerts_in_window": None, "num_minutes_in_window": None, - "custom_button_trigger": None, "notify_schedule": None, "notify_to_group": None, "escalation_counter": 0, diff --git a/engine/apps/alerts/tests/test_escalation_snapshot.py b/engine/apps/alerts/tests/test_escalation_snapshot.py index 96d62694..2b00ec42 100644 --- a/engine/apps/alerts/tests/test_escalation_snapshot.py +++ b/engine/apps/alerts/tests/test_escalation_snapshot.py @@ -45,7 +45,6 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup): "to_time": None, "num_alerts_in_window": None, "num_minutes_in_window": None, - "custom_button_trigger": None, "custom_webhook": None, "escalation_counter": 0, "passed_last_time": None, @@ -65,7 +64,6 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup): "to_time": None, "num_alerts_in_window": None, "num_minutes_in_window": None, - "custom_button_trigger": None, "custom_webhook": None, "escalation_counter": 0, "passed_last_time": None, @@ -85,7 +83,6 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup): "to_time": notify_if_time_step.to_time.isoformat(), "num_alerts_in_window": None, "num_minutes_in_window": None, - "custom_button_trigger": None, "custom_webhook": None, "escalation_counter": 0, "passed_last_time": None, diff --git a/engine/apps/alerts/tests/test_utils.py b/engine/apps/alerts/tests/test_utils.py deleted file mode 100644 index 7934f64a..00000000 --- a/engine/apps/alerts/tests/test_utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import socket -from unittest.mock import patch - -import pytest - -from apps.alerts.utils import request_outgoing_webhook - - -@pytest.mark.django_db -def test_request_outgoing_webhook_cannot_resolve_name(): - with patch("apps.alerts.utils.socket.gethostbyname", side_effect=socket.gaierror): - success, err = request_outgoing_webhook("http://something.something/webhook", "GET") - assert success is False - assert err == "Cannot resolve name in url" - - -@pytest.mark.django_db -def test_request_outgoing_webhook_resolve_name_without_port(): - with patch("apps.alerts.utils.socket.gethostbyname") as mock_gethostbyname: - mock_gethostbyname.return_value = "127.0.0.1" - request_outgoing_webhook("http://something.something:9000/webhook", "GET") - assert mock_gethostbyname.call_args_list[0].args[0] == "something.something" diff --git a/engine/apps/alerts/utils.py b/engine/apps/alerts/utils.py index 1f89b9a0..abf6b24c 100644 --- a/engine/apps/alerts/utils.py +++ b/engine/apps/alerts/utils.py @@ -1,14 +1,3 @@ -import ipaddress -import json -import socket -from typing import Tuple -from urllib.parse import urlparse - -import requests - -from apps.base.utils import live_settings - - def render_relative_timeline(log_created_at, alert_group_started_at): time_delta = log_created_at - alert_group_started_at seconds = int(time_delta.total_seconds()) @@ -23,77 +12,3 @@ def render_relative_timeline(log_created_at, alert_group_started_at): return "%dm%ds" % (minutes, seconds) else: return "%ds" % (seconds,) - - -# TODO: remove this function when we remove CustomButton model -def render_curl_command(webhook_url, http_request_type, post_kwargs): - if http_request_type == "POST": - curl_request = "curl -X POST" - if "auth" in post_kwargs: - curl_request += "\n-u ****" - if "headers" in post_kwargs: - curl_request += "\n-H ****" - if "json" in post_kwargs: - curl_request += "\n-d '{}'".format(json.dumps(post_kwargs["json"], indent=2, sort_keys=True)) - curl_request += "\n{}".format(webhook_url) - elif http_request_type == "GET": - curl_request = f"curl -X GET {webhook_url}" - else: - raise Exception("Unsupported http method") - return curl_request - - -# TODO: remove this function when we remove CustomButton model -def request_outgoing_webhook(webhook_url, http_request_type, post_kwargs=None) -> Tuple[bool, str]: - OUTGOING_WEBHOOK_TIMEOUT = 10 - if http_request_type not in ["POST", "GET"]: - raise Exception(f"Wrong http_method parameter: {http_request_type}") - - parsed_url = urlparse(webhook_url) - # ensure the url looks like url - if parsed_url.scheme not in ["http", "https"]: - return False, "Malformed url" - if not parsed_url.netloc: - return False, "Malformed url" - if not live_settings.DANGEROUS_WEBHOOKS_ENABLED: - # Get the ip address of the webhook url and check if it belongs to the private network - try: - webhook_url_ip_address = socket.gethostbyname(parsed_url.hostname) - except socket.gaierror: - return False, "Cannot resolve name in url" - if not live_settings.DANGEROUS_WEBHOOKS_ENABLED: - if ipaddress.ip_address(socket.gethostbyname(webhook_url_ip_address)).is_private: - return False, "This url is not supported for outgoing webhooks" - - if post_kwargs is None: - post_kwargs = {} - - try: - if http_request_type == "POST": - r = requests.post(webhook_url, timeout=OUTGOING_WEBHOOK_TIMEOUT, **post_kwargs) - elif http_request_type == "GET": - r = requests.get(webhook_url, timeout=OUTGOING_WEBHOOK_TIMEOUT) - else: - raise Exception() - r.raise_for_status() - return True, "OK 200" - except requests.exceptions.HTTPError: - return False, "HTTP error {}".format(r.status_code) - except requests.exceptions.SSLError: - return False, "ssl certificate error" - except requests.exceptions.ConnectionError: - return False, "Connection error happened. Probably that's because of network or proxy." - except requests.exceptions.MissingSchema: - return False, "Url {} is incorrect. http:// or https:// might be missing.".format(webhook_url) - except requests.exceptions.ChunkedEncodingError: - return False, "File content or headers might be wrong." - except requests.exceptions.InvalidURL: - return False, "Url {} is incorrect".format(webhook_url) - except requests.exceptions.TooManyRedirects: - return False, "Multiple redirects happened. That's suspicious!" - except requests.exceptions.Timeout: - return False, f"Request timeout {OUTGOING_WEBHOOK_TIMEOUT} secs exceeded." - except requests.exceptions.RequestException: # This is the correct syntax - return False, "Failed to call outgoing webhook" - except Exception: - return False, "Failed to call outgoing webhook" diff --git a/engine/apps/api/serializers/alert_group_escalation_snapshot.py b/engine/apps/api/serializers/alert_group_escalation_snapshot.py index 23e135b8..296e8d26 100644 --- a/engine/apps/api/serializers/alert_group_escalation_snapshot.py +++ b/engine/apps/api/serializers/alert_group_escalation_snapshot.py @@ -1,6 +1,5 @@ from rest_framework import serializers -from apps.api.serializers.custom_button import CustomButtonFastSerializer from apps.api.serializers.escalation_policy import EscalationPolicySerializer from apps.api.serializers.schedule_base import ScheduleFastSerializer from apps.api.serializers.user import FastUserSerializer @@ -14,7 +13,6 @@ class EscalationPolicySnapshotAPISerializer(EscalationPolicySerializer): notify_to_users_queue = FastUserSerializer(many=True, read_only=True) notify_schedule = ScheduleFastSerializer(read_only=True) notify_to_group = UserGroupSerializer(read_only=True) - custom_button_trigger = CustomButtonFastSerializer(read_only=True) custom_webhook = WebhookFastSerializer(read_only=True) class Meta(EscalationPolicySerializer.Meta): @@ -27,7 +25,6 @@ class EscalationPolicySnapshotAPISerializer(EscalationPolicySerializer): "num_alerts_in_window", "num_minutes_in_window", "slack_integration_required", - "custom_button_trigger", "custom_webhook", "notify_schedule", "notify_to_group", diff --git a/engine/apps/api/serializers/custom_button.py b/engine/apps/api/serializers/custom_button.py deleted file mode 100644 index 555c8ac2..00000000 --- a/engine/apps/api/serializers/custom_button.py +++ /dev/null @@ -1,79 +0,0 @@ -from collections import defaultdict - -from django.core.validators import URLValidator, ValidationError -from rest_framework import serializers -from rest_framework.validators import UniqueTogetherValidator - -from apps.alerts.models import CustomButton -from apps.base.utils import live_settings -from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField -from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, URLValidatorWithoutTLD -from common.jinja_templater import apply_jinja_template -from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning - - -class CustomButtonSerializer(serializers.ModelSerializer): - id = serializers.CharField(read_only=True, source="public_primary_key") - organization = serializers.HiddenField(default=CurrentOrganizationDefault()) - team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault()) - forward_whole_payload = serializers.BooleanField(allow_null=True, required=False) - - class Meta: - model = CustomButton - fields = [ - "id", - "name", - "team", - "webhook", - "data", - "user", - "password", - "authorization_header", - "organization", - "forward_whole_payload", - ] - extra_kwargs = { - "name": {"required": True, "allow_null": False, "allow_blank": False}, - "webhook": {"required": True, "allow_null": False, "allow_blank": False}, - } - - validators = [UniqueTogetherValidator(queryset=CustomButton.objects.all(), fields=["name", "organization"])] - - def validate_webhook(self, webhook): - if webhook: - try: - if live_settings.DANGEROUS_WEBHOOKS_ENABLED: - URLValidatorWithoutTLD()(webhook) - else: - URLValidator()(webhook) - except ValidationError: - raise serializers.ValidationError("Webhook is incorrect") - return webhook - return None - - def validate_data(self, data): - if not data: - return None - - try: - apply_jinja_template(data, alert_payload=defaultdict(str), alert_group_id="abcd") - except JinjaTemplateError as e: - raise serializers.ValidationError(e.fallback_message) - except JinjaTemplateWarning: - # Suppress render exceptions since we do not have a representative payload to test with - pass - - return data - - def validate_forward_whole_payload(self, data): - if data is None: - return False - return data - - -class CustomButtonFastSerializer(serializers.ModelSerializer): - id = serializers.CharField(read_only=True, source="public_primary_key") - - class Meta: - model = CustomButton - fields = ["id", "name"] diff --git a/engine/apps/api/serializers/escalation_policy.py b/engine/apps/api/serializers/escalation_policy.py index ace5d708..6accbc71 100644 --- a/engine/apps/api/serializers/escalation_policy.py +++ b/engine/apps/api/serializers/escalation_policy.py @@ -3,7 +3,7 @@ from datetime import timedelta from rest_framework import serializers -from apps.alerts.models import CustomButton, EscalationChain, EscalationPolicy +from apps.alerts.models import EscalationChain, EscalationPolicy from apps.schedules.models import OnCallSchedule from apps.slack.models import SlackUserGroup from apps.user_management.models import Team, User @@ -23,7 +23,6 @@ FROM_TIME = "from_time" TO_TIME = "to_time" NUM_ALERTS_IN_WINDOW = "num_alerts_in_window" NUM_MINUTES_IN_WINDOW = "num_minutes_in_window" -CUSTOM_BUTTON_TRIGGER = "custom_button_trigger" CUSTOM_WEBHOOK_TRIGGER = "custom_webhook" STEP_TYPE_TO_RELATED_FIELD_MAP = { @@ -35,7 +34,6 @@ STEP_TYPE_TO_RELATED_FIELD_MAP = { EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS: [NOTIFY_TEAM_MEMBERS], EscalationPolicy.STEP_NOTIFY_IF_TIME: [FROM_TIME, TO_TIME], EscalationPolicy.STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW: [NUM_ALERTS_IN_WINDOW, NUM_MINUTES_IN_WINDOW], - EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: [CUSTOM_BUTTON_TRIGGER], EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: [CUSTOM_WEBHOOK_TRIGGER], } @@ -75,12 +73,6 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) allow_null=True, filter_field="slack_team_identity__organizations", ) - custom_button_trigger = OrganizationFilteredPrimaryKeyRelatedField( - queryset=CustomButton.objects, - required=False, - allow_null=True, - filter_field="organization", - ) custom_webhook = OrganizationFilteredPrimaryKeyRelatedField( queryset=Webhook.objects, required=False, @@ -101,7 +93,6 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) "num_alerts_in_window", "num_minutes_in_window", "slack_integration_required", - "custom_button_trigger", "custom_webhook", "notify_schedule", "notify_to_group", @@ -114,7 +105,6 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) "notify_schedule", "notify_to_group", "notify_to_team_members", - "custom_button_trigger", "custom_webhook", ] PREFETCH_RELATED = ["notify_to_users_queue"] @@ -130,7 +120,6 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) TO_TIME, NUM_ALERTS_IN_WINDOW, NUM_MINUTES_IN_WINDOW, - CUSTOM_BUTTON_TRIGGER, CUSTOM_WEBHOOK_TRIGGER, ] @@ -239,7 +228,6 @@ class EscalationPolicyUpdateSerializer(EscalationPolicySerializer): TO_TIME, NUM_ALERTS_IN_WINDOW, NUM_MINUTES_IN_WINDOW, - CUSTOM_BUTTON_TRIGGER, CUSTOM_WEBHOOK_TRIGGER, ] diff --git a/engine/apps/api/tests/test_alert_group_escalation_snapshot.py b/engine/apps/api/tests/test_alert_group_escalation_snapshot.py index c4ae0c42..5dfeda0e 100644 --- a/engine/apps/api/tests/test_alert_group_escalation_snapshot.py +++ b/engine/apps/api/tests/test_alert_group_escalation_snapshot.py @@ -48,7 +48,6 @@ def test_alert_group_escalation_snapshot_with_important( "to_time": None, "num_alerts_in_window": None, "num_minutes_in_window": None, - "custom_button_trigger": None, "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, @@ -62,7 +61,6 @@ def test_alert_group_escalation_snapshot_with_important( "to_time": None, "num_alerts_in_window": None, "num_minutes_in_window": None, - "custom_button_trigger": None, "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, diff --git a/engine/apps/api/tests/test_custom_button.py b/engine/apps/api/tests/test_custom_button.py deleted file mode 100644 index 240b6745..00000000 --- a/engine/apps/api/tests/test_custom_button.py +++ /dev/null @@ -1,499 +0,0 @@ -import json -from unittest.mock import patch - -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.response import Response -from rest_framework.test import APIClient - -from apps.alerts.models import CustomButton -from apps.api.permissions import LegacyAccessControlRole - -TEST_URL = "https://amixr.io" -URL_WITH_TLD = "http://www.google.com" -URL_WITHOUT_TLD = "http://container:8080" - - -@pytest.fixture() -def custom_button_internal_api_setup(make_organization_and_user_with_plugin_token, make_custom_action): - organization, user, token = make_organization_and_user_with_plugin_token() - custom_button = make_custom_action( - name="github_button", - webhook="https://github.com/", - user="Chris Vanstras", - password="qwerty", - data='{"name": "{{ alert_payload }}"}', - authorization_header="auth_token", - organization=organization, - ) - return user, token, custom_button - - -@pytest.mark.django_db -def test_get_list_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - expected_payload = [ - { - "id": custom_button.public_primary_key, - "name": "github_button", - "team": None, - "webhook": "https://github.com/", - "data": '{"name": "{{ alert_payload }}"}', - "user": "Chris Vanstras", - "password": "qwerty", - "authorization_header": "auth_token", - "forward_whole_payload": False, - } - ] - - response = client.get(url, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_200_OK - assert response.json() == expected_payload - - -@pytest.mark.django_db -def test_get_detail_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key}) - - expected_payload = { - "id": custom_button.public_primary_key, - "name": "github_button", - "team": None, - "webhook": "https://github.com/", - "data": '{"name": "{{ alert_payload }}"}', - "user": "Chris Vanstras", - "password": "qwerty", - "authorization_header": "auth_token", - "forward_whole_payload": False, - } - - response = client.get(url, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_200_OK - assert response.json() == expected_payload - - -@pytest.mark.django_db -def test_create_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button", - "webhook": TEST_URL, - "team": None, - } - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - custom_button = CustomButton.objects.get(public_primary_key=response.data["id"]) - expected_response = data | { - "id": custom_button.public_primary_key, - "user": None, - "password": None, - "data": None, - "authorization_header": None, - "forward_whole_payload": False, - } - assert response.status_code == status.HTTP_201_CREATED - assert response.data == expected_response - - -@pytest.mark.django_db -def test_create_valid_data_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button_with_valid_data", - "webhook": TEST_URL, - "data": '{"name": "{{ alert_payload }}"}', - "team": None, - } - - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - # modify initial data by adding id and None for optional fields - custom_button = CustomButton.objects.get(public_primary_key=response.data["id"]) - expected_response = data | { - "id": custom_button.public_primary_key, - "user": None, - "password": None, - "authorization_header": None, - "forward_whole_payload": False, - } - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == expected_response - - -@pytest.mark.django_db -def test_create_valid_nested_data_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button_with_valid_data", - "webhook": TEST_URL, - # Assert that nested field access still works as long as the variable - # is quoted, making it valid JSON. - # This ensures backwards compatibility from when templates were required - # to be JSON. - "data": '{"nested_item": "{{ alert_payload.foo.bar }}"}', - "team": None, - } - - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - # modify initial data by adding id and None for optional fields - custom_button = CustomButton.objects.get(public_primary_key=response.data["id"]) - expected_response = data | { - "id": custom_button.public_primary_key, - "user": None, - "password": None, - "authorization_header": None, - "forward_whole_payload": False, - } - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == expected_response - - -@pytest.mark.django_db -def test_create_valid_data_after_render_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button_with_valid_data", - "webhook": TEST_URL, - "data": '{"name": "{{ alert_payload.name }}", "labels": {{ alert_payload.labels | tojson }}}', - "team": None, - } - - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - # modify initial data by adding id and None for optional fields - custom_button = CustomButton.objects.get(public_primary_key=response.data["id"]) - expected_response = data | { - "id": custom_button.public_primary_key, - "user": None, - "password": None, - "authorization_header": None, - "forward_whole_payload": False, - } - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == expected_response - - -@pytest.mark.django_db -def test_create_valid_data_after_render_use_all_data_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button_with_valid_data", - "webhook": TEST_URL, - "data": "{{ alert_payload | tojson }}", - "team": None, - } - - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - # modify initial data by adding id and None for optional fields - custom_button = CustomButton.objects.get(public_primary_key=response.data["id"]) - expected_response = data | { - "id": custom_button.public_primary_key, - "user": None, - "password": None, - "authorization_header": None, - "forward_whole_payload": False, - } - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == expected_response - - -@pytest.mark.django_db -def test_create_invalid_url_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button_invalid_url", - "webhook": "invalid_url", - } - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.django_db -def test_create_invalid_data_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button_invalid_data", - "webhook": TEST_URL, - "data": "{{%", - } - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.django_db -def test_update_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key}) - - data = { - "name": "github_button_updated", - "webhook": "https://github.com/", - "team": None, - } - response = client.put( - url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token) - ) - updated_instance = CustomButton.objects.get(public_primary_key=custom_button.public_primary_key) - assert response.status_code == status.HTTP_200_OK - assert updated_instance.name == "github_button_updated" - - -@pytest.mark.django_db -def test_delete_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key}) - - response = client.delete(url, **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_204_NO_CONTENT - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "role,expected_status", - [ - (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), - (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), - (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), - (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), - ], -) -def test_custom_button_create_permissions( - make_organization_and_user_with_plugin_token, - make_user_auth_headers, - role, - expected_status, -): - _, user, token = make_organization_and_user_with_plugin_token(role) - client = APIClient() - - url = reverse("api-internal:custom_button-list") - - with patch( - "apps.api.views.custom_button.CustomButtonView.create", - return_value=Response( - status=status.HTTP_200_OK, - ), - ): - response = client.post(url, format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == expected_status - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "role,expected_status", - [ - (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), - (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), - (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), - (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), - ], -) -def test_custom_button_update_permissions( - make_organization_and_user_with_plugin_token, - make_custom_action, - make_user_auth_headers, - role, - expected_status, -): - organization, user, token = make_organization_and_user_with_plugin_token(role) - custom_button = make_custom_action(organization=organization) - client = APIClient() - - url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key}) - - with patch( - "apps.api.views.custom_button.CustomButtonView.update", - return_value=Response( - status=status.HTTP_200_OK, - ), - ): - response = client.put(url, format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == expected_status - - response = client.patch(url, format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == expected_status - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "role,expected_status", - [ - (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), - (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), - (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), - (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), - ], -) -def test_custom_button_list_permissions( - make_organization_and_user_with_plugin_token, - make_custom_action, - make_user_auth_headers, - role, - expected_status, -): - organization, user, token = make_organization_and_user_with_plugin_token(role) - make_custom_action(organization=organization) - client = APIClient() - - url = reverse("api-internal:custom_button-list") - - with patch( - "apps.api.views.custom_button.CustomButtonView.list", - return_value=Response( - status=status.HTTP_200_OK, - ), - ): - response = client.get(url, format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == expected_status - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "role,expected_status", - [ - (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), - (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), - (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), - (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), - ], -) -def test_custom_button_retrieve_permissions( - make_organization_and_user_with_plugin_token, - make_custom_action, - make_user_auth_headers, - role, - expected_status, -): - organization, user, token = make_organization_and_user_with_plugin_token(role) - custom_button = make_custom_action(organization=organization) - client = APIClient() - - url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key}) - - with patch( - "apps.api.views.custom_button.CustomButtonView.retrieve", - return_value=Response( - status=status.HTTP_200_OK, - ), - ): - response = client.get(url, format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == expected_status - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "role,expected_status", - [ - (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), - (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), - (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), - (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), - ], -) -def test_custom_button_delete_permissions( - make_organization_and_user_with_plugin_token, - make_custom_action, - make_user_auth_headers, - role, - expected_status, -): - organization, user, token = make_organization_and_user_with_plugin_token(role) - custom_button = make_custom_action(organization=organization) - client = APIClient() - - url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key}) - - with patch( - "apps.api.views.custom_button.CustomButtonView.destroy", - return_value=Response( - status=status.HTTP_204_NO_CONTENT, - ), - ): - response = client.delete(url, format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == expected_status - - -@pytest.mark.django_db -def test_get_custom_button_from_other_team_with_flag( - make_organization_and_user_with_plugin_token, - make_team, - make_user_auth_headers, - make_custom_action, -): - organization, user, token = make_organization_and_user_with_plugin_token() - - team = make_team(organization) - - custom_button = make_custom_action(organization=organization, team=team) - client = APIClient() - - url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key}) - url = f"{url}?from_organization=true" - - response = client.get(url, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "dangerous_webhooks,webhook_url,expected_status", - [ - (True, URL_WITH_TLD, status.HTTP_201_CREATED), - (True, URL_WITHOUT_TLD, status.HTTP_201_CREATED), - (False, URL_WITH_TLD, status.HTTP_201_CREATED), - (False, URL_WITHOUT_TLD, status.HTTP_400_BAD_REQUEST), - ], -) -def test_url_without_tld_custom_button( - custom_button_internal_api_setup, - make_user_auth_headers, - settings, - dangerous_webhooks, - webhook_url, - expected_status, -): - settings.DANGEROUS_WEBHOOKS_ENABLED = dangerous_webhooks - - user, token, _ = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button", - "webhook": webhook_url, - "team": None, - } - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == expected_status diff --git a/engine/apps/api/tests/test_escalation_policy.py b/engine/apps/api/tests/test_escalation_policy.py index 73601d7f..cd0b8e4c 100644 --- a/engine/apps/api/tests/test_escalation_policy.py +++ b/engine/apps/api/tests/test_escalation_policy.py @@ -713,7 +713,6 @@ def test_escalation_policy_can_not_create_with_non_step_type_related_data( (EscalationPolicy.STEP_NOTIFY_USERS_QUEUE, ["notify_to_users_queue"]), (EscalationPolicy.STEP_NOTIFY_IF_TIME, ["from_time", "to_time"]), (EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS, ["notify_to_users_queue"]), - (EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, ["custom_button_trigger"]), (EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, ["custom_webhook"]), ], ) @@ -753,7 +752,6 @@ def test_escalation_policy_update_drop_non_step_type_related_data( "notify_to_team_members", "from_time", "to_time", - "custom_button_trigger", "custom_webhook", ] for f in related_fields: @@ -804,7 +802,6 @@ def test_escalation_policy_switch_importance( "num_alerts_in_window": None, "num_minutes_in_window": None, "slack_integration_required": escalation_policy.slack_integration_required, - "custom_button_trigger": None, "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, @@ -862,7 +859,6 @@ def test_escalation_policy_filter_by_user( "num_alerts_in_window": None, "num_minutes_in_window": None, "slack_integration_required": False, - "custom_button_trigger": None, "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, @@ -880,7 +876,6 @@ def test_escalation_policy_filter_by_user( "num_alerts_in_window": None, "num_minutes_in_window": None, "slack_integration_required": False, - "custom_button_trigger": None, "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, @@ -946,7 +941,6 @@ def test_escalation_policy_filter_by_slack_channel( "num_alerts_in_window": None, "num_minutes_in_window": None, "slack_integration_required": False, - "custom_button_trigger": None, "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 839f6e58..e7cf9cba 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -7,7 +7,6 @@ from django.urls import reverse from django.utils import timezone from rest_framework import status from rest_framework.response import Response -from rest_framework.serializers import ValidationError from rest_framework.test import APIClient from apps.alerts.models import EscalationPolicy @@ -725,25 +724,6 @@ def test_create_web_schedule(schedule_internal_api_setup, make_user_auth_headers assert response.data == data -@pytest.mark.django_db -def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, _, _, _ = schedule_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - with patch( - "apps.api.serializers.schedule_ical.ScheduleICalSerializer.validate_ical_url_primary", - side_effect=ValidationError("Ical download failed"), - ): - data = { - "ical_url_primary": ICAL_URL, - "ical_url_overrides": None, - "name": "created_ical_schedule", - "type": 1, - } - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - @pytest.mark.django_db @pytest.mark.parametrize("calendar_type", [0, 2]) def test_create_schedule_invalid_time_zone(schedule_internal_api_setup, make_user_auth_headers, calendar_type): diff --git a/engine/apps/api/tests/test_team.py b/engine/apps/api/tests/test_team.py index d34de3a2..84af9908 100644 --- a/engine/apps/api/tests/test_team.py +++ b/engine/apps/api/tests/test_team.py @@ -340,7 +340,6 @@ def test_team_permissions_wrong_team( make_user, make_escalation_chain, make_schedule, - make_custom_action, make_token_for_organization, make_user_auth_headers, ): @@ -361,14 +360,12 @@ def test_team_permissions_wrong_team( escalation_chain = make_escalation_chain(organization, team=team_without_user) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team_without_user) - webhook = make_custom_action(organization, team=team_without_user) for endpoint, instance in ( ("alertgroup", alert_group), ("alert_receive_channel", alert_receive_channel), ("escalation_chain", escalation_chain), ("schedule", schedule), - ("custom_button", webhook), ): url = reverse(f"api-internal:{endpoint}-detail", kwargs={"pk": instance.public_primary_key}) @@ -387,7 +384,6 @@ def test_team_permissions_not_in_team( make_user, make_escalation_chain, make_schedule, - make_custom_action, make_token_for_organization, make_user_auth_headers, ): @@ -410,14 +406,12 @@ def test_team_permissions_not_in_team( escalation_chain = make_escalation_chain(organization, team=team) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team) - webhook = make_custom_action(organization, team=team) for endpoint, instance in ( ("alertgroup", alert_group), ("alert_receive_channel", alert_receive_channel), ("escalation_chain", escalation_chain), ("schedule", schedule), - ("custom_button", webhook), ): url = reverse(f"api-internal:{endpoint}-detail", kwargs={"pk": instance.public_primary_key}) @@ -447,7 +441,6 @@ def test_team_permissions_right_team( make_user, make_escalation_chain, make_schedule, - make_custom_action, make_token_for_organization, make_user_auth_headers, ): @@ -472,14 +465,12 @@ def test_team_permissions_right_team( escalation_chain = make_escalation_chain(organization, team=team) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team) - webhook = make_custom_action(organization, team=team) for endpoint, instance in ( ("alertgroup", alert_group), ("alert_receive_channel", alert_receive_channel), ("escalation_chain", escalation_chain), ("schedule", schedule), - ("custom_button", webhook), ("user", another_user), ): url = reverse(f"api-internal:{endpoint}-detail", kwargs={"pk": instance.public_primary_key}) diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 80137dc0..41df5f17 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -9,7 +9,6 @@ from .views.alert_receive_channel import AlertReceiveChannelView from .views.alert_receive_channel_template import AlertReceiveChannelTemplateView from .views.alerts import AlertDetailView from .views.channel_filter import ChannelFilterView -from .views.custom_button import CustomButtonView from .views.escalation_chain import EscalationChainViewSet from .views.escalation_policy import EscalationPolicyView from .views.features import FeaturesAPIView @@ -57,7 +56,6 @@ router.register( ) router.register(r"channel_filters", ChannelFilterView, basename="channel_filter") router.register(r"schedules", ScheduleView, basename="schedule") -router.register(r"custom_buttons", CustomButtonView, basename="custom_button") router.register(r"webhooks", WebhooksView, basename="webhooks") router.register(r"resolution_notes", ResolutionNoteView, basename="resolution_note") router.register(r"telegram_channels", TelegramChannelViewSet, basename="telegram_channel") diff --git a/engine/apps/api/views/custom_button.py b/engine/apps/api/views/custom_button.py deleted file mode 100644 index 9a65558a..00000000 --- a/engine/apps/api/views/custom_button.py +++ /dev/null @@ -1,125 +0,0 @@ -from django.core.exceptions import ObjectDoesNotExist -from django_filters import rest_framework as filters -from rest_framework.decorators import action -from rest_framework.exceptions import NotFound -from rest_framework.filters import SearchFilter -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet - -from apps.alerts.models import CustomButton -from apps.api.permissions import RBACPermission -from apps.api.serializers.custom_button import CustomButtonSerializer -from apps.auth_token.auth import PluginAuthentication -from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter -from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin -from common.insight_log import EntityEvent, write_resource_insight_log - - -class CustomButtonFilter(ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet): - team = TeamModelMultipleChoiceFilter() - - -class CustomButtonView(TeamFilteringMixin, PublicPrimaryKeyMixin[CustomButton], ModelViewSet): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, RBACPermission) - - rbac_permissions = { - "metadata": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], - "filters": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], - "list": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], - "retrieve": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], - "create": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], - "update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], - "partial_update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], - "destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], - } - - model = CustomButton - serializer_class = CustomButtonSerializer - - filter_backends = [SearchFilter, filters.DjangoFilterBackend] - search_fields = ["public_primary_key", "name"] - filterset_class = CustomButtonFilter - - def get_queryset(self, ignore_filtering_by_available_teams=False): - queryset = CustomButton.objects.filter( - organization=self.request.auth.organization, - ) - if not ignore_filtering_by_available_teams: - queryset = queryset.filter(*self.available_teams_lookup_args).distinct() - - return queryset - - def get_object(self): - # get the object from the whole organization if there is a flag `get_from_organization=true` - # otherwise get the object from the current team - get_from_organization = self.request.query_params.get("from_organization", "false") == "true" - if get_from_organization: - return self.get_object_from_organization() - return super().get_object() - - def get_object_from_organization(self): - # use this method to get the object from the whole organization instead of the current team - pk = self.kwargs["pk"] - organization = self.request.auth.organization - try: - obj = ( - organization.custom_buttons.filter(*self.available_teams_lookup_args) - .distinct() - .get(public_primary_key=pk) - ) - except ObjectDoesNotExist: - raise NotFound - - # May raise a permission denied - self.check_object_permissions(self.request, obj) - - return obj - - def perform_create(self, serializer): - serializer.save() - write_resource_insight_log( - instance=serializer.instance, - author=self.request.user, - event=EntityEvent.CREATED, - ) - - def perform_update(self, serializer): - prev_state = serializer.instance.insight_logs_serialized - serializer.save() - new_state = serializer.instance.insight_logs_serialized - write_resource_insight_log( - instance=serializer.instance, - author=self.request.user, - event=EntityEvent.UPDATED, - prev_state=prev_state, - new_state=new_state, - ) - - def perform_destroy(self, instance): - write_resource_insight_log( - instance=instance, - author=self.request.user, - event=EntityEvent.DELETED, - ) - instance.delete() - - @action(methods=["get"], detail=False) - def filters(self, request): - filter_name = request.query_params.get("search", None) - api_root = "/api/internal/v1/" - - filter_options = [ - { - "name": "team", - "type": "team_select", - "href": api_root + "teams/", - "global": True, - }, - ] - - if filter_name is not None: - filter_options = list(filter(lambda f: filter_name in f["name"], filter_options)) - - return Response(filter_options) diff --git a/engine/apps/api/views/escalation_policy.py b/engine/apps/api/views/escalation_policy.py index 47bcbe2f..beda08d1 100644 --- a/engine/apps/api/views/escalation_policy.py +++ b/engine/apps/api/views/escalation_policy.py @@ -118,9 +118,6 @@ class EscalationPolicyView( def escalation_options(self, request): choices = [] for step in EscalationPolicy.INTERNAL_API_STEPS: - if step == EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: - continue - verbal = EscalationPolicy.INTERNAL_API_STEPS_TO_VERBAL_MAP[step] can_change_importance = ( step in EscalationPolicy.IMPORTANT_STEPS_SET or step in EscalationPolicy.DEFAULT_STEPS_SET diff --git a/engine/apps/public_api/serializers/escalation_policies.py b/engine/apps/public_api/serializers/escalation_policies.py index d32d0ebb..34177855 100644 --- a/engine/apps/public_api/serializers/escalation_policies.py +++ b/engine/apps/public_api/serializers/escalation_policies.py @@ -36,15 +36,6 @@ class EscalationPolicyTypeField(fields.CharField): return step_type -class WebhookTransitionField(OrganizationFilteredPrimaryKeyRelatedField): - def get_attribute(self, instance): - value = super().get_attribute(instance) - if value is None: - # fallback to the custom button old value - value = instance.custom_button_trigger - return value - - class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") escalation_chain_id = OrganizationFilteredPrimaryKeyRelatedField( @@ -76,7 +67,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer): source="notify_to_group", filter_field="slack_team_identity__organizations", ) - action_to_trigger = WebhookTransitionField( + action_to_trigger = OrganizationFilteredPrimaryKeyRelatedField( queryset=Webhook.objects, required=False, source="custom_webhook", @@ -99,6 +90,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer): "type", "duration", "important", + "action_to_trigger", "persons_to_notify", "team_to_notify", "persons_to_notify_next_each_time", @@ -190,7 +182,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer): fields_to_remove.remove("persons_to_notify_next_each_time") elif step in [EscalationPolicy.STEP_NOTIFY_GROUP, EscalationPolicy.STEP_NOTIFY_GROUP_IMPORTANT]: fields_to_remove.remove("group_to_notify") - elif step in (EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK): + elif step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: fields_to_remove.remove("action_to_trigger") elif step == EscalationPolicy.STEP_NOTIFY_IF_TIME: fields_to_remove.remove("notify_if_time_from") @@ -216,7 +208,6 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer): "notify_schedule", "notify_to_group", "notify_to_team_members", - "custom_button_trigger", "custom_webhook", "from_time", "to_time", @@ -226,7 +217,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer): step = validated_data.get("step") important = validated_data.pop("important", None) - if step == EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON and validated_data.get("custom_webhook"): + if step == EscalationPolicy._DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON and validated_data.get("custom_webhook"): # migrate step to webhook step = validated_data["step"] = EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK @@ -244,8 +235,6 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer): validated_data_fields_to_remove.remove("notify_to_group") elif step in [EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS, EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT]: validated_data_fields_to_remove.remove("notify_to_team_members") - elif step == EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: - validated_data_fields_to_remove.remove("custom_button_trigger") elif step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: validated_data_fields_to_remove.remove("custom_webhook") elif step == EscalationPolicy.STEP_NOTIFY_IF_TIME: @@ -302,8 +291,6 @@ class EscalationPolicyUpdateSerializer(EscalationPolicySerializer): instance.notify_to_team_members = None if step not in [EscalationPolicy.STEP_NOTIFY_GROUP, EscalationPolicy.STEP_NOTIFY_GROUP_IMPORTANT]: instance.notify_to_group = None - if step != EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: - instance.custom_button_trigger = None if step != EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: instance.custom_webhook = None if step != EscalationPolicy.STEP_NOTIFY_IF_TIME: diff --git a/engine/apps/public_api/tests/test_escalation_policies.py b/engine/apps/public_api/tests/test_escalation_policies.py index 078d0d15..da2a5271 100644 --- a/engine/apps/public_api/tests/test_escalation_policies.py +++ b/engine/apps/public_api/tests/test_escalation_policies.py @@ -322,7 +322,7 @@ def test_create_escalation_policy_using_webhooks( data_for_create = { "escalation_chain_id": escalation_chain.public_primary_key, - "type": "trigger_action", + "type": "trigger_webhook", "position": 0, "action_to_trigger": webhook.public_primary_key, } @@ -338,88 +338,6 @@ def test_create_escalation_policy_using_webhooks( assert response.data == serializer.data -@pytest.mark.django_db -def test_retrieve_escalation_policy_using_button( - make_organization_and_user_with_token, - make_custom_action, - escalation_policies_setup, -): - organization, user, token = make_organization_and_user_with_token() - action = make_custom_action(organization) - escalation_chain, _, _ = escalation_policies_setup(organization, user) - - escalation_policy_action = escalation_chain.escalation_policies.create( - step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, - custom_button_trigger=action, - ) - - client = APIClient() - url = reverse("api-public:escalation_policies-detail", kwargs={"pk": escalation_policy_action.public_primary_key}) - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - - escalation_policy = EscalationPolicy.objects.get(public_primary_key=response.data["id"]) - serializer = EscalationPolicySerializer(escalation_policy) - assert response.data == serializer.data - assert response.data["action_to_trigger"] == action.public_primary_key - - -@pytest.mark.django_db -def test_update_escalation_policy_using_button_disabled( - make_organization_and_user_with_token, - make_custom_action, - escalation_policies_setup, -): - organization, user, token = make_organization_and_user_with_token() - action = make_custom_action(organization) - other_action = make_custom_action(organization) - escalation_chain, _, _ = escalation_policies_setup(organization, user) - - escalation_policy_action = escalation_chain.escalation_policies.create( - step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, - custom_button_trigger=action, - ) - - client = APIClient() - data_to_change = {"action_to_trigger": other_action.public_primary_key} - url = reverse("api-public:escalation_policies-detail", kwargs={"pk": escalation_policy_action.public_primary_key}) - response = client.put(url, data=data_to_change, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.django_db -def test_update_escalation_policy_using_button_to_webhook( - make_organization_and_user_with_token, - make_custom_action, - make_custom_webhook, - escalation_policies_setup, -): - organization, user, token = make_organization_and_user_with_token() - action = make_custom_action(organization) - webhook = make_custom_webhook(organization) - escalation_chain, _, _ = escalation_policies_setup(organization, user) - - escalation_policy_action = escalation_chain.escalation_policies.create( - step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, - custom_button_trigger=action, - ) - - client = APIClient() - data_to_change = {"action_to_trigger": webhook.public_primary_key} - url = reverse("api-public:escalation_policies-detail", kwargs={"pk": escalation_policy_action.public_primary_key}) - response = client.put(url, data=data_to_change, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - - escalation_policy = EscalationPolicy.objects.get(public_primary_key=response.data["id"]) - serializer = EscalationPolicySerializer(escalation_policy) - assert response.data == serializer.data - # step is migrated - assert escalation_policy.step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK - - @pytest.mark.django_db @pytest.mark.parametrize( "value,expected_status", diff --git a/engine/apps/public_api/views/action.py b/engine/apps/public_api/views/action.py index 1dd81bbe..8513da1b 100644 --- a/engine/apps/public_api/views/action.py +++ b/engine/apps/public_api/views/action.py @@ -13,6 +13,11 @@ from common.insight_log import EntityEvent, write_resource_insight_log class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): + """ + This endpoint is deprecated and webhooks should be used instead. This view should remain in the + codebase in order to support terraform configurations that are still referencing it. + """ + authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) pagination_class = FiftyPageSizePaginator diff --git a/engine/apps/slack/representatives/alert_group_representative.py b/engine/apps/slack/representatives/alert_group_representative.py index 67fd4ce6..8cf1274c 100644 --- a/engine/apps/slack/representatives/alert_group_representative.py +++ b/engine/apps/slack/representatives/alert_group_representative.py @@ -278,11 +278,6 @@ class AlertGroupSlackRepresentative(AlertGroupAbstractRepresentative): step = AcknowledgeConfirmationStep(self.log_record.alert_group.channel.organization.slack_team_identity) step.process_signal(self.log_record) - def on_custom_button_triggered(self): - CustomButtonProcessStep = ScenarioStep.get_step("distribute_alerts", "CustomButtonProcessStep") - step = CustomButtonProcessStep(self.log_record.alert_group.channel.organization.slack_team_identity) - step.process_signal(self.log_record) - def on_wiped(self): WipeGroupStep = ScenarioStep.get_step("distribute_alerts", "WipeGroupStep") step = WipeGroupStep(self.log_record.alert_group.channel.organization.slack_team_identity) diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 39917d22..3ba16351 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -1,19 +1,15 @@ import json import logging import typing -from contextlib import suppress from datetime import datetime from django.core.cache import cache from django.utils import timezone -from jinja2 import TemplateError from apps.alerts.constants import ActionSource from apps.alerts.incident_appearance.renderers.constants import DEFAULT_BACKUP_TITLE from apps.alerts.incident_appearance.renderers.slack_renderer import AlertSlackRenderer from apps.alerts.models import Alert, AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, Invitation -from apps.alerts.tasks import custom_button_result -from apps.alerts.utils import render_curl_command from apps.api.permissions import RBACPermission from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME from apps.slack.errors import ( @@ -565,69 +561,6 @@ class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): self.alert_group_slack_service.update_alert_group_slack_message(log_record.invitation.alert_group) -class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] - - def process_scenario( - self, - slack_user_identity: "SlackUserIdentity", - slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, - ) -> None: - from apps.alerts.models import CustomButton - - alert_group = self.get_alert_group(slack_team_identity, payload) - if not self.is_authorized(alert_group): - self.open_unauthorized_warning(payload) - return - - custom_button_pk = payload["actions"][0]["name"].split("_")[1] - alert_group_pk = payload["actions"][0]["name"].split("_")[2] - try: - CustomButton.objects.get(pk=custom_button_pk) - except CustomButton.DoesNotExist: - warning_text = "Oops! This button was deleted" - self.open_warning_window(payload, warning_text=warning_text) - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) - else: - custom_button_result.apply_async( - args=( - custom_button_pk, - alert_group_pk, - ), - kwargs={"user_pk": self.user.pk}, - ) - - def process_signal(self, log_record: AlertGroupLogRecord) -> None: - alert_group = log_record.alert_group - result_message = log_record.reason - custom_button = log_record.custom_button - debug_message = "" - if not log_record.step_specific_info["is_request_successful"]: - with suppress(TemplateError, json.JSONDecodeError): - post_kwargs = custom_button.build_post_kwargs(log_record.alert_group.alerts.first()) - curl_request = render_curl_command(log_record.custom_button.webhook, "POST", post_kwargs) - debug_message = f"```{curl_request}```" - - if log_record.author is not None: - user_verbal = log_record.author.get_username_with_slack_verbal(mention=True) - text = ( - f"{user_verbal} sent a request from an outgoing webhook `{log_record.custom_button.name}` " - f"with the result `{result_message}`" - ) - else: - text = ( - f"A request from an outgoing webhook `{log_record.custom_button.name}` was sent " - f"according to escalation policy with the result `{result_message}`" - ) - attachments = [ - {"callback_id": "alert", "text": debug_message}, - ] - self.alert_group_slack_service.publish_message_to_alert_group_thread( - alert_group, attachments=attachments, text=text - ) - - class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] @@ -1162,10 +1095,4 @@ STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ "action_name": StopInvitationProcess.routing_uid(), "step": StopInvitationProcess, }, - { - "payload_type": PayloadType.INTERACTIVE_MESSAGE, - "action_type": InteractiveMessageActionType.BUTTON, - "action_name": CustomButtonProcessStep.routing_uid(), - "step": CustomButtonProcessStep, - }, ] diff --git a/engine/apps/webhooks/migrations/0008_auto_20230712_1613.py b/engine/apps/webhooks/migrations/0008_auto_20230712_1613.py index 266d1810..b55b37e6 100644 --- a/engine/apps/webhooks/migrations/0008_auto_20230712_1613.py +++ b/engine/apps/webhooks/migrations/0008_auto_20230712_1613.py @@ -11,6 +11,7 @@ LEGACY_SUFFIX = " (Legacy)" logger = logging.getLogger(__name__) + def convert_custom_button_to_webhook(apps, schema_editor): CustomButton = apps.get_model("alerts", "CustomButton") Webhooks = apps.get_model("webhooks", "Webhook") @@ -36,10 +37,10 @@ def convert_custom_button_to_webhook(apps, schema_editor): ) # migrate related escalation policies EscalationPolicies.objects.filter( - step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, + step=EscalationPolicy._DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON, custom_button_trigger=cb, ).update( - step=EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, + step=EscalationPolicy._DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON, custom_webhook=webhook, ) @@ -60,7 +61,7 @@ def undo_custom_button_to_webhook(apps, schema_editor): step=EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, custom_webhook=webhook, ).update( - step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, + step=EscalationPolicy._DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON, custom_button_trigger=cb, custom_webhook=None, ) diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index 264c4319..99da8047 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -251,8 +251,7 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id, t # create log record error_code = None - # reuse existing webhooks record type (TODO: rename after migration) - log_type = AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED + log_type = AlertGroupLogRecord.TYPE_CUSTOM_WEBHOOK_TRIGGERED reason = str(status["status_code"]) if error is not None: log_type = AlertGroupLogRecord.TYPE_ESCALATION_FAILED diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 059b153c..b2413f1f 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -249,7 +249,7 @@ def test_execute_webhook_ok( assert log.url == templated_url # check log record log_record = alert_group.log_records.last() - assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED + assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_WEBHOOK_TRIGGERED expected_info = { "trigger": "acknowledge", "webhook_id": webhook.public_primary_key, @@ -306,7 +306,7 @@ def test_execute_webhook_via_escalation_ok( assert mock_requests.post.called # check log record log_record = alert_group.log_records.last() - assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED + assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_WEBHOOK_TRIGGERED expected_info = { "trigger": "escalation", "webhook_id": webhook.public_primary_key, diff --git a/engine/apps/webhooks/utils.py b/engine/apps/webhooks/utils.py index c7efa1cf..f0128637 100644 --- a/engine/apps/webhooks/utils.py +++ b/engine/apps/webhooks/utils.py @@ -85,10 +85,10 @@ def escape_string(string: str): class EscapeDoubleQuotesDict(dict): """ - Warning: Please, do not use this dict anywhere except CustomButton._escape_alert_payload. - This custom dict escapes double quotes to produce string which is safe to pass to json.loads() - It fixes case when CustomButton.build_post_kwargs failing on payloads which contains string with single quote. - In this case built-in dict's str method will surround value with double quotes. + Warning: Please, do not use this dict anywhere except `apps.webhooks.utils.escape_payload`. + This custom dict escapes double quotes to produce string which is safe to pass to `json.loads()` + It fixes issues originating from payloads which contains strings with single quote. + In this case, built-in `dict`'s `str` method will surround value with double quotes. For example: diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index 0ec76200..a5bf8fd7 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -5,9 +5,7 @@ from urllib.parse import urljoin import requests from django.conf import settings -from django.core.validators import URLValidator from django.utils import dateparse, timezone -from django.utils.regex_helper import _lazy_re_compile from icalendar import Calendar from rest_framework import serializers from rest_framework.request import Request @@ -52,34 +50,6 @@ class CurrentTeamDefault: return "%s()" % self.__class__.__name__ -class URLValidatorWithoutTLD(URLValidator): - """ - Overrides Django URLValidator Regex. It removes the tld part because - most of the time, containers don't have any TLD in their urls and such outgoing webhooks - can't be registered. - """ - - host_re = ( - "(" - + URLValidator.hostname_re - + URLValidator.domain_re - + URLValidator.tld_re - + "|" - + URLValidator.hostname_re - + "|localhost)" - ) - - regex = _lazy_re_compile( - r"^(?:[a-z0-9.+-]*)://" # scheme is validated separately - r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?" # user:pass authentication - r"(?:" + URLValidator.ipv4_re + "|" + URLValidator.ipv6_re + "|" + host_re + ")" - r"(?::[0-9]{1,5})?" # port - r"(?:[/?#][^\s]*)?" # resource path - r"\Z", - re.IGNORECASE, - ) - - class CurrentUserDefault: """ Utility class to get the current user right from the serializer field. diff --git a/engine/common/tests/test_urlvalidator_without_tld.py b/engine/common/tests/test_urlvalidator_without_tld.py deleted file mode 100644 index 7df900cd..00000000 --- a/engine/common/tests/test_urlvalidator_without_tld.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest -from django.core.validators import ValidationError - -from common.api_helpers.utils import URLValidatorWithoutTLD - -valid_urls = ["https://www.google.com", "https://www.google", "http://conatainer1"] -invalid_urls = ["https:/www.google.com", "htt://www.google.com/"] - - -@pytest.mark.parametrize("url", valid_urls) -def test_urlvalidator_without_tld_valid_urls(url): - # Test valid URLs - URLValidatorWithoutTLD()(url) - - -@pytest.mark.parametrize("url", invalid_urls) -def test_urlvalidator_without_tld_invalid_urls(url): - # Test an invalid URL - with pytest.raises(ValidationError): - URLValidatorWithoutTLD()(url) diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index 1145feaa..b349cf2e 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -177,7 +177,6 @@ CELERY_TASK_ROUTES = { "apps.telegram.tasks.send_log_and_actions_message": {"queue": "telegram"}, "apps.telegram.tasks.on_alert_group_action_triggered_async": {"queue": "telegram"}, # WEBHOOK - "apps.alerts.tasks.custom_button_result.custom_button_result": {"queue": "webhook"}, "apps.alerts.tasks.custom_webhook_result.custom_webhook_result": {"queue": "webhook"}, "apps.webhooks.tasks.trigger_webhook.execute_webhook": {"queue": "webhook"}, "apps.webhooks.tasks.trigger_webhook.send_webhook_event": {"queue": "webhook"}, diff --git a/grafana-plugin/e2e-tests/escalationChains/escalationPolicy.test.ts b/grafana-plugin/e2e-tests/escalationChains/escalationPolicy.test.ts index 0b178717..b0b9b8fc 100644 --- a/grafana-plugin/e2e-tests/escalationChains/escalationPolicy.test.ts +++ b/grafana-plugin/e2e-tests/escalationChains/escalationPolicy.test.ts @@ -1,15 +1,13 @@ import { Locator, expect, test } from '../fixtures'; -import { createEscalationChain, EscalationStep, selectEscalationStepValue } from '../utils/escalationChain'; +import { createEscalationChain, EscalationStep } from '../utils/escalationChain'; import { generateRandomValue } from '../utils/forms'; test('escalation policy does not go back to "Default" after adding users to notify', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; const escalationChainName = generateRandomValue(); - // create important escalation step - await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, null, true); - // add user to notify - await selectEscalationStepValue(page, EscalationStep.NotifyUsers, userName); + // create important escalation step + add user to notif + await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName, true); // reload and check if important is still selected await page.reload(); diff --git a/grafana-plugin/e2e-tests/outgoingWebhooks/createAdvancedWebhook.test.ts b/grafana-plugin/e2e-tests/outgoingWebhooks/advancedWebhook.test.ts similarity index 100% rename from grafana-plugin/e2e-tests/outgoingWebhooks/createAdvancedWebhook.test.ts rename to grafana-plugin/e2e-tests/outgoingWebhooks/advancedWebhook.test.ts diff --git a/grafana-plugin/e2e-tests/outgoingWebhooks/createSimpleWebhook.test.ts b/grafana-plugin/e2e-tests/outgoingWebhooks/createSimpleWebhook.test.ts deleted file mode 100644 index 6a4d2982..00000000 --- a/grafana-plugin/e2e-tests/outgoingWebhooks/createSimpleWebhook.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test } from '../fixtures'; -import { clickButton, generateRandomValue } from '../utils/forms'; -import { goToOnCallPage } from '../utils/navigation'; -import { checkWebhookPresenceInTable } from '../utils/outgoingWebhooks'; - -test('create simple webhook and check it is displayed on the list correctly', async ({ adminRolePage: { page } }) => { - const WEBHOOK_NAME = generateRandomValue(); - const WEBHOOK_URL = 'https://example.com'; - await goToOnCallPage(page, 'outgoing_webhooks'); - - await clickButton({ page, buttonText: 'New Outgoing Webhook' }); - - await page.getByText('Simple').first().click(); - - await page.waitForTimeout(2000); - - await page.keyboard.insertText(WEBHOOK_URL); - await page.locator('[name=name]').fill(WEBHOOK_NAME); - await page.getByLabel('New Outgoing Webhook').getByRole('img').nth(1).click(); // Open team dropdown - await page.getByLabel('Select options menu').getByText('No team').click(); - await clickButton({ page, buttonText: 'Create' }); - - await checkWebhookPresenceInTable({ page, webhookName: WEBHOOK_NAME, expectedTriggerType: 'Escalation step' }); -}); diff --git a/grafana-plugin/e2e-tests/outgoingWebhooks/simpleWebhook.test.ts b/grafana-plugin/e2e-tests/outgoingWebhooks/simpleWebhook.test.ts new file mode 100644 index 00000000..31c9da4a --- /dev/null +++ b/grafana-plugin/e2e-tests/outgoingWebhooks/simpleWebhook.test.ts @@ -0,0 +1,82 @@ +import express from 'express'; + +import { expect, test } from '../fixtures'; +import { EscalationStep, createEscalationChain } from '../utils/escalationChain'; +import { clickButton, generateRandomValue } from '../utils/forms'; +import { createIntegrationAndSendDemoAlert } from '../utils/integrations'; +import { goToOnCallPage } from '../utils/navigation'; +import { checkWebhookPresenceInTable } from '../utils/outgoingWebhooks'; + +const createWebhook = async ({ page, webhookName, webhookUrl }) => { + await goToOnCallPage(page, 'outgoing_webhooks'); + + await clickButton({ page, buttonText: 'New Outgoing Webhook' }); + + await page.getByText('Simple').first().click(); + + await page.waitForTimeout(2000); + + await page.keyboard.insertText(webhookUrl); + await page.locator('[name=name]').fill(webhookName); + await page.getByLabel('New Outgoing Webhook').getByRole('img').nth(1).click(); // Open team dropdown + await page.getByLabel('Select options menu').getByText('No team').click(); + await clickButton({ page, buttonText: 'Create' }); +}; + +test.describe('simple webhook', () => { + test('Create and check it is displayed on the list correctly', async ({ adminRolePage: { page } }) => { + const webhookName = generateRandomValue(); + await createWebhook({ page, webhookName, webhookUrl: 'https://example.com' }); + await checkWebhookPresenceInTable({ page, webhookName, expectedTriggerType: 'Escalation step' }); + }); + + /** + * TODO: will finalize this test in a separate PR. It passes locally but something about the networking on + * GitHub Actions causes the test to fail on CI + */ + test.skip('Create and check that our webhook actually receives the payload', async ({ adminRolePage: { page } }) => { + const escalationChainName = generateRandomValue(); + const integrationName = generateRandomValue(); + const PORT = 5050; + + /** + * This is a simple express server that listens for outgoing webhook requests + * The backend is able to communicate with it via host.docker.internal (docker -> host communication) + */ + let resolveRequest: (value: unknown) => void; + const requestPromise = new Promise((resolve) => { + resolveRequest = resolve; + }); + + const app = express(); + app.use(express.json()); + app.post('/', (req, res) => { + resolveRequest(req.body); // Resolve the promise with the request body + res.send('ok'); + }); + app.listen(PORT); + + /** + * TODO: this might need to be parametrized to be read from an env var + * rather than hardcoding the hostname to be host.docker.internal + */ + const webhookUrl = `http://host.docker.internal:${PORT}`; + const webhookName = generateRandomValue(); + + await createWebhook({ page, webhookName, webhookUrl }); + + await createEscalationChain(page, escalationChainName, EscalationStep.TriggerWebhook, webhookName); + await createIntegrationAndSendDemoAlert(page, integrationName, escalationChainName); + + /** + * Wait for the request to be received on our express server's endpoint handler + * when the request is received, the promise will be resolved w/ the request body + */ + const payload = await requestPromise; + + expect(payload.alert_group.state).toEqual('firing'); + expect(payload.alert_payload.message).toEqual('This alert was sent by user for demonstration purposes'); + expect(payload.integration.name).toEqual(`${integrationName} - Webhook`); + expect(payload.integration.type).toEqual('webhook'); + }); +}); diff --git a/grafana-plugin/e2e-tests/utils/escalationChain.ts b/grafana-plugin/e2e-tests/utils/escalationChain.ts index c7e5c772..a731b6bd 100644 --- a/grafana-plugin/e2e-tests/utils/escalationChain.ts +++ b/grafana-plugin/e2e-tests/utils/escalationChain.ts @@ -7,11 +7,13 @@ export enum EscalationStep { NotifyUsers = 'Notify users', NotifyUsersFromOnCallSchedule = 'Notify users from on-call schedule', ContinueEscalationIfCurrentUTCTimeIsIn = 'Continue escalation if current UTC time is in range', + TriggerWebhook = 'Trigger webhook', } const escalationStepValuePlaceholder: Partial> = { [EscalationStep.NotifyUsers]: 'Select User', [EscalationStep.NotifyUsersFromOnCallSchedule]: 'Select Schedule', + [EscalationStep.TriggerWebhook]: 'Select Webhook', }; export const createEscalationChain = async ( diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 66bc9c42..4a0d0489 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -61,6 +61,7 @@ "@testing-library/react": "14.0.0", "@testing-library/user-event": "^14.4.3", "@types/dompurify": "^2.3.4", + "@types/express": "^4.17.21", "@types/jest": "^29.5.0", "@types/lodash": "^4.14.194", "@types/lodash-es": "^4.17.6", @@ -87,6 +88,7 @@ "eslint-plugin-rulesdir": "^0.2.1", "eslint-plugin-unused-imports": "^3.1.0", "eslint-webpack-plugin": "^4.0.1", + "express": "^4.18.3", "fork-ts-checker-webpack-plugin": "^8.0.0", "glob": "^10.2.7", "identity-obj-proxy": "3.0.0", diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 9e9343db..f56be41c 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -3121,6 +3121,21 @@ dependencies: "@babel/types" "^7.3.0" +"@types/body-parser@*": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + "@types/d3-color@*": version "3.1.0" resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.0.tgz#6594da178ded6c7c3842f3cc0ac84b156f12f2d4" @@ -3174,6 +3189,26 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/express-serve-static-core@^4.17.33": + version "4.17.43" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz#10d8444be560cb789c4735aea5eac6e5af45df54" + integrity sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^4.17.21": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/fined@*": version "1.1.3" resolved "https://registry.yarnpkg.com/@types/fined/-/fined-1.1.3.tgz#83f03e8f0a8d3673dfcafb18fce3571f6250e1bc" @@ -3207,6 +3242,11 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + "@types/inquirer@^6.5.0": version "6.5.0" resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-6.5.0.tgz#b83b0bf30b88b8be7246d40e51d32fe9d10e09be" @@ -3318,6 +3358,16 @@ dependencies: "@types/unist" "*" +"@types/mime@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45" + integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw== + +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + "@types/minimatch@*": version "5.1.2" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" @@ -3365,6 +3415,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== +"@types/qs@*": + version "6.9.12" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.12.tgz#afa96b383a3a6fdc859453a1892d41b607fc7756" + integrity sha512-bZcOkJ6uWrL0Qb2NAWKa7TBU+mJHPzhx9jjLL1KHF+XpzEcR7EXHvjbHlGtR/IsP1vyPrehuS6XqkmaePy//mg== + "@types/query-string@^6.3.0": version "6.3.0" resolved "https://registry.yarnpkg.com/@types/query-string/-/query-string-6.3.0.tgz#b6fa172a01405abcaedac681118e78429d62ea39" @@ -3372,6 +3427,11 @@ dependencies: query-string "*" +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + "@types/react-copy-to-clipboard@^5.0.4": version "5.0.4" resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.4.tgz#558f2c38a97f53693e537815f6024f1e41e36a7e" @@ -3465,6 +3525,23 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== +"@types/send@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.5" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033" + integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ== + dependencies: + "@types/http-errors" "*" + "@types/mime" "*" + "@types/node" "*" + "@types/shimmer@^1.0.2": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.0.5.tgz#491d8984d4510e550bfeb02d518791d7f59d2b88" @@ -3911,6 +3988,14 @@ abab@^2.0.6: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + acorn-globals@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" @@ -4187,6 +4272,11 @@ array-each@^1.0.1: resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f" integrity sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA== +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + array-includes@^3.1.5: version "3.1.6" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.6.tgz#9e9e720e194f198266ba9e18c29e6a9b0e4b225f" @@ -4535,6 +4625,24 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +body-parser@1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + body@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069" @@ -4647,6 +4755,11 @@ bytes@1: resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" integrity sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ== +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -5144,6 +5257,18 @@ constant-case@^3.0.4: tslib "^2.0.3" upper-case "^2.0.2" +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + continuable-cache@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" @@ -5159,6 +5284,16 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -5631,6 +5766,13 @@ debounce@^1.2.1: resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== +debug@2.6.9, debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -5638,13 +5780,6 @@ debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, d dependencies: ms "2.1.2" -debug@^2.2.0, debug@^2.3.3: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -5816,6 +5951,16 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + detect-file@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" @@ -5988,6 +6133,11 @@ easy-table@1.2.0: optionalDependencies: wcwidth "^1.0.1" +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + electron-to-chromium@^1.4.251: version "1.4.284" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz#61046d1e4cab3a25238f6bf7413795270f125592" @@ -6030,6 +6180,11 @@ encode-registry@^3.0.1: dependencies: mem "^8.0.0" +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -6307,6 +6462,11 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -6690,6 +6850,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + eventemitter3@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.0.tgz#084eb7f5b5388df1451e63f4c2aafd71b217ccb3" @@ -6793,6 +6958,43 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" +express@^4.18.3: + version "4.18.3" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.3.tgz#6870746f3ff904dee1819b82e4b51509afffb0d4" + integrity sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.2" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.5.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" @@ -6984,6 +7186,19 @@ filter-obj@^1.1.0: resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + find-cache-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-4.0.0.tgz#a30ee0448f81a3990708f6453633c733e2f6eec2" @@ -7124,6 +7339,11 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" @@ -7131,6 +7351,11 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + fs-extra@10.1.0, fs-extra@^10.0.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -7752,6 +7977,17 @@ htmlparser2@^3.10.0: inherits "^2.0.1" readable-stream "^3.1.1" +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-parser-js@>=0.5.1: version "0.5.8" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" @@ -7803,6 +8039,13 @@ i18next@^22.0.0: dependencies: "@babel/runtime" "^7.17.2" +iconv-lite@0.4.24, iconv-lite@^0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + iconv-lite@0.6, iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -7810,13 +8053,6 @@ iconv-lite@0.6, iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -iconv-lite@^0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - icss-utils@^5.0.0, icss-utils@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" @@ -7918,7 +8154,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@^2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -8013,6 +8249,11 @@ invariant@^2.2.1, invariant@^2.2.2, invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + is-absolute@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" @@ -9691,6 +9932,11 @@ mdn-data@2.0.14: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + mem@^6.0.1: version "6.1.1" resolved "https://registry.yarnpkg.com/mem/-/mem-6.1.1.tgz#ea110c2ebc079eca3022e6b08c85a795e77f6318" @@ -9747,6 +9993,11 @@ meow@^9.0.0: type-fest "^0.18.0" yargs-parser "^20.2.3" +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -9757,6 +10008,11 @@ merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + micro-memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/micro-memoize/-/micro-memoize-4.1.2.tgz#ce719c1ba1e41592f1cd91c64c5f41dcbf135f36" @@ -9802,13 +10058,18 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@^2.1.27: +mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -9972,7 +10233,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: +ms@2.1.3, ms@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -10044,6 +10305,11 @@ ndjson@^2.0.0: split2 "^3.0.0" through2 "^4.0.0" +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + neo-async@^2.6.0, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -10433,6 +10699,13 @@ ol@^7.3.0: pbf "3.2.1" rbush "^3.0.1" +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -10698,6 +10971,11 @@ parse5@^7.0.0, parse5@^7.1.1: dependencies: entities "^4.4.0" +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + pascal-case@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-2.0.1.tgz#2d578d3455f660da65eca18ef95b4e0de912761e" @@ -10786,6 +11064,11 @@ path-temp@^2.1.0: dependencies: unique-string "^2.0.0" +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -11132,6 +11415,14 @@ protocol-buffers-schema@^3.3.1: resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03" integrity sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw== +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -11170,6 +11461,13 @@ qrcode.react@^3.1.0: resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8" integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q== +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + qs@^6.4.0: version "6.11.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" @@ -11241,6 +11539,21 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + raw-body@~1.1.0: version "1.1.7" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" @@ -12392,7 +12705,7 @@ safe-array-concat@^1.0.1, safe-array-concat@^1.1.0: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -12540,6 +12853,25 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + sentence-case@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/sentence-case/-/sentence-case-2.1.1.tgz#1f6e2dda39c168bf92d13f86d4a918933f667ed4" @@ -12571,6 +12903,16 @@ serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + set-function-length@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.0.tgz#2f81dc6c16c7059bda5ab7c82c11f03a515ed8e1" @@ -12628,6 +12970,11 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -13046,6 +13393,11 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" @@ -13633,6 +13985,11 @@ toggle-selection@^1.0.6: resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + totalist@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" @@ -13823,6 +14180,14 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + type-of@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/type-of/-/type-of-2.0.1.tgz#e72a1741896568e9f628378d816d6912f7f23972" @@ -14023,6 +14388,11 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + unset-value@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" @@ -14146,6 +14516,11 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + uuid@9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" @@ -14209,6 +14584,11 @@ value-equal@^1.0.1: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + version-selector-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/version-selector-type/-/version-selector-type-3.0.0.tgz#47c365fb4d9ca4a54e6dabcad6fb7a46265f7955"