Integration webhooks API (#3954)

# What this PR does

Adds internal API endpoints for
`alert_receive_channels/<id>/webhooks/<webhook_id>`.

## 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)
This commit is contained in:
Vadim Stepanov 2024-02-27 08:12:21 +00:00 committed by GitHub
parent c2ffd28675
commit 7549a688b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 215 additions and 2 deletions

View file

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

View file

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

View file

@ -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/<pk>/webhooks/<webhook_id>, 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