From f04f4eaa3fb29e78998c4fc741390ed66b5eb305 Mon Sep 17 00:00:00 2001 From: Julia Date: Fri, 17 Jun 2022 15:34:59 +0300 Subject: [PATCH 1/2] Update public endpoint for outgoing webhooks - Add abilities to create, update and delete outgoing webhooks by public api endpoint --- engine/apps/public_api/serializers/action.py | 75 +++++++++++++++++++- engine/apps/public_api/views/action.py | 37 ++++++++-- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/engine/apps/public_api/serializers/action.py b/engine/apps/public_api/serializers/action.py index db202b22..963aacbc 100644 --- a/engine/apps/public_api/serializers/action.py +++ b/engine/apps/public_api/serializers/action.py @@ -1,17 +1,88 @@ +import json + +from django.core.validators import URLValidator, ValidationError +from jinja2 import Template, TemplateError from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator from apps.alerts.models import CustomButton from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField +from common.api_helpers.utils import CurrentOrganizationDefault -class ActionSerializer(serializers.ModelSerializer): +class ActionCreateSerializer(serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") - team_id = TeamPrimaryKeyRelatedField(allow_null=True, source="team") + organization = serializers.HiddenField(default=CurrentOrganizationDefault()) + team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team") class Meta: model = CustomButton fields = [ "id", "name", + "organization", "team_id", + "webhook", + "data", + "user", + "password", + "authorization_header", + "forward_whole_payload", ] + extra_kwargs = { + "name": {"required": True, "allow_null": False, "allow_blank": False}, + "webhook": {"required": True, "allow_null": False, "allow_blank": False}, + "data": {"required": False, "allow_null": True, "allow_blank": False}, + "user": {"required": False, "allow_null": True, "allow_blank": False}, + "password": {"required": False, "allow_null": True, "allow_blank": False}, + "authorization_header": {"required": False, "allow_null": True, "allow_blank": False}, + "forward_whole_payload": {"required": False, "allow_null": True}, + } + + validators = [UniqueTogetherValidator(queryset=CustomButton.objects.all(), fields=["name", "organization"])] + + def validate_webhook(self, webhook): + if webhook: + try: + 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: + json.loads(data) + except ValueError: + raise serializers.ValidationError("Data has incorrect format") + + try: + Template(data) + except TemplateError: + raise serializers.ValidationError("Data has incorrect template") + + return data + + def validate_forward_whole_payload(self, data): + if data is None: + return False + return data + + +class ActionUpdateSerializer(ActionCreateSerializer): + team_id = TeamPrimaryKeyRelatedField(source="team", read_only=True) + + class Meta(ActionCreateSerializer.Meta): + + extra_kwargs = { + "name": {"required": False, "allow_null": False, "allow_blank": False}, + "webhook": {"required": False, "allow_null": False, "allow_blank": False}, + "data": {"required": False, "allow_null": True, "allow_blank": False}, + "user": {"required": False, "allow_null": True, "allow_blank": False}, + "password": {"required": False, "allow_null": True, "allow_blank": False}, + "authorization_header": {"required": False, "allow_null": True, "allow_blank": False}, + "forward_whole_payload": {"required": False, "allow_null": True}, + } diff --git a/engine/apps/public_api/views/action.py b/engine/apps/public_api/views/action.py index 60ca1465..0e5944eb 100644 --- a/engine/apps/public_api/views/action.py +++ b/engine/apps/public_api/views/action.py @@ -1,25 +1,26 @@ from django_filters import rest_framework as filters -from rest_framework import mixins from rest_framework.permissions import IsAuthenticated -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import ModelViewSet from apps.alerts.models import CustomButton from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api.serializers.action import ActionSerializer +from apps.public_api.serializers.action import ActionCreateSerializer, ActionUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle +from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.filters import ByTeamFilter -from common.api_helpers.mixins import RateLimitHeadersMixin +from common.api_helpers.mixins import PublicPrimaryKeyMixin, RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class ActionView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): +class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) pagination_class = FiftyPageSizePaginator throttle_classes = [UserThrottle] model = CustomButton - serializer_class = ActionSerializer + serializer_class = ActionCreateSerializer + update_serializer_class = ActionUpdateSerializer filter_backends = (filters.DjangoFilterBackend,) filterset_class = ByTeamFilter @@ -32,3 +33,27 @@ class ActionView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): queryset = queryset.filter(name=action_name) return queryset + + def perform_create(self, serializer): + serializer.save() + instance = serializer.instance + organization = self.request.auth.organization + user = self.request.user + description = f"Custom action {instance.name} was created" + create_organization_log(organization, user, OrganizationLogType.TYPE_CUSTOM_ACTION_CREATED, description) + + def perform_update(self, serializer): + organization = self.request.auth.organization + user = self.request.user + old_state = serializer.instance.repr_settings_for_client_side_logging + serializer.save() + new_state = serializer.instance.repr_settings_for_client_side_logging + description = f"Custom action {serializer.instance.name} was changed " f"from:\n{old_state}\nto:\n{new_state}" + create_organization_log(organization, user, OrganizationLogType.TYPE_CUSTOM_ACTION_CHANGED, description) + + def perform_destroy(self, instance): + organization = self.request.auth.organization + user = self.request.user + description = f"Custom action {instance.name} was deleted" + create_organization_log(organization, user, OrganizationLogType.TYPE_CUSTOM_ACTION_DELETED, description) + instance.delete() From 43bc8c2fe5366930004ae6821e00765e6a004dda Mon Sep 17 00:00:00 2001 From: Julia Date: Fri, 17 Jun 2022 15:41:46 +0300 Subject: [PATCH 2/2] Add tests for outgoing webhooks public api endpoint --- .../public_api/tests/test_custom_actions.py | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/engine/apps/public_api/tests/test_custom_actions.py b/engine/apps/public_api/tests/test_custom_actions.py index 2fc39f92..ee0e5f67 100644 --- a/engine/apps/public_api/tests/test_custom_actions.py +++ b/engine/apps/public_api/tests/test_custom_actions.py @@ -3,6 +3,8 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.alerts.models import CustomButton + @pytest.mark.django_db def test_get_custom_actions( @@ -28,6 +30,12 @@ def test_get_custom_actions( "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, } ], } @@ -60,6 +68,12 @@ def test_get_custom_actions_filter_by_name( "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, } ], } @@ -87,3 +101,171 @@ def test_get_custom_actions_filter_by_name_empty_result( assert response.status_code == status.HTTP_200_OK assert response.data == expected_payload + + +@pytest.mark.django_db +def test_get_custom_action( + make_organization_and_user_with_token, + make_custom_action, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + custom_action = make_custom_action(organization=organization) + + url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_payload = { + "id": custom_action.public_primary_key, + "name": custom_action.name, + "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + +@pytest.mark.django_db +def test_create_custom_action(make_organization_and_user_with_token): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:actions-list") + + data = { + "name": "Test outgoing webhook", + "webhook": "https://example.com", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + custom_action = CustomButton.objects.get(public_primary_key=response.data["id"]) + + expected_result = { + "id": custom_action.public_primary_key, + "name": custom_action.name, + "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.data == expected_result + + +@pytest.mark.django_db +def test_create_custom_action_invalid_data( + make_organization_and_user_with_token, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:actions-list") + + data = { + "name": "Test outgoing webhook", + "webhook": "invalid_url", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["webhook"][0] == "Webhook is incorrect" + + data = { + "name": "Test outgoing webhook", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["webhook"][0] == "This field is required." + + data = { + "webhook": "https://example.com", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["name"][0] == "This field is required." + + +@pytest.mark.django_db +def test_update_custom_action( + make_organization_and_user_with_token, + make_custom_action, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + custom_action = make_custom_action(organization=organization) + + url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) + + data = { + "name": "RENAMED", + } + + assert custom_action.name != data["name"] + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_result = { + "id": custom_action.public_primary_key, + "name": data["name"], + "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, + } + + assert response.status_code == status.HTTP_200_OK + custom_action.refresh_from_db() + assert custom_action.name == expected_result["name"] + assert response.data == expected_result + + +@pytest.mark.django_db +def test_delete_custom_action( + make_organization_and_user_with_token, + make_custom_action, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + custom_action = make_custom_action(organization=organization) + url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) + + assert custom_action.deleted_at is None + + response = client.delete(url, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + custom_action.refresh_from_db() + assert custom_action.deleted_at is not None + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.data["detail"] == "Not found."