Skip to content
Merged
8 changes: 8 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@
from sentry.integrations.api.endpoints.organization_config_integrations import (
OrganizationConfigIntegrationsEndpoint,
)
from sentry.integrations.api.endpoints.organization_integration_channel_validate import (
OrganizationIntegrationChannelValidateEndpoint,
)
from sentry.integrations.api.endpoints.organization_integration_channels import (
OrganizationIntegrationChannelsEndpoint,
)
Expand Down Expand Up @@ -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<organization_id_or_slug>[^/]+)/integrations/(?P<integration_id>[^/]+)/channel-validate/$",
OrganizationIntegrationChannelValidateEndpoint.as_view(),
name="sentry-api-0-organization-integration-channel-validate",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/integrations/(?P<integration_id>[^/]+)/issues/$",
OrganizationIntegrationIssuesEndpoint.as_view(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from __future__ import annotations

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

provider = integration.provider

try:
if provider == IntegrationProviderSlug.SLACK.value:
channel_data = get_channel_id(integration=integration, channel_name=channel)
return Response({"valid": bool(channel_data.channel_id)})

elif provider == IntegrationProviderSlug.MSTEAMS.value:
channel_id = msteams_find_channel_id(integration=integration, name=channel)
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:
sentry_sdk.capture_message(f"Unexpected {provider} channel validation error")
sentry_sdk.capture_exception(e)
return Response({"valid": False, "detail": "Unexpected error"})
1 change: 1 addition & 0 deletions static/app/data/controlsiloUrlPatterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/$'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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": False}


@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", "")
Loading