From 7549a688b083eb13301b0ae03997e2914566772b Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 27 Feb 2024 08:12:21 +0000 Subject: [PATCH] Integration webhooks API (#3954) # What this PR does Adds internal API endpoints for `alert_receive_channels//webhooks/`. ## Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/2541 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- .../api/tests/test_alert_receive_channel.py | 156 +++++++++++++++++- .../apps/api/views/alert_receive_channel.py | 54 ++++++ engine/common/tests/test_viewset_actions.py | 7 +- 3 files changed, 215 insertions(+), 2 deletions(-) diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 67de03b7..06d4405f 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -1,5 +1,5 @@ import json -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest from django.urls import reverse @@ -1696,3 +1696,157 @@ def test_team_not_updated_if_not_in_data( alert_receive_channel.refresh_from_db() assert alert_receive_channel.team == team + + +def _webhook_data(webhook_id=ANY, webhook_name=ANY, webhook_url=ANY, alert_receive_channel_id=ANY): + return { + "authorization_header": None, + "data": None, + "forward_all": True, + "headers": None, + "http_method": "POST", + "id": webhook_id, + "integration_filter": [alert_receive_channel_id], + "is_legacy": False, + "is_webhook_enabled": True, + "labels": [], + "last_response_log": { + "content": "", + "event_data": "", + "request_data": "", + "request_headers": "", + "request_trigger": "", + "status_code": None, + "timestamp": None, + "url": "", + }, + "name": webhook_name, + "password": None, + "preset": None, + "team": None, + "trigger_template": None, + "trigger_type": "0", + "trigger_type_name": "Escalation step", + "url": webhook_url, + "username": None, + } + + +@pytest.mark.django_db +def test_alert_receive_channel_webhooks_get( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_custom_webhook, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + webhook = make_custom_webhook(organization, is_from_connected_integration=True) + webhook.filtered_integrations.set([alert_receive_channel]) + + # create 2 webhooks that are not connected to the integration + make_custom_webhook(organization) + webhook2 = make_custom_webhook(organization, is_from_connected_integration=False) + webhook2.filtered_integrations.set([alert_receive_channel]) + + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-webhooks-get", kwargs={"pk": alert_receive_channel.public_primary_key} + ) + response = client.get(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + _webhook_data( + webhook_id=webhook.public_primary_key, + alert_receive_channel_id=alert_receive_channel.public_primary_key, + ) + ] + + +@pytest.mark.django_db +def test_alert_receive_channel_webhooks_post( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-webhooks-get", kwargs={"pk": alert_receive_channel.public_primary_key} + ) + + data = { + "name": None, + "enabled": True, + "url": "http://example.com/", + "http_method": "POST", + "trigger_type": "0", + "trigger_template": None, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == _webhook_data( + webhook_url="http://example.com/", + alert_receive_channel_id=alert_receive_channel.public_primary_key, + ) + assert alert_receive_channel.webhooks.get().is_from_connected_integration is True + + +@pytest.mark.django_db +def test_alert_receive_channel_webhooks_put( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_custom_webhook, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + webhook = make_custom_webhook(organization, is_from_connected_integration=True) + webhook.filtered_integrations.set([alert_receive_channel]) + + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-webhooks-put", + kwargs={"pk": alert_receive_channel.public_primary_key, "webhook_id": webhook.public_primary_key}, + ) + + data = _webhook_data( + webhook_id=webhook.public_primary_key, + webhook_name="Test", + webhook_url="http://example.com/", + alert_receive_channel_id=alert_receive_channel.public_primary_key, + ) + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + webhook.refresh_from_db() + assert webhook.url == "http://example.com/" + + +@pytest.mark.django_db +def test_alert_receive_channel_webhooks_delete( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_custom_webhook, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + webhook = make_custom_webhook(organization, is_from_connected_integration=True) + webhook.filtered_integrations.set([alert_receive_channel]) + + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-webhooks-put", + kwargs={"pk": alert_receive_channel.public_primary_key, "webhook_id": webhook.public_primary_key}, + ) + response = client.delete(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + webhook.refresh_from_db() + assert webhook.deleted_at is not None + assert alert_receive_channel.webhooks.count() == 0 diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index b118b532..268405f1 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -1,5 +1,6 @@ import typing +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from django_filters import rest_framework as filters from django_filters.rest_framework import DjangoFilterBackend @@ -7,6 +8,7 @@ from drf_spectacular.plumbing import resolve_type_hint from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_view, inline_serializer from rest_framework import serializers, status 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 @@ -22,6 +24,7 @@ from apps.api.serializers.alert_receive_channel import ( AlertReceiveChannelUpdateSerializer, FilterAlertReceiveChannelSerializer, ) +from apps.api.serializers.webhook import WebhookSerializer from apps.api.throttlers import DemoAlertThrottler from apps.api.views.labels import schedule_update_label_cache from apps.auth_token.auth import PluginAuthentication @@ -148,6 +151,10 @@ class AlertReceiveChannelView( "connect_contact_point": [RBACPermission.Permissions.INTEGRATIONS_WRITE], "create_contact_point": [RBACPermission.Permissions.INTEGRATIONS_WRITE], "disconnect_contact_point": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "webhooks_get": [RBACPermission.Permissions.INTEGRATIONS_READ], + "webhooks_post": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "webhooks_put": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "webhooks_delete": [RBACPermission.Permissions.INTEGRATIONS_WRITE], } def perform_update(self, serializer): @@ -622,3 +629,50 @@ class AlertReceiveChannelView( if not disconnected: raise BadRequest(detail=error) return Response(status=status.HTTP_200_OK) + + @extend_schema(request=None, responses=WebhookSerializer(many=True)) + @action(detail=True, methods=["get"], url_path="webhooks") + def webhooks_get(self, request, pk): + instance = self.get_object() + return Response( + WebhookSerializer( + instance.webhooks.filter(is_from_connected_integration=True), + many=True, + context={"request": request}, + ).data + ) + + @extend_schema(request=WebhookSerializer, responses=WebhookSerializer) + @webhooks_get.mapping.post + # https://www.django-rest-framework.org/api-guide/viewsets/#routing-additional-http-methods-for-extra-actions + def webhooks_post(self, request, pk): + instance = self.get_object() + serializer = WebhookSerializer(data=request.data, context={"request": request}) + serializer.is_valid(raise_exception=True) + serializer.save(filtered_integrations=[instance], is_from_connected_integration=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema(request=WebhookSerializer, responses=WebhookSerializer) + @action(detail=True, methods=["put"], url_path=r"webhooks/(?P\w+)") + def webhooks_put(self, request, pk, webhook_id): + instance = self.get_object() + try: + webhook = instance.webhooks.get(is_from_connected_integration=True, public_primary_key=webhook_id) + except ObjectDoesNotExist: + raise NotFound + serializer = WebhookSerializer(webhook, data=request.data, context={"request": request}) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema(request=None, responses=None) + @webhooks_put.mapping.delete + # https://www.django-rest-framework.org/api-guide/viewsets/#routing-additional-http-methods-for-extra-actions + def webhooks_delete(self, request, pk, webhook_id): + instance = self.get_object() + try: + webhook = instance.webhooks.get(is_from_connected_integration=True, public_primary_key=webhook_id) + except ObjectDoesNotExist: + raise NotFound + webhook.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/engine/common/tests/test_viewset_actions.py b/engine/common/tests/test_viewset_actions.py index 52a5a503..6c6d3b10 100644 --- a/engine/common/tests/test_viewset_actions.py +++ b/engine/common/tests/test_viewset_actions.py @@ -1,3 +1,4 @@ +import re from unittest.mock import patch import pytest @@ -27,7 +28,11 @@ def test_internal_api_detail_actions_get_object( organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() - url = reverse(f"api-internal:{basename}-{action.url_name}", kwargs={"pk": "NONEXISTENT"}) + # get additional kwargs based on url_path regex + # example: for /alert_receive_channel//webhooks/, url_path_kwargs = {"webhook_id": "NONEXISTENT"} + url_path_kwargs = {key: "NONEXISTENT" for key in re.compile(action.url_path).groupindex.keys()} + + url = reverse(f"api-internal:{basename}-{action.url_name}", kwargs={"pk": "NONEXISTENT", **url_path_kwargs}) with patch.object(viewset_class, "get_object", side_effect=NotFound) as mock_get_object: method = list(action.mapping.keys())[0] # get the first allowed method