Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add webhook test button #4218

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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