Skip to content

Commit

Permalink
fix(2460) Add webhook test button
Browse files Browse the repository at this point in the history
Test cases
  • Loading branch information
ravishankar15 committed Apr 19, 2024
1 parent baef4e2 commit 17e552c
Show file tree
Hide file tree
Showing 11 changed files with 258 additions and 5 deletions.
3 changes: 3 additions & 0 deletions engine/apps/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ class Permissions:
OUTGOING_WEBHOOKS_READ = LegacyAccessControlCompatiblePermission(
Resources.OUTGOING_WEBHOOKS, Actions.READ, LegacyAccessControlRole.VIEWER
)
OUTGOING_WEBHOOKS_TEST = LegacyAccessControlCompatiblePermission(
Resources.OUTGOING_WEBHOOKS, Actions.TEST, LegacyAccessControlRole.EDITOR
)
OUTGOING_WEBHOOKS_WRITE = LegacyAccessControlCompatiblePermission(
Resources.OUTGOING_WEBHOOKS, Actions.WRITE, LegacyAccessControlRole.ADMIN
)
Expand Down
47 changes: 47 additions & 0 deletions engine/apps/api/tests/test_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1079,3 +1079,50 @@ def test_team_not_updated_if_not_in_data(

webhook.refresh_from_db()
assert webhook.team == team

@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_403_FORBIDDEN),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
def test_webhook_test_webhook_permissions(
make_organization_and_user_with_plugin_token,
make_custom_webhook,
make_user_auth_headers,
role,
expected_status,
):
organization, user, token = make_organization_and_user_with_plugin_token(role)
webhook = make_custom_webhook(organization=organization)
client = APIClient()

url = reverse("api-internal:webhooks-test-webhook", kwargs={"pk": webhook.public_primary_key})
data = {}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))

assert response.status_code == expected_status


@pytest.mark.django_db
def test_webhook_test_webhook_incorrect_payload(
make_organization_and_user_with_plugin_token,
make_custom_webhook,
make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook = make_custom_webhook(organization=organization)
client = APIClient()

url = reverse("api-internal:webhooks-test-webhook", kwargs={"pk": webhook.public_primary_key})
data = {
"webhook_test_payload": "teste"
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()['detail'] == 'Payload for test webhook must be a valid json object'
1 change: 1 addition & 0 deletions engine/apps/api/throttlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .demo_alert_throttler import DemoAlertThrottler # noqa: F401
from .demo_webhook_throttler import DemoWebhookThrottler # noqa: F401
from .phone_verification_throttler import ( # noqa: F401
GetPhoneVerificationCodeThrottlerPerOrg,
GetPhoneVerificationCodeThrottlerPerUser,
Expand Down
6 changes: 6 additions & 0 deletions engine/apps/api/throttlers/demo_webhook_throttler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from rest_framework.throttling import UserRateThrottle


class DemoWebhookThrottler(UserRateThrottle):
scope = "test_webhook"
rate = "30/m"
17 changes: 17 additions & 0 deletions engine/apps/api/views/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
from apps.api.label_filtering import parse_label_query
from apps.api.permissions import RBACPermission
from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer
from apps.api.throttlers import DemoWebhookThrottler
from apps.api.views.labels import schedule_update_label_cache
from apps.auth_token.auth import PluginAuthentication
from apps.labels.utils import is_labels_feature_enabled
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.tasks.trigger_webhook import execute_test_webhook
from apps.webhooks.utils import apply_jinja_template_for_json
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
Expand Down Expand Up @@ -58,6 +60,7 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
"responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"preset_options": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"test_webhook": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_TEST],
}

model = Webhook
Expand Down Expand Up @@ -216,3 +219,17 @@ def preview_template(self, request, pk):
def preset_options(self, request):
result = [asdict(preset) for preset in WebhookPresetOptions.WEBHOOK_PRESET_CHOICES]
return Response(result)

@action(methods=["post"], detail=True, throttle_classes=[DemoWebhookThrottler])
def test_webhook(self, request, pk):
instance = self.get_object()
payload = request.data.get("webhook_test_payload", None)

if payload is not None and not isinstance(payload, dict):
raise BadRequest(detail="Payload for test webhook must be a valid json object")

if payload is None:
payload = WebhookPresetOptions.EXAMPLE_PAYLOAD

execute_test_webhook.apply_async((instance.pk, payload))
return Response(status=status.HTTP_200_OK)
46 changes: 46 additions & 0 deletions engine/apps/webhooks/presets/preset_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,52 @@ class WebhookPresetOptions:

WEBHOOK_PRESET_CHOICES = [webhook_preset.metadata for webhook_preset in WEBHOOK_PRESETS.values()]

EXAMPLE_PAYLOAD = {
"event": {"type": "resolve", "time": "2023-04-19T21:59:21.714058+00:00"},
"user": {"id": "UVMX6YI9VY9PV", "username": "admin", "email": "admin@localhost"},
"alert_group": {
"id": "I6HNZGUFG4K11",
"integration_id": "CZ7URAT4V3QF2",
"route_id": "RKHXJKVZYYVST",
"alerts_count": 1,
"state": "resolved",
"created_at": "2023-04-19T21:53:48.231148Z",
"resolved_at": "2023-04-19T21:59:21.714058Z",
"acknowledged_at": "2023-04-19T21:54:39.029347Z",
"title": "Incident",
"permalinks": {
"slack": None,
"telegram": None,
"web": "https://**********.grafana.net/a/grafana-oncall-app/alert-groups/I6HNZGUFG4K11",
},
},
"alert_group_id": "I6HNZGUFG4K11",
"alert_payload": {
"endsAt": "0001-01-01T00:00:00Z",
"labels": {"region": "eu-1", "alertname": "TestAlert"},
"status": "firing",
"startsAt": "2018-12-25T15:47:47.377363608Z",
"annotations": {"description": "This alert was sent by user for the demonstration purposes"},
"generatorURL": "",
},
"integration": {
"id": "CZ7URAT4V3QF2",
"type": "webhook",
"name": "Main Integration - Webhook",
"team": "Webhooks Demo",
},
"notified_users": [],
"users_to_be_notified": [],
"responses": {
"WHP936BM1GPVHQ": {
"id": "7Qw7TbPmzppRnhLvK3AdkQ",
"created_at": "15:53:50",
"status": "new",
"content": {"message": "Ticket created!", "region": "eu"},
}
},
}


@receiver(pre_save, sender=Webhook)
def listen_for_webhook_save(sender: Webhook, instance: Webhook, raw: bool, *args, **kwargs) -> None:
Expand Down
2 changes: 1 addition & 1 deletion engine/apps/webhooks/tasks/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
from .alert_group_status import alert_group_created, alert_group_status_change # noqa: F401
from .trigger_webhook import execute_webhook, send_webhook_event # noqa: F401
from .trigger_webhook import execute_test_webhook, execute_webhook, send_webhook_event # noqa: F401
25 changes: 23 additions & 2 deletions engine/apps/webhooks/tasks/trigger_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def mask_authorization_header(


def make_request(
webhook: Webhook, alert_group: AlertGroup, data: typing.Dict[str, typing.Any]
webhook: Webhook, alert_group: AlertGroup, data: typing.Dict[str, typing.Any], is_demo: bool = False
) -> typing.Tuple[bool, WebhookRequestStatus, typing.Optional[str], typing.Optional[Exception]]:
status: WebhookRequestStatus = {
"url": None,
Expand All @@ -162,7 +162,7 @@ def make_request(
preset.override_parameters_at_runtime(webhook)
masked_header_keys.extend(preset.get_masked_headers())

if not webhook.check_integration_filter(alert_group):
if not is_demo and not webhook.check_integration_filter(alert_group):
status["request_trigger"] = NOT_FROM_SELECTED_INTEGRATION
return False, status, None, None

Expand Down Expand Up @@ -300,3 +300,24 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id, t
logger.warning(f"Exhausted execute_webhook retries for {msg_details}")
elif exception:
raise exception


@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else EXECUTE_WEBHOOK_RETRIES
)
def execute_test_webhook(webhook_pk, payload):
from apps.webhooks.models import Webhook

webhook = None
try:
webhook = Webhook.objects.get(pk=webhook_pk)
except Webhook.DoesNotExist:
logger.warning(f"Webhook {webhook_pk} does not exist")
return

triggered, _, error, exception = make_request(webhook, None, payload, True)

if not triggered:
logger.warning(f"Test Webhook {webhook_pk} trigger failed error={error}, exception={exception}")
else:
logger.debug(f"Test Webhook {webhook_pk} triggered successfully")
110 changes: 109 additions & 1 deletion engine/apps/webhooks/tests/test_trigger_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from apps.base.models import UserNotificationPolicyLogRecord
from apps.public_api.serializers import IncidentSerializer
from apps.webhooks.models import Webhook
from apps.webhooks.tasks import execute_webhook, send_webhook_event
from apps.webhooks.tasks import execute_webhook, send_webhook_event, execute_test_webhook
from apps.webhooks.tasks.trigger_webhook import NOT_FROM_SELECTED_INTEGRATION
from settings.base import WEBHOOK_RESPONSE_LIMIT

Expand Down Expand Up @@ -176,6 +176,51 @@ def test_execute_webhook_integration_filter_matching(


ALERT_GROUP_PUBLIC_PRIMARY_KEY = "IXJ47FKMYYJ5U"
EXAMPLE_PAYLOAD = {
"event": {"type": "resolve", "time": "2023-04-19T21:59:21.714058+00:00"},
"user": {"id": "UVMX6YI9VY9PV", "username": "admin", "email": "admin@localhost"},
"alert_group": {
"id": "I6HNZGUFG4K11",
"integration_id": "CZ7URAT4V3QF2",
"route_id": "RKHXJKVZYYVST",
"alerts_count": 1,
"state": "resolved",
"created_at": "2023-04-19T21:53:48.231148Z",
"resolved_at": "2023-04-19T21:59:21.714058Z",
"acknowledged_at": "2023-04-19T21:54:39.029347Z",
"title": "Incident",
"permalinks": {
"slack": None,
"telegram": None,
"web": "https://**********.grafana.net/a/grafana-oncall-app/alert-groups/I6HNZGUFG4K11",
},
},
"alert_group_id": "I6HNZGUFG4K11",
"alert_payload": {
"endsAt": "0001-01-01T00:00:00Z",
"labels": {"region": "eu-1", "alertname": "TestAlert"},
"status": "firing",
"startsAt": "2018-12-25T15:47:47.377363608Z",
"annotations": {"description": "This alert was sent by user for the demonstration purposes"},
"generatorURL": "",
},
"integration": {
"id": "CZ7URAT4V3QF2",
"type": "webhook",
"name": "Main Integration - Webhook",
"team": "Webhooks Demo",
},
"notified_users": [],
"users_to_be_notified": [],
"responses": {
"WHP936BM1GPVHQ": {
"id": "7Qw7TbPmzppRnhLvK3AdkQ",
"created_at": "15:53:50",
"status": "new",
"content": {"message": "Ticket created!", "region": "eu"},
}
},
}


@httpretty.activate(verbose=True, allow_net_connect=False)
Expand Down Expand Up @@ -270,6 +315,69 @@ def test_execute_webhook_ok(
assert log_record.escalation_policy_step is None
assert log_record.rendered_log_line_action() == f"outgoing webhook `{webhook.name}` triggered by acknowledge"

@httpretty.activate(verbose=True, allow_net_connect=False)
@pytest.mark.parametrize(
"data,expected_request_data,request_post_kwargs",
[
(
'{"value": "{{ alert_group_id }}"}',
{"value": "I6HNZGUFG4K11"},
{"json": {"value": "I6HNZGUFG4K11"}},
),
# test that non-latin characters are properly encoded
(
"😊",
"😊",
{"data": "😊".encode("utf-8")},
),
],
)
@pytest.mark.django_db
def test_execute_test_webhook_ok(
make_organization,
make_custom_webhook,
make_alert_receive_channel,
data,
expected_request_data,
request_post_kwargs,
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
webhook = make_custom_webhook(
organization=organization,
url="https://example.com/{{ alert_group_id }}/",
http_method="POST",
trigger_type=Webhook.TRIGGER_ACKNOWLEDGE,
trigger_template="{{{{ alert_group.integration_id == '{}' }}}}".format(
EXAMPLE_PAYLOAD["alert_group"]["integration_id"]
),
headers='{"some-header": "{{ alert_group_id }}"}',
data=data,
forward_all=False,
)
webhook.filtered_integrations.add(alert_receive_channel)

templated_url = f"https://example.com/{EXAMPLE_PAYLOAD['alert_group']['id']}/"
mock_response = httpretty.Response(json.dumps({"response": 200}))
httpretty.register_uri(httpretty.POST, templated_url, responses=[mock_response])

with patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8"):
with patch("apps.webhooks.models.webhook.requests", wraps=requests) as mock_requests:
execute_test_webhook(webhook.pk, payload=EXAMPLE_PAYLOAD)

mock_requests.post.assert_called_once_with(
templated_url,
timeout=TIMEOUT,
headers={"some-header": EXAMPLE_PAYLOAD["alert_group"]["id"]},
**request_post_kwargs,
)

# assert the request was made to the webhook as we expected
last_request = httpretty.last_request()
assert last_request.method == "POST"
assert last_request.parsed_body == expected_request_data
assert last_request.url == templated_url
assert last_request.headers["some-header"] == EXAMPLE_PAYLOAD["alert_group"]["id"]

@pytest.mark.django_db
def test_execute_webhook_via_escalation_ok(
Expand Down
1 change: 1 addition & 0 deletions engine/settings/celery_task_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
# 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.execute_test_webhook": {"queue": "webhook"},
"apps.webhooks.tasks.trigger_webhook.send_webhook_event": {"queue": "webhook"},
"apps.webhooks.tasks.alert_group_status.alert_group_created": {"queue": "webhook"},
"apps.webhooks.tasks.alert_group_status.alert_group_status_change": {"queue": "webhook"},
Expand Down
5 changes: 4 additions & 1 deletion grafana-plugin/src/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@

{ "action": "grafana-oncall-app.outgoing-webhooks:read" },
{ "action": "grafana-oncall-app.outgoing-webhooks:write" },
{ "action": "grafana-oncall-app.outgoing-webhooks:test" },

{ "action": "grafana-oncall-app.maintenance:read" },
{ "action": "grafana-oncall-app.maintenance:write" },
Expand Down Expand Up @@ -228,6 +229,7 @@
{ "action": "grafana-oncall-app.chatops:write" },

{ "action": "grafana-oncall-app.outgoing-webhooks:read" },
{ "action": "grafana-oncall-app.outgoing-webhooks:test" },

{ "action": "grafana-oncall-app.maintenance:read" },
{ "action": "grafana-oncall-app.maintenance:write" },
Expand Down Expand Up @@ -454,7 +456,8 @@
"permissions": [
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
{ "action": "grafana-oncall-app.outgoing-webhooks:read" },
{ "action": "grafana-oncall-app.outgoing-webhooks:write" }
{ "action": "grafana-oncall-app.outgoing-webhooks:write" },
{ "action": "grafana-oncall-app.outgoing-webhooks:test" }
]
},
"grants": []
Expand Down

0 comments on commit 17e552c

Please sign in to comment.