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 <michael.derynck@grafana.com>
This commit is contained in:
Joey Orlando 2024-03-28 11:37:22 -04:00 committed by GitHub
parent b544a4360a
commit c5cd675738
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 562 additions and 1609 deletions

View file

@ -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

View file

@ -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. |

View file

@ -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 = {}

View file

@ -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",

View file

@ -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()

View file

@ -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:

View file

@ -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')]),
),
]

View file

@ -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:

View file

@ -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_<public_primary_key>
# 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('(?<!\\\\)"', '\\\\"', original_str)
return original_str

View file

@ -3,6 +3,7 @@ import datetime
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
from django_deprecate_fields import deprecate_field
from common.ordered_model.ordered_model import OrderedModel
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
@ -38,7 +39,7 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_IMPORTANT,
STEP_NOTIFY_GROUP_IMPORTANT,
STEP_NOTIFY_SCHEDULE_IMPORTANT,
STEP_TRIGGER_CUSTOM_BUTTON,
_DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON, # only here to keep range intact
STEP_NOTIFY_USERS_QUEUE,
STEP_NOTIFY_IF_TIME,
STEP_NOTIFY_MULTIPLE_USERS,
@ -61,7 +62,7 @@ class EscalationPolicy(OrderedModel):
(STEP_NOTIFY_IMPORTANT, "Notify User (Important)"),
(STEP_NOTIFY_GROUP_IMPORTANT, "Notify Group (Important)"),
(STEP_NOTIFY_SCHEDULE_IMPORTANT, "Notify Schedule (Important)"),
(STEP_TRIGGER_CUSTOM_BUTTON, "Trigger Outgoing Webhook"),
(_DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON, "Trigger Outgoing Webhook"),
(STEP_NOTIFY_USERS_QUEUE, "Notify User (next each time)"),
(STEP_NOTIFY_IF_TIME, "Continue escalation only if time is from"),
(STEP_NOTIFY_MULTIPLE_USERS, "Notify multiple Users"),
@ -85,7 +86,6 @@ class EscalationPolicy(OrderedModel):
STEP_FINAL_NOTIFYALL,
STEP_NOTIFY_GROUP,
# Other
STEP_TRIGGER_CUSTOM_BUTTON,
STEP_TRIGGER_CUSTOM_WEBHOOK,
STEP_NOTIFY_USERS_QUEUE,
STEP_NOTIFY_IF_TIME,
@ -109,7 +109,6 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
STEP_NOTIFY_MULTIPLE_USERS,
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
STEP_TRIGGER_CUSTOM_BUTTON,
STEP_TRIGGER_CUSTOM_WEBHOOK,
STEP_REPEAT_ESCALATION_N_TIMES,
]
@ -136,7 +135,6 @@ class EscalationPolicy(OrderedModel):
"Notify Slack User Group",
),
# Other
STEP_TRIGGER_CUSTOM_BUTTON: ("Trigger outgoing webhook {{custom_action}}", "Trigger outgoing webhook"),
STEP_TRIGGER_CUSTOM_WEBHOOK: ("Trigger webhook {{custom_webhook}}", "Trigger webhook"),
STEP_NOTIFY_USERS_QUEUE: ("Round robin notification for {{users}}", "Notify users one by one (round-robin)"),
STEP_NOTIFY_IF_TIME: (
@ -157,7 +155,6 @@ class EscalationPolicy(OrderedModel):
STEP_WAIT,
STEP_FINAL_NOTIFYALL,
STEP_FINAL_RESOLVE,
STEP_TRIGGER_CUSTOM_BUTTON,
STEP_TRIGGER_CUSTOM_WEBHOOK,
STEP_NOTIFY_USERS_QUEUE,
STEP_NOTIFY_IF_TIME,
@ -207,7 +204,6 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_GROUP,
STEP_FINAL_RESOLVE,
STEP_FINAL_NOTIFYALL,
STEP_TRIGGER_CUSTOM_BUTTON,
STEP_TRIGGER_CUSTOM_WEBHOOK,
STEP_NOTIFY_IF_TIME,
STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW,
@ -224,7 +220,6 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_IMPORTANT: "notify_one_person",
STEP_NOTIFY_SCHEDULE: "notify_on_call_from_schedule",
STEP_NOTIFY_SCHEDULE_IMPORTANT: "notify_on_call_from_schedule",
STEP_TRIGGER_CUSTOM_BUTTON: "trigger_action",
STEP_TRIGGER_CUSTOM_WEBHOOK: "trigger_webhook",
STEP_NOTIFY_USERS_QUEUE: "notify_person_next_each_time",
STEP_NOTIFY_MULTIPLE_USERS: "notify_persons",
@ -286,12 +281,15 @@ class EscalationPolicy(OrderedModel):
default=None,
)
custom_button_trigger = models.ForeignKey(
"alerts.CustomButton",
on_delete=models.CASCADE,
related_name="escalation_policies",
default=None,
null=True,
# TODO: remove this in a subsequent release
custom_button_trigger = deprecate_field(
models.ForeignKey(
"alerts.CustomButton",
on_delete=models.CASCADE,
related_name="escalation_policies",
default=None,
null=True,
)
)
custom_webhook = models.ForeignKey(
@ -405,12 +403,8 @@ class EscalationPolicy(OrderedModel):
if self.notify_schedule:
result["on-call_schedule"] = self.notify_schedule.insight_logs_verbal
result["on-call_schedule_id"] = self.notify_schedule.public_primary_key
elif self.step == EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON:
if self.custom_button_trigger:
result["outgoing_webhook"] = self.custom_button_trigger.insight_logs_verbal
result["outgoing_webhook_id"] = self.custom_button_trigger.public_primary_key
elif self.step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK:
if self.custom_button_trigger:
if self.custom_webhook:
result["outgoing_webhook"] = self.custom_webhook.insight_logs_verbal
result["outgoing_webhook_id"] = self.custom_webhook.public_primary_key
elif self.step in [

View file

@ -4,7 +4,6 @@ from .alert_group_web_title_cache import ( # noqa:F401
update_web_title_cache_for_alert_receive_channel,
)
from .check_escalation_finished import check_escalation_finished_task # noqa: F401
from .custom_button_result import custom_button_result # noqa: F401
from .custom_webhook_result import custom_webhook_result # noqa: F401
from .delete_alert_group import delete_alert_group # noqa: F401
from .delete_alert_group import finish_delete_alert_group # noqa: F401

View file

@ -1,84 +0,0 @@
import json
import logging
from functools import partial
from django.conf import settings
from django.db import transaction
from jinja2 import TemplateError
from apps.alerts.utils import request_outgoing_webhook
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from .send_alert_group_signal import send_alert_group_signal
from .task_logger import task_logger
logger = logging.getLogger(__name__)
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
)
def custom_button_result(custom_button_pk, alert_group_pk, user_pk=None, escalation_policy_pk=None):
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, CustomButton, EscalationPolicy
from apps.user_management.models import User
task_logger.debug(
f"Start custom_button_result for alert_group {alert_group_pk}, " f"custom_button {custom_button_pk}"
)
try:
custom_button = CustomButton.objects.get(pk=custom_button_pk)
except CustomButton.DoesNotExist:
task_logger.info(f"Custom_button {custom_button_pk} for alert_group {alert_group_pk} does not exist")
return
alert_group = AlertGroup.objects.filter(pk=alert_group_pk)[0]
escalation_policy = EscalationPolicy.objects.filter(pk=escalation_policy_pk).first()
task_logger.debug(
f"Start getting data for request in custom_button_result task for alert_group {alert_group_pk}, "
f"custom_button {custom_button_pk}"
)
first_alert = alert_group.alerts.first()
try:
post_kwargs = custom_button.build_post_kwargs(first_alert)
except TemplateError:
is_request_successful = False
result_message = "Template error"
except json.JSONDecodeError:
is_request_successful = False
result_message = "JSON decoding error"
else:
is_request_successful, result_message = request_outgoing_webhook(
custom_button.webhook, "POST", post_kwargs=post_kwargs
)
task_logger.debug(
f"Send post request in custom_button_result task for alert_group {alert_group_pk}, "
f"custom_button {custom_button_pk}"
)
with transaction.atomic():
user = None
if user_pk:
user = User.objects.get(pk=user_pk)
log_record = AlertGroupLogRecord(
type=AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED,
alert_group=alert_group,
custom_button=custom_button,
author=user,
reason=result_message,
step_specific_info={
"custom_button_name": custom_button.name,
"is_request_successful": is_request_successful,
},
escalation_policy=escalation_policy,
escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON,
)
log_record.save()
task_logger.debug(
f"call send_alert_group_signal for alert_group {alert_group_pk}, "
f"log record {log_record.pk} with type '{log_record.get_type_display()}'"
)
transaction.on_commit(partial(send_alert_group_signal.apply_async, (log_record.pk,)))
task_logger.debug(f"Finish custom_button_result for alert_group {alert_group_pk}, custom_button {custom_button_pk}")

View file

@ -1,61 +0,0 @@
import pytest
@pytest.mark.django_db
def test_escaping_payload_with_double_quotes(
make_organization,
make_custom_action,
make_alert_receive_channel,
make_alert_group,
make_alert,
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
alert_payload = {
"text": '"Hello world"',
}
alert = make_alert(alert_group=alert_group, raw_request_data=alert_payload)
custom_button = make_custom_action(
name="github_button",
webhook="https://github.com/",
user="Chris Vanstras",
password="qwerty",
data='{\n "text" : "{{ alert_payload.text }}"\n}',
authorization_header="auth_token",
organization=organization,
)
custom_button.build_post_kwargs(alert)
@pytest.mark.django_db
def test_escaping_payload_with_single_quote_in_string(
make_organization,
make_custom_action,
make_alert_receive_channel,
make_alert_group,
make_alert,
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
alert_payload = {
"text": "Hi, it's alert",
}
alert = make_alert(alert_group=alert_group, raw_request_data=alert_payload)
custom_button = make_custom_action(
name="github_button",
webhook="https://github.com/",
user="Chris Vanstras",
password="qwerty",
data='{"data" : "{{ alert_payload }}"}',
authorization_header="auth_token",
organization=organization,
)
custom_button.build_post_kwargs(alert)

View file

@ -448,37 +448,6 @@ def test_escalation_step_notify_if_num_alerts_in_window_deleted_escalation_polic
).exists()
@patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None)
@pytest.mark.django_db
def test_escalation_step_trigger_custom_button(
mocked_execute_tasks,
escalation_step_test_setup,
make_custom_action,
make_escalation_policy,
):
organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup
custom_button = make_custom_action(organization=organization)
trigger_custom_button_step = make_escalation_policy(
escalation_chain=channel_filter.escalation_chain,
escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON,
custom_button_trigger=custom_button,
)
escalation_policy_snapshot = get_escalation_policy_snapshot_from_model(trigger_custom_button_step)
expected_eta = timezone.now() + timezone.timedelta(seconds=NEXT_ESCALATION_DELAY)
result = escalation_policy_snapshot.execute(alert_group, reason)
expected_result = EscalationPolicySnapshot.StepExecutionResultData(
eta=result.eta,
stop_escalation=False,
pause_escalation=False,
start_from_beginning=False,
)
assert expected_eta + timezone.timedelta(seconds=15) > 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,

View file

@ -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,

View file

@ -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"

View file

@ -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"

View file

@ -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",

View file

@ -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"]

View file

@ -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,
]

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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):

View file

@ -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})

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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,
},
]

View file

@ -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,
)

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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.

View file

@ -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)

View file

@ -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"},

View file

@ -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();

View file

@ -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' });
});

View file

@ -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<any>((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');
});
});

View file

@ -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<Record<EscalationStep, string>> = {
[EscalationStep.NotifyUsers]: 'Select User',
[EscalationStep.NotifyUsersFromOnCallSchedule]: 'Select Schedule',
[EscalationStep.TriggerWebhook]: 'Select Webhook',
};
export const createEscalationChain = async (

View file

@ -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",

View file

@ -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"