From 890b23ed08b677cfd60b461dfa3ffa3fd2b7b4dd Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 15 Oct 2025 13:43:45 +0200 Subject: [PATCH 1/3] feat(integration): Add endpoint to validate a custom integration channel --- src/sentry/api/urls.py | 8 + ...ganization_integration_channel_validate.py | 95 +++++++++++ ...ganization_integration_channel_validate.py | 152 ++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 src/sentry/integrations/api/endpoints/organization_integration_channel_validate.py create mode 100644 tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 5e152b9f46643b..7c5d7a49441980 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -246,6 +246,9 @@ from sentry.integrations.api.endpoints.organization_integration_channels import ( OrganizationIntegrationChannelsEndpoint, ) +from sentry.integrations.api.endpoints.organization_integration_channel_validate import ( + OrganizationIntegrationChannelValidateEndpoint, +) from sentry.integrations.api.endpoints.organization_integration_details import ( OrganizationIntegrationDetailsEndpoint, ) @@ -1902,6 +1905,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationIntegrationChannelsEndpoint.as_view(), name="sentry-api-0-organization-integration-channels", ), + re_path( + r"^(?P[^/]+)/integrations/(?P[^/]+)/channel-validate/$", + OrganizationIntegrationChannelValidateEndpoint.as_view(), + name="sentry-api-0-organization-integration-channel-validate", + ), re_path( r"^(?P[^/]+)/integrations/(?P[^/]+)/issues/$", OrganizationIntegrationIssuesEndpoint.as_view(), diff --git a/src/sentry/integrations/api/endpoints/organization_integration_channel_validate.py b/src/sentry/integrations/api/endpoints/organization_integration_channel_validate.py new file mode 100644 index 00000000000000..7d22e88d893158 --- /dev/null +++ b/src/sentry/integrations/api/endpoints/organization_integration_channel_validate.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Any + +from django.core.exceptions import ValidationError +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.integrations.api.bases.organization_integrations import ( + OrganizationIntegrationBaseEndpoint, +) +from sentry.integrations.discord.utils.channel import ( + validate_channel_id as discord_validate_channel_id, +) +from sentry.integrations.discord.utils.channel_from_url import ( + get_channel_id_from_url as discord_get_channel_id_from_url, +) +from sentry.integrations.msteams.utils import find_channel_id as msteams_find_channel_id +from sentry.integrations.slack.utils.channel import get_channel_id +from sentry.integrations.types import IntegrationProviderSlug +from sentry.shared_integrations.exceptions import ApiError + + +@control_silo_endpoint +class OrganizationIntegrationChannelValidateEndpoint(OrganizationIntegrationBaseEndpoint): + publish_status = {"GET": ApiPublishStatus.PRIVATE} + owner = ApiOwner.TELEMETRY_EXPERIENCE + + class ChannelValidateSerializer(serializers.Serializer): + channel = serializers.CharField(required=True, allow_blank=False) + + def get( + self, + request: Request, + organization_context: Any, + integration_id: int, + **kwargs: Any, + ) -> Response: + """Validate whether a channel exists for the given integration.""" + serializer = self.ChannelValidateSerializer(data=request.GET) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + channel = serializer.validated_data["channel"].strip() + integration = self.get_integration(organization_context.organization.id, integration_id) + + try: + provider = integration.provider + + if provider == IntegrationProviderSlug.SLACK.value: + channel_data = get_channel_id(integration=integration, channel_name=channel) + if channel_data.channel_id: + return Response({"valid": True}) + return Response({"valid": False}) + + if provider == IntegrationProviderSlug.MSTEAMS.value: + channel_id = msteams_find_channel_id(integration=integration, name=channel) + if channel_id: + return Response({"valid": True}) + return Response({"valid": False}) + + if provider == IntegrationProviderSlug.DISCORD.value: + try: + channel_id = ( + channel if channel.isdigit() else discord_get_channel_id_from_url(channel) + ) + except ValidationError: + return Response({"valid": False}) + + try: + discord_validate_channel_id( + channel_id=channel_id, + guild_id=str(integration.external_id), + guild_name=integration.name, + ) + except (ValidationError, ApiError) as e: + return Response( + {"valid": False, "detail": str(e) or "Discord channel validation failed"} + ) + + return Response({"valid": True}) + + return Response( + {"valid": False, "detail": "Unsupported provider"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + return Response( + {"valid": False, "detail": str(e) or "Unexpected error"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py new file mode 100644 index 00000000000000..c7db2434f3bfde --- /dev/null +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from unittest.mock import patch + +from django.core.exceptions import ValidationError + +from sentry.shared_integrations.exceptions import ApiError +from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import control_silo_test + + +class BaseChannelValidateTest(APITestCase): + endpoint = "sentry-api-0-organization-integration-channel-validate" + method = "get" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def _get_response(self, integration_id, channel): + return self.get_success_response(self.organization.slug, integration_id, channel=channel) + + +@control_silo_test +class SlackChannelValidateTest(BaseChannelValidateTest): + def setUp(self): + super().setUp() + self.integration = self.create_integration( + organization=self.organization, + provider="slack", + name="Slack", + external_id="slack:1", + ) + + @patch( + "sentry.integrations.api.endpoints.organization_integration_channel_validate.get_channel_id" + ) + def test_channel_validation(self, mock_get_channel_id): + cases = [("#general", "C123", True), ("#missing", None, False)] + + for channel_name, channel_id, expected in cases: + mock_obj = type("SlackChannelData", (), {"channel_id": channel_id})() + mock_get_channel_id.return_value = mock_obj + resp = self._get_response(self.integration.id, channel_name) + assert resp.data["valid"] is expected + + +@control_silo_test +class MsTeamsChannelValidateTest(BaseChannelValidateTest): + def setUp(self): + super().setUp() + self.integration = self.create_integration( + organization=self.organization, + provider="msteams", + name="Teams", + external_id="teams:1", + ) + + @patch( + "sentry.integrations.api.endpoints.organization_integration_channel_validate.msteams_find_channel_id" + ) + def test_channel_validation(self, mock_find): + cases = [("General", "19:abc@thread.tacv2", True), ("Missing", None, False)] + + for channel_name, channel_id, expected in cases: + mock_find.return_value = channel_id + resp = self._get_response(self.integration.id, channel_name) + assert resp.data["valid"] is expected + + +@control_silo_test +class DiscordChannelValidateTest(BaseChannelValidateTest): + def setUp(self): + super().setUp() + self.integration = self.create_integration( + organization=self.organization, + provider="discord", + name="Discord Server", + external_id="1234567890", + ) + + @patch( + "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id" + ) + def test_discord_valid_numeric_id(self, mock_validate): + mock_validate.return_value = "123" + resp = self._get_response(self.integration.id, "123") + assert resp.data == {"valid": True} + mock_validate.assert_called_once() + + @patch( + "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id", + side_effect=ValidationError("bad"), + ) + def test_discord_invalid_plain_name(self, _mock_validate): + resp = self._get_response(self.integration.id, "alerts") + assert resp.data == {"valid": False} + + @patch( + "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id" + ) + @patch( + "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_get_channel_id_from_url" + ) + def test_discord_valid_url(self, mock_parse, mock_validate): + mock_parse.return_value = "123" + mock_validate.return_value = object() + resp = self._get_response(self.integration.id, "https://discord.com/channels/123/123") + assert resp.data == {"valid": True} + mock_parse.assert_called_once() + mock_validate.assert_called_once() + + @patch( + "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_get_channel_id_from_url", + side_effect=ValidationError("bad"), + ) + def test_discord_invalid_url_parse(self, _mock_parse): + resp = self._get_response(self.integration.id, "https://discord.com/channels/bad") + assert resp.data == {"valid": False} + + @patch( + "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id", + side_effect=ApiError("rate limited", code=429), + ) + def test_discord_validate_api_error(self, _mock_validate): + resp = self._get_response(self.integration.id, "123") + assert resp.data["valid"] is False + assert "rate limited" in resp.data.get("detail", "") + + +@control_silo_test +class ChannelValidateErrorCasesTest(BaseChannelValidateTest): + def test_missing_channel_param(self): + integration = self.create_integration( + organization=self.organization, provider="slack", name="Slack", external_id="slack:1" + ) + resp = self.get_error_response(self.organization.slug, integration.id, status_code=400) + assert "channel" in resp.data + + def test_integration_not_found(self): + resp = self.get_error_response(self.organization.slug, 99999, status_code=404, channel="#x") + assert resp.status_code == 404 + + def test_unsupported_provider(self): + integration = self.create_integration( + organization=self.organization, provider="github", name="GitHub", external_id="github:1" + ) + resp = self.get_error_response( + self.organization.slug, integration.id, status_code=400, channel="#x" + ) + assert resp.data["valid"] is False + assert "Unsupported provider" in resp.data.get("detail", "") From ae5083d37d1821fcaf0613db49264c3baac60077 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:51:34 +0000 Subject: [PATCH 2/3] :hammer_and_wrench: apply pre-commit fixes --- src/sentry/api/urls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 7c5d7a49441980..9feebd98185168 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -243,12 +243,12 @@ from sentry.integrations.api.endpoints.organization_config_integrations import ( OrganizationConfigIntegrationsEndpoint, ) -from sentry.integrations.api.endpoints.organization_integration_channels import ( - OrganizationIntegrationChannelsEndpoint, -) from sentry.integrations.api.endpoints.organization_integration_channel_validate import ( OrganizationIntegrationChannelValidateEndpoint, ) +from sentry.integrations.api.endpoints.organization_integration_channels import ( + OrganizationIntegrationChannelsEndpoint, +) from sentry.integrations.api.endpoints.organization_integration_details import ( OrganizationIntegrationDetailsEndpoint, ) From 375e89fa592d81858b59f266d6eafa3493165510 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 16 Oct 2025 06:58:54 +0200 Subject: [PATCH 3/3] all feedback --- ...ganization_integration_channel_validate.py | 54 ++++++++----------- static/app/data/controlsiloUrlPatterns.ts | 1 + ...ganization_integration_channel_validate.py | 3 +- 3 files changed, 24 insertions(+), 34 deletions(-) diff --git a/src/sentry/integrations/api/endpoints/organization_integration_channel_validate.py b/src/sentry/integrations/api/endpoints/organization_integration_channel_validate.py index 7d22e88d893158..cffcb9ba902716 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_channel_validate.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_channel_validate.py @@ -2,10 +2,12 @@ from typing import Any +import sentry_sdk from django.core.exceptions import ValidationError from rest_framework import serializers, status from rest_framework.request import Request from rest_framework.response import Response +from slack_sdk.errors import SlackApiError from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus @@ -48,48 +50,36 @@ def get( channel = serializer.validated_data["channel"].strip() integration = self.get_integration(organization_context.organization.id, integration_id) - try: - provider = integration.provider + provider = integration.provider + try: if provider == IntegrationProviderSlug.SLACK.value: channel_data = get_channel_id(integration=integration, channel_name=channel) - if channel_data.channel_id: - return Response({"valid": True}) - return Response({"valid": False}) + return Response({"valid": bool(channel_data.channel_id)}) - if provider == IntegrationProviderSlug.MSTEAMS.value: + elif provider == IntegrationProviderSlug.MSTEAMS.value: channel_id = msteams_find_channel_id(integration=integration, name=channel) - if channel_id: - return Response({"valid": True}) - return Response({"valid": False}) - - if provider == IntegrationProviderSlug.DISCORD.value: - try: - channel_id = ( - channel if channel.isdigit() else discord_get_channel_id_from_url(channel) - ) - except ValidationError: - return Response({"valid": False}) - - try: - discord_validate_channel_id( - channel_id=channel_id, - guild_id=str(integration.external_id), - guild_name=integration.name, - ) - except (ValidationError, ApiError) as e: - return Response( - {"valid": False, "detail": str(e) or "Discord channel validation failed"} - ) + return Response({"valid": bool(channel_id)}) + elif provider == IntegrationProviderSlug.DISCORD.value: + channel_id = ( + channel if channel.isdigit() else discord_get_channel_id_from_url(channel) + ) + discord_validate_channel_id( + channel_id=channel_id, + guild_id=str(integration.external_id), + guild_name=integration.name, + ) return Response({"valid": True}) return Response( {"valid": False, "detail": "Unsupported provider"}, status=status.HTTP_400_BAD_REQUEST, ) + + except (SlackApiError, ApiError, ValidationError): + return Response({"valid": False}) except Exception as e: - return Response( - {"valid": False, "detail": str(e) or "Unexpected error"}, - status=status.HTTP_400_BAD_REQUEST, - ) + sentry_sdk.capture_message(f"Unexpected {provider} channel validation error") + sentry_sdk.capture_exception(e) + return Response({"valid": False, "detail": "Unexpected error"}) diff --git a/static/app/data/controlsiloUrlPatterns.ts b/static/app/data/controlsiloUrlPatterns.ts index 5d85049ce3f38b..0411e86ad09bda 100644 --- a/static/app/data/controlsiloUrlPatterns.ts +++ b/static/app/data/controlsiloUrlPatterns.ts @@ -63,6 +63,7 @@ const patterns: RegExp[] = [ new RegExp('^api/0/organizations/[^/]+/integrations/$'), new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/$'), new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/channels/$'), + new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/channel-validate/$'), new RegExp('^api/0/organizations/[^/]+/sentry-app-installations/$'), new RegExp('^api/0/organizations/[^/]+/sentry-apps/$'), new RegExp('^api/0/organizations/[^/]+/sentry-app-components/$'), diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py index c7db2434f3bfde..f244a3b856d85d 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py @@ -124,8 +124,7 @@ def test_discord_invalid_url_parse(self, _mock_parse): ) def test_discord_validate_api_error(self, _mock_validate): resp = self._get_response(self.integration.id, "123") - assert resp.data["valid"] is False - assert "rate limited" in resp.data.get("detail", "") + assert resp.data == {"valid": False} @control_silo_test