diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index b3e00d4222cfc9..8f4ccc74c8032b 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -302,6 +302,9 @@ from sentry.sentry_apps.api.endpoints.sentry_app_requests import SentryAppRequestsEndpoint from sentry.sentry_apps.api.endpoints.sentry_app_rotate_secret import SentryAppRotateSecretEndpoint from sentry.sentry_apps.api.endpoints.sentry_app_stats_details import SentryAppStatsEndpoint +from sentry.sentry_apps.api.endpoints.sentry_app_webhook_requests import ( + SentryAppWebhookRequestsEndpoint, +) from sentry.sentry_apps.api.endpoints.sentry_apps import SentryAppsEndpoint from sentry.sentry_apps.api.endpoints.sentry_apps_stats import SentryAppsStatsEndpoint from sentry.sentry_apps.api.endpoints.sentry_internal_app_token_details import ( @@ -2906,6 +2909,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: SentryAppPublishRequestEndpoint.as_view(), name="sentry-api-0-sentry-app-publish-request", ), + re_path( + r"^(?P[^\/]+)/webhook-requests/$", + SentryAppWebhookRequestsEndpoint.as_view(), + name="sentry-api-0-sentry-app-webhook-requests", + ), # The following a region endpoints as interactions and request logs # are per-region. re_path( diff --git a/src/sentry/conf/api_pagination_allowlist_do_not_modify.py b/src/sentry/conf/api_pagination_allowlist_do_not_modify.py index d3ad9bce77bc6a..1655bf1e2657b3 100644 --- a/src/sentry/conf/api_pagination_allowlist_do_not_modify.py +++ b/src/sentry/conf/api_pagination_allowlist_do_not_modify.py @@ -92,6 +92,7 @@ "ProjectUsersEndpoint", "ReleaseThresholdEndpoint", "SentryAppRequestsEndpoint", + "SentryAppWebhookRequestsEndpoint", "SentryAppsStatsEndpoint", "SentryInternalAppTokensEndpoint", "TeamGroupsOldEndpoint", diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_webhook_requests.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_webhook_requests.py new file mode 100644 index 00000000000000..1c5c9abcbd96db --- /dev/null +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_webhook_requests.py @@ -0,0 +1,136 @@ +from datetime import datetime, timezone + +from dateutil.parser import parse as parse_date +from rest_framework import serializers, status +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import control_silo_endpoint +from sentry.api.serializers import serialize +from sentry.models.organizationmapping import OrganizationMapping +from sentry.sentry_apps.api.bases.sentryapps import SentryAppBaseEndpoint, SentryAppStatsPermission +from sentry.sentry_apps.api.serializers.sentry_app_webhook_request import ( + SentryAppWebhookRequestSerializer, +) +from sentry.sentry_apps.api.utils.webhook_requests import ( + BufferedRequest, + DatetimeOrganizationFilterArgs, + get_buffer_requests_from_control, + get_buffer_requests_from_regions, +) +from sentry.sentry_apps.models.sentry_app import SentryApp +from sentry.sentry_apps.services.app_request import SentryAppRequestFilterArgs +from sentry.utils.sentry_apps import EXTENDED_VALID_EVENTS + + +class IncomingRequestSerializer(serializers.Serializer): + date_format = "%Y-%m-%d %H:%M:%S" + eventType = serializers.ChoiceField( + choices=EXTENDED_VALID_EVENTS, + required=False, + ) + errorsOnly = serializers.BooleanField(default=False, required=False) + organizationSlug = serializers.CharField(required=False) + start = serializers.DateTimeField( + format=date_format, + default=datetime.strptime("2000-01-01 00:00:00", date_format).replace(tzinfo=timezone.utc), + default_timezone=timezone.utc, + required=False, + ) + end = serializers.DateTimeField( + format=date_format, default=None, default_timezone=timezone.utc, required=False + ) + + def validate(self, data): + if "start" in data and "end" in data and data["start"] > data["end"]: + raise serializers.ValidationError("Invalid timestamp (start must be before end).") + return data + + def validate_end(self, end): + if end is None: + end = datetime.now(tz=timezone.utc) + return end + + +@control_silo_endpoint +class SentryAppWebhookRequestsEndpoint(SentryAppBaseEndpoint): + owner = ApiOwner.ECOSYSTEM + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + } + permission_classes = (SentryAppStatsPermission,) + + def get(self, request: Request, sentry_app: SentryApp) -> Response: + """ + :qparam string eventType: Optionally specify a specific event type to filter requests + :qparam bool errorsOnly: If this is true, only return error/warning requests (300-599) + :qparam string organizationSlug: Optionally specify an org slug to filter requests + :qparam string start: Optionally specify a date to begin at. Format must be YYYY-MM-DD HH:MM:SS + :qparam string end: Optionally specify a date to end at. Format must be YYYY-MM-DD HH:MM:SS + """ + serializer = IncomingRequestSerializer(data=request.GET) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serialized = serializer.validated_data + + event_type = serialized.get("eventType") + errors_only = serialized.get("errorsOnly") + org_slug = serialized.get("organizationSlug") + start_time = serialized.get("start") + end_time = serialized.get("end") + + organization = None + if org_slug: + try: + organization = OrganizationMapping.objects.get(slug=org_slug) + except OrganizationMapping.DoesNotExist: + return Response({"detail": "Invalid organization."}, status=400) + + requests: list[BufferedRequest] = [] + control_filter: SentryAppRequestFilterArgs = {} + region_filter: SentryAppRequestFilterArgs = {} + control_filter["errors_only"] = region_filter["errors_only"] = errors_only + datetime_org_filter: DatetimeOrganizationFilterArgs = { + "start_time": start_time, + "end_time": end_time, + "organization": organization, + } + + # If event type is installation.created or installation.deleted, we only need to fetch requests from the control buffer + if event_type == "installation.created" or event_type == "installation.deleted": + control_filter["event"] = event_type + requests.extend( + get_buffer_requests_from_control(sentry_app, control_filter, datetime_org_filter) + ) + # If event type has been specified, we only need to fetch requests from region buffers + elif event_type: + region_filter["event"] = event_type + requests.extend( + get_buffer_requests_from_regions(sentry_app.id, region_filter, datetime_org_filter) + ) + else: + control_filter["event"] = [ + "installation.created", + "installation.deleted", + ] + requests.extend( + get_buffer_requests_from_control(sentry_app, control_filter, datetime_org_filter) + ) + region_filter["event"] = list( + set(EXTENDED_VALID_EVENTS) + - { + "installation.created", + "installation.deleted", + } + ) + requests.extend( + get_buffer_requests_from_regions(sentry_app.id, region_filter, datetime_org_filter) + ) + + requests.sort(key=lambda x: parse_date(x.data.date), reverse=True) + + return Response( + serialize(requests, request.user, SentryAppWebhookRequestSerializer(sentry_app)) + ) diff --git a/src/sentry/sentry_apps/api/serializers/sentry_app_webhook_request.py b/src/sentry/sentry_apps/api/serializers/sentry_app_webhook_request.py new file mode 100644 index 00000000000000..cb784624e5a188 --- /dev/null +++ b/src/sentry/sentry_apps/api/serializers/sentry_app_webhook_request.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping, Sequence +from typing import Any, NotRequired, TypedDict + +from sentry.api.serializers import Serializer +from sentry.hybridcloud.services.organization_mapping import ( + RpcOrganizationMapping, + organization_mapping_service, +) +from sentry.sentry_apps.api.utils.webhook_requests import BufferedRequest +from sentry.sentry_apps.models.sentry_app import SentryApp +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser + + +class _BufferedRequestAttrs(TypedDict): + organization: RpcOrganizationMapping | None + + +class OrganizationResponse(TypedDict): + name: str + slug: str + + +class SentryAppWebhookRequestSerializerResponse(TypedDict): + webhookUrl: str + sentryAppSlug: str + eventType: str + date: str + responseCode: int + organization: NotRequired[OrganizationResponse] + + +class SentryAppWebhookRequestSerializer(Serializer): + def __init__(self, sentry_app: SentryApp) -> None: + self.sentry_app = sentry_app + + def get_attrs( + self, item_list: Sequence[BufferedRequest], user: User | RpcUser, **kwargs: Any + ) -> MutableMapping[BufferedRequest, _BufferedRequestAttrs]: + organization_ids = {item.data.organization_id for item in item_list} + organizations = organization_mapping_service.get_many(organization_ids=organization_ids) + organizations_by_id = {organization.id: organization for organization in organizations} + + return { + item: { + "organization": ( + organizations_by_id.get(item.data.organization_id) + if item.data.organization_id + else None + ) + } + for item in item_list + } + + def serialize( + self, obj: BufferedRequest, attrs: Mapping[Any, Any], user: Any, **kwargs: Any + ) -> SentryAppWebhookRequestSerializerResponse: + organization = attrs.get("organization") + response_code = obj.data.response_code + + data: SentryAppWebhookRequestSerializerResponse = { + "webhookUrl": obj.data.webhook_url, + "sentryAppSlug": self.sentry_app.slug, + "eventType": obj.data.event_type, + "date": obj.data.date, + "responseCode": response_code, + } + + if organization: + data["organization"] = {"name": organization.name, "slug": organization.slug} + + return data diff --git a/src/sentry/sentry_apps/api/utils/__init__.py b/src/sentry/sentry_apps/api/utils/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/sentry_apps/api/utils/webhook_requests.py b/src/sentry/sentry_apps/api/utils/webhook_requests.py new file mode 100644 index 00000000000000..af424e4948e601 --- /dev/null +++ b/src/sentry/sentry_apps/api/utils/webhook_requests.py @@ -0,0 +1,99 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import TypedDict + +from sentry.models.organizationmapping import OrganizationMapping +from sentry.sentry_apps.models.sentry_app import SentryApp +from sentry.sentry_apps.services.app_request import RpcSentryAppRequest, SentryAppRequestFilterArgs +from sentry.sentry_apps.services.app_request.serial import serialize_rpc_sentry_app_request +from sentry.sentry_apps.services.app_request.service import app_request_service +from sentry.types.region import find_all_region_names +from sentry.utils.sentry_apps import SentryAppWebhookRequestsBuffer + + +@dataclass +class BufferedRequest: + id: int + data: RpcSentryAppRequest + + def __hash__(self): + return self.id + + +class DatetimeOrganizationFilterArgs(TypedDict): + start_time: datetime + end_time: datetime + organization: OrganizationMapping | None + + +def _filter_by_date(request: RpcSentryAppRequest, start: datetime, end: datetime) -> bool: + date_str = request.date + if not date_str: + return False + timestamp = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S.%f+00:00").replace( + microsecond=0, tzinfo=timezone.utc + ) + return start <= timestamp <= end + + +def _filter_by_organization( + request: RpcSentryAppRequest, organization: OrganizationMapping | None +) -> bool: + if not organization: + return True + return request.organization_id == organization.organization_id + + +def filter_requests( + unfiltered_requests: list[RpcSentryAppRequest], + filter: DatetimeOrganizationFilterArgs, +) -> list[BufferedRequest]: + requests: list[BufferedRequest] = [] + for i, req in enumerate(unfiltered_requests): + start_time = filter.get("start_time") + end_time = filter.get("end_time") + if ( + start_time + and end_time + and _filter_by_date(req, start_time, end_time) + and _filter_by_organization(req, organization=filter.get("organization")) + ): + requests.append(BufferedRequest(id=i, data=req)) + return requests + + +def get_buffer_requests_from_control( + sentry_app: SentryApp, + filter: SentryAppRequestFilterArgs, + datetime_org_filter: DatetimeOrganizationFilterArgs, +) -> list[BufferedRequest]: + control_buffer = SentryAppWebhookRequestsBuffer(sentry_app) + + event = filter.get("event", None) if filter else None + errors_only = filter.get("errors_only", False) if filter else False + + unfiltered_requests = [ + serialize_rpc_sentry_app_request(req) + for req in control_buffer.get_requests(event=event, errors_only=errors_only) + ] + return filter_requests( + unfiltered_requests, + datetime_org_filter, + ) + + +def get_buffer_requests_from_regions( + sentry_app_id: int, + filter: SentryAppRequestFilterArgs, + datetime_org_filter: DatetimeOrganizationFilterArgs, +) -> list[BufferedRequest]: + requests: list[RpcSentryAppRequest] = [] + for region_name in find_all_region_names(): + buffer_requests = app_request_service.get_buffer_requests_for_region( + sentry_app_id=sentry_app_id, + region_name=region_name, + filter=filter, + ) + if buffer_requests: + requests.extend(buffer_requests) + return filter_requests(requests, datetime_org_filter) diff --git a/src/sentry/sentry_apps/services/app_request/model.py b/src/sentry/sentry_apps/services/app_request/model.py index c676f5eeb0af8e..3e98df2d0a417c 100644 --- a/src/sentry/sentry_apps/services/app_request/model.py +++ b/src/sentry/sentry_apps/services/app_request/model.py @@ -12,10 +12,10 @@ class RpcSentryAppRequest(RpcModel): date: str response_code: int webhook_url: str - organization_id: int + organization_id: int | None event_type: str class SentryAppRequestFilterArgs(TypedDict, total=False): - event: str + event: str | list[str] errors_only: bool diff --git a/src/sentry/utils/sentry_apps/request_buffer.py b/src/sentry/utils/sentry_apps/request_buffer.py index e7d60fa4393714..e27b6c3c68e3f2 100644 --- a/src/sentry/utils/sentry_apps/request_buffer.py +++ b/src/sentry/utils/sentry_apps/request_buffer.py @@ -100,18 +100,26 @@ def _get_all_from_buffer( else: return self.client.lrange(buffer_key, 0, BUFFER_SIZE - 1) - def _get_requests(self, event: str | None = None, error: bool = False) -> list[dict[str, Any]]: + def _get_requests( + self, event: str | list[str] | None = None, error: bool = False + ) -> list[dict[str, Any]]: + if isinstance(event, str): + return [ + self._convert_redis_request(request, event) + for request in self._get_all_from_buffer(self._get_redis_key(event, error=error)) + ] # If no event is specified, return the latest requests/errors for all event types - if event is None: + else: + event_types = event or EXTENDED_VALID_EVENTS pipe = self.client.pipeline() all_requests = [] - for evt in EXTENDED_VALID_EVENTS: + for evt in event_types: self._get_all_from_buffer(self._get_redis_key(evt, error=error), pipeline=pipe) values = pipe.execute() - for idx, evt in enumerate(EXTENDED_VALID_EVENTS): + for idx, evt in enumerate(event_types): event_requests = [ self._convert_redis_request(request, evt) for request in values[idx] ] @@ -120,14 +128,8 @@ def _get_requests(self, event: str | None = None, error: bool = False) -> list[d all_requests.sort(key=lambda x: parse_date(x["date"]), reverse=True) return all_requests[0:BUFFER_SIZE] - else: - return [ - self._convert_redis_request(request, event) - for request in self._get_all_from_buffer(self._get_redis_key(event, error=error)) - ] - def get_requests( - self, event: str | None = None, errors_only: bool = False + self, event: str | list[str] | None = None, errors_only: bool = False ) -> list[dict[str, Any]]: return self._get_requests(event=event, error=errors_only) diff --git a/static/app/data/controlsiloUrlPatterns.ts b/static/app/data/controlsiloUrlPatterns.ts index f0e1959803be80..d5586ad241d0d9 100644 --- a/static/app/data/controlsiloUrlPatterns.ts +++ b/static/app/data/controlsiloUrlPatterns.ts @@ -98,6 +98,7 @@ const patterns: RegExp[] = [ new RegExp('^api/0/sentry-apps/[^/]+/rotate-secret/$'), new RegExp('^api/0/sentry-apps/[^/]+/stats/$'), new RegExp('^api/0/sentry-apps/[^/]+/publish-request/$'), + new RegExp('^api/0/sentry-apps/[^/]+/webhook-requests/$'), new RegExp('^api/0/sentry-app-installations/[^/]+/$'), new RegExp('^api/0/sentry-app-installations/[^/]+/authorizations/$'), new RegExp('^api/0/auth/$'), diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py new file mode 100644 index 00000000000000..0260eded0a4546 --- /dev/null +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py @@ -0,0 +1,389 @@ +from datetime import datetime, timedelta + +from django.urls import reverse + +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.datetime import before_now, freeze_time +from sentry.testutils.silo import control_silo_test, create_test_regions +from sentry.testutils.skips import requires_snuba +from sentry.utils.sentry_apps import SentryAppWebhookRequestsBuffer + +pytestmark = [requires_snuba] + + +@control_silo_test(regions=create_test_regions("us")) +class SentryAppWebhookRequestsGetTest(APITestCase): + def setUp(self): + self.superuser = self.create_user(email="superuser@example.com", is_superuser=True) + self.user = self.create_user(email="user@example.com") + self.org = self.create_organization(owner=self.user, region="us", slug="test-org") + self.project = self.create_project(organization=self.org) + self.event_id = "d5111da2c28645c5889d072017e3445d" + + self.published_app = self.create_sentry_app( + name="Published App", organization=self.org, published=True + ) + self.unowned_published_app = self.create_sentry_app( + name="Unowned Published App", organization=self.create_organization(), published=True + ) + + self.unpublished_app = self.create_sentry_app(name="Unpublished App", organization=self.org) + self.unowned_unpublished_app = self.create_sentry_app( + name="Unowned Unpublished App", organization=self.create_organization() + ) + + self.internal_app = self.create_internal_integration( + name="Internal app", organization=self.org + ) + + self.create_sentry_app_installation( + organization=self.org, slug=self.published_app.slug, prevent_token_exchange=True + ) + + def test_superuser_sees_unowned_published_requests(self): + self.login_as(user=self.superuser, superuser=True) + + buffer = SentryAppWebhookRequestsBuffer(self.unowned_published_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.unowned_published_app.webhook_url, + ) + buffer.add_request( + response_code=500, + org_id=self.org.id, + event="issue.assigned", + url=self.unowned_published_app.webhook_url, + ) + + url = reverse( + "sentry-api-0-sentry-app-webhook-requests", args=[self.unowned_published_app.slug] + ) + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 2 + assert response.data[0]["organization"]["slug"] == self.org.slug + assert response.data[0]["sentryAppSlug"] == self.unowned_published_app.slug + assert response.data[0]["responseCode"] == 500 + + def test_superuser_sees_unpublished_stats(self): + self.login_as(user=self.superuser, superuser=True) + + buffer = SentryAppWebhookRequestsBuffer(self.unowned_unpublished_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.unowned_unpublished_app.webhook_url, + ) + + url = reverse( + "sentry-api-0-sentry-app-webhook-requests", args=[self.unowned_unpublished_app.slug] + ) + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]["sentryAppSlug"] == self.unowned_unpublished_app.slug + + def test_user_sees_owned_published_requests(self): + self.login_as(user=self.user) + + buffer = SentryAppWebhookRequestsBuffer(self.published_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]["organization"]["slug"] == self.org.slug + assert response.data[0]["sentryAppSlug"] == self.published_app.slug + assert response.data[0]["responseCode"] == 200 + + def test_user_does_not_see_unowned_published_requests(self): + self.login_as(user=self.user) + + buffer = SentryAppWebhookRequestsBuffer(self.unowned_published_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.unowned_published_app.webhook_url, + ) + + url = reverse( + "sentry-api-0-sentry-app-webhook-requests", args=[self.unowned_published_app.slug] + ) + response = self.client.get(url, format="json") + assert response.status_code == 403 + assert response.data["detail"] == "You do not have permission to perform this action." + + def test_user_sees_owned_unpublished_requests(self): + self.login_as(user=self.user) + + buffer = SentryAppWebhookRequestsBuffer(self.unpublished_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.unpublished_app.webhook_url, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.unpublished_app.slug]) + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 1 + + def test_internal_app_requests_does_not_have_organization_field(self): + self.login_as(user=self.user) + buffer = SentryAppWebhookRequestsBuffer(self.internal_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.internal_app.webhook_url, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.internal_app.slug]) + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 1 + assert "organization" not in response.data[0] + assert response.data[0]["sentryAppSlug"] == self.internal_app.slug + assert response.data[0]["responseCode"] == 200 + + def test_event_type_filter(self): + self.login_as(user=self.user) + buffer = SentryAppWebhookRequestsBuffer(self.published_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + buffer.add_request( + response_code=400, + org_id=self.org.id, + event="installation.created", + url=self.published_app.webhook_url, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) + response1 = self.client.get(f"{url}?eventType=issue.created", format="json") + assert response1.status_code == 200 + assert len(response1.data) == 0 + + response2 = self.client.get(f"{url}?eventType=issue.assigned", format="json") + assert response2.status_code == 200 + assert len(response2.data) == 1 + assert response2.data[0]["sentryAppSlug"] == self.published_app.slug + assert response2.data[0]["responseCode"] == 200 + + response3 = self.client.get(f"{url}?eventType=installation.created", format="json") + assert response3.status_code == 200 + assert len(response3.data) == 1 + assert response3.data[0]["sentryAppSlug"] == self.published_app.slug + assert response3.data[0]["responseCode"] == 400 + + def test_invalid_event_type(self): + self.login_as(user=self.user) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) + response = self.client.get(f"{url}?eventType=invalid_type", format="json") + + assert response.status_code == 400 + + def test_errors_only_filter(self): + self.login_as(user=self.user) + buffer = SentryAppWebhookRequestsBuffer(self.published_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + buffer.add_request( + response_code=500, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) + errors_only_response = self.client.get(f"{url}?errorsOnly=true", format="json") + assert errors_only_response.status_code == 200 + assert len(errors_only_response.data) == 1 + assert errors_only_response.data[0]["sentryAppSlug"] == self.published_app.slug + assert errors_only_response.data[0]["responseCode"] == 500 + + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 2 + + def test_linked_error_not_returned_if_project_does_not_exist(self): + self.login_as(user=self.user) + + self.store_event( + data={"event_id": self.event_id, "timestamp": before_now(minutes=1).isoformat()}, + project_id=self.project.id, + ) + + buffer = SentryAppWebhookRequestsBuffer(self.published_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.unpublished_app.webhook_url, + error_id=self.event_id, + project_id=1000, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]["organization"]["slug"] == self.org.slug + assert response.data[0]["sentryAppSlug"] == self.published_app.slug + assert "errorUrl" not in response.data[0] + + def test_org_slug_filter(self): + """Test that filtering by the qparam organizationSlug properly filters results""" + self.login_as(user=self.user) + buffer = SentryAppWebhookRequestsBuffer(self.published_app) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + buffer.add_request( + response_code=500, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) + made_up_org_response = self.client.get(f"{url}?organizationSlug=madeUpOrg", format="json") + assert made_up_org_response.status_code == 400 + assert made_up_org_response.data["detail"] == "Invalid organization." + + org_response = self.client.get(f"{url}?organizationSlug={self.org.slug}", format="json") + assert org_response.status_code == 200 + assert len(org_response.data) == 2 + + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 2 + + def test_date_filter(self): + """Test that filtering by the qparams start and end properly filters results""" + self.login_as(user=self.user) + buffer = SentryAppWebhookRequestsBuffer(self.published_app) + now = datetime.now() - timedelta(hours=1) + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + with freeze_time(now + timedelta(seconds=1)): + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + with freeze_time(now + timedelta(seconds=2)): + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 3 + + # test adding a start time + start_date = now.strftime("%Y-%m-%d %H:%M:%S") + start_date_response = self.client.get(f"{url}?start={start_date}", format="json") + assert start_date_response.status_code == 200 + assert len(start_date_response.data) == 3 + + # test adding an end time + end_date = (now + timedelta(seconds=2)).strftime("%Y-%m-%d %H:%M:%S") + end_date_response = self.client.get(f"{url}?end={end_date}", format="json") + assert end_date_response.status_code == 200 + assert len(end_date_response.data) == 2 + + # test adding a start and end time + new_start_date = (now + timedelta(seconds=1)).strftime("%Y-%m-%d %H:%M:%S") + new_end_date = (now + timedelta(seconds=2)).strftime("%Y-%m-%d %H:%M:%S") + start_end_date_response = self.client.get( + f"{url}?start={new_start_date}&end={new_end_date}", format="json" + ) + assert start_end_date_response.status_code == 200 + assert len(start_end_date_response.data) == 2 + + # test adding an improperly formatted end time + bad_date_format_response = self.client.get(f"{url}?end=2000-01- 00:00:00", format="json") + assert bad_date_format_response.status_code == 400 + + # test adding a start and end time + late_start_date = (now + timedelta(seconds=2)).strftime("%Y-%m-%d %H:%M:%S") + early_end_date = (now + timedelta(seconds=1)).strftime("%Y-%m-%d %H:%M:%S") + start_after_end_response = self.client.get( + f"{url}?start={late_start_date}&end={early_end_date}", format="json" + ) + assert start_after_end_response.status_code == 400 + + def test_get_includes_installation_requests(self): + self.login_as(user=self.user) + buffer = SentryAppWebhookRequestsBuffer(self.published_app) + now = datetime.now() - timedelta(hours=1) + with freeze_time(now): + buffer.add_request( + response_code=200, + org_id=self.org.id, + event="issue.created", + url=self.published_app.webhook_url, + ) + with freeze_time(now + timedelta(seconds=1)): + buffer.add_request( + response_code=500, + org_id=self.org.id, + event="installation.created", + url=self.published_app.webhook_url, + ) + with freeze_time(now + timedelta(seconds=2)): + buffer.add_request( + response_code=500, + org_id=self.org.id, + event="issue.assigned", + url=self.published_app.webhook_url, + ) + with freeze_time(now + timedelta(seconds=3)): + buffer.add_request( + response_code=500, + org_id=self.org.id, + event="installation.deleted", + url=self.published_app.webhook_url, + ) + + url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) + + response = self.client.get(url, format="json") + assert response.status_code == 200 + assert len(response.data) == 4 + assert response.data[0]["eventType"] == "installation.deleted" + assert response.data[1]["eventType"] == "issue.assigned" + assert response.data[2]["eventType"] == "installation.created" + assert response.data[3]["eventType"] == "issue.created"