Skip to content

Commit 353aa1e

Browse files
committed
feat(discord): Add API-driven pipeline backend for Discord integration setup
Implement get_pipeline_api_steps() on DiscordIntegrationProvider with a custom DiscordOAuthApiStep that handles Discord's unique OAuth flow (bot permissions in authorize URL, guild_id in callback). The step binds guild_id and code to pipeline state so build_integration() works unchanged for both legacy and API paths. Refs [VDY-83](https://linear.app/getsentry/issue/VDY-83/discord-api-driven-integration-setup)
1 parent 1093817 commit 353aa1e

File tree

2 files changed

+204
-2
lines changed

2 files changed

+204
-2
lines changed

src/sentry/integrations/discord/integration.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from django.http.request import HttpRequest
99
from django.http.response import HttpResponseBase
1010
from django.utils.translation import gettext_lazy as _
11+
from rest_framework.fields import CharField
1112

1213
from sentry import options
14+
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
1315
from sentry.constants import ObjectStatus
1416
from sentry.integrations.base import (
1517
FeatureDescription,
@@ -32,7 +34,8 @@
3234
)
3335
from sentry.notifications.platform.target import IntegrationNotificationTarget
3436
from sentry.organizations.services.organization.model import RpcOrganization
35-
from sentry.pipeline.views.base import PipelineView
37+
from sentry.pipeline.types import PipelineStepResult
38+
from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView
3639
from sentry.shared_integrations.exceptions import ApiError, IntegrationError
3740
from sentry.utils.http import absolute_uri
3841

@@ -141,6 +144,64 @@ def uninstall(self) -> None:
141144
return
142145

143146

147+
class DiscordOAuthApiSerializer(CamelSnakeSerializer):
148+
code = CharField(required=True)
149+
state = CharField(required=True)
150+
guild_id = CharField(required=True)
151+
152+
153+
class DiscordOAuthApiStep:
154+
"""API-mode OAuth step for Discord integration setup.
155+
156+
Discord's OAuth flow is unique: the authorize URL includes bot permissions,
157+
and the callback returns a guild_id alongside the authorization code.
158+
This step handles both, binding guild_id and code to pipeline state.
159+
"""
160+
161+
step_name = "oauth_login"
162+
163+
def __init__(
164+
self,
165+
client_id: str,
166+
permissions: int,
167+
scopes: frozenset[str],
168+
redirect_url: str,
169+
) -> None:
170+
self.client_id = client_id
171+
self.permissions = permissions
172+
self.scopes = scopes
173+
self.redirect_url = redirect_url
174+
175+
def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, str]:
176+
params = urlencode(
177+
{
178+
"client_id": self.client_id,
179+
"permissions": self.permissions,
180+
"scope": " ".join(self.scopes),
181+
"response_type": "code",
182+
"state": pipeline.signature,
183+
"redirect_uri": self.redirect_url,
184+
}
185+
)
186+
return {"oauthUrl": f"https://discord.com/api/oauth2/authorize?{params}"}
187+
188+
def get_serializer_cls(self) -> type:
189+
return DiscordOAuthApiSerializer
190+
191+
def handle_post(
192+
self,
193+
validated_data: dict[str, str],
194+
pipeline: IntegrationPipeline,
195+
request: HttpRequest,
196+
) -> PipelineStepResult:
197+
if validated_data["state"] != pipeline.signature:
198+
return PipelineStepResult.error("An error occurred while validating your request.")
199+
200+
pipeline.bind_state("guild_id", validated_data["guild_id"])
201+
pipeline.bind_state("code", validated_data["code"])
202+
return PipelineStepResult.advance()
203+
204+
144205
class DiscordIntegrationProvider(IntegrationProvider):
145206
key = IntegrationProviderSlug.DISCORD.value
146207
name = "Discord"
@@ -176,6 +237,16 @@ def __init__(self) -> None:
176237
def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]:
177238
return [DiscordInstallPipeline(self.get_params_for_oauth())]
178239

240+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
241+
return [
242+
DiscordOAuthApiStep(
243+
client_id=self.application_id,
244+
permissions=self.bot_permissions,
245+
scopes=self.oauth_scopes,
246+
redirect_url=self.setup_url,
247+
),
248+
]
249+
179250
def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
180251
guild_id = str(state.get("guild_id"))
181252

tests/sentry/integrations/discord/test_integration.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
14
from unittest import mock
25
from urllib.parse import parse_qs, urlencode, urlparse
36

47
import pytest
58
import responses
9+
from django.urls import reverse
610
from responses.matchers import header_matcher, json_params_matcher
711

812
from sentry import audit_log, options
@@ -18,6 +22,8 @@
1822
DiscordIntegrationProvider,
1923
)
2024
from sentry.integrations.models.integration import Integration
25+
from sentry.integrations.models.organization_integration import OrganizationIntegration
26+
from sentry.integrations.pipeline import IntegrationPipeline
2127
from sentry.models.auditlogentry import AuditLogEntry
2228
from sentry.notifications.platform.discord.provider import DiscordRenderable
2329
from sentry.notifications.platform.target import IntegrationNotificationTarget
@@ -30,7 +36,7 @@
3036
IntegrationConfigurationError,
3137
IntegrationError,
3238
)
33-
from sentry.testutils.cases import IntegrationTestCase, TestCase
39+
from sentry.testutils.cases import APITestCase, IntegrationTestCase, TestCase
3440
from sentry.testutils.silo import control_silo_test
3541
from sentry.utils import json
3642

@@ -552,3 +558,128 @@ def test_send_notification_api_error(self, mock_send: mock.MagicMock) -> None:
552558
self.installation.send_notification(target=self.target, payload=payload)
553559

554560
assert str(e.value) == error_payload
561+
562+
563+
@control_silo_test
564+
class DiscordApiPipelineTest(APITestCase):
565+
endpoint = "sentry-api-0-organization-pipeline"
566+
method = "post"
567+
568+
guild_id = "1234567890"
569+
guild_name = "Cool server"
570+
571+
def setUp(self) -> None:
572+
super().setUp()
573+
self.login_as(self.user)
574+
self.application_id = "application-id"
575+
self.public_key = "public-key"
576+
self.bot_token = "bot-token"
577+
self.client_secret = "client-secret"
578+
options.set("discord.application-id", self.application_id)
579+
options.set("discord.public-key", self.public_key)
580+
options.set("discord.bot-token", self.bot_token)
581+
options.set("discord.client-secret", self.client_secret)
582+
583+
def tearDown(self) -> None:
584+
responses.reset()
585+
super().tearDown()
586+
587+
def _get_pipeline_url(self) -> str:
588+
return reverse(
589+
self.endpoint,
590+
args=[self.organization.slug, IntegrationPipeline.pipeline_name],
591+
)
592+
593+
def _initialize_pipeline(self) -> Any:
594+
return self.client.post(
595+
self._get_pipeline_url(),
596+
data={"action": "initialize", "provider": "discord"},
597+
format="json",
598+
)
599+
600+
def _advance_step(self, data: dict[str, Any]) -> Any:
601+
return self.client.post(self._get_pipeline_url(), data=data, format="json")
602+
603+
def _get_pipeline_signature(self, resp: Any) -> str:
604+
return resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0]
605+
606+
@responses.activate
607+
def test_initialize_pipeline(self) -> None:
608+
resp = self._initialize_pipeline()
609+
assert resp.status_code == 200
610+
assert resp.data["step"] == "oauth_login"
611+
assert resp.data["stepIndex"] == 0
612+
assert resp.data["totalSteps"] == 1
613+
assert resp.data["provider"] == "discord"
614+
oauth_url = resp.data["data"]["oauthUrl"]
615+
assert "discord.com/api/oauth2/authorize" in oauth_url
616+
assert "permissions=" in oauth_url
617+
618+
parsed = urlparse(oauth_url)
619+
params = parse_qs(parsed.query)
620+
assert params["client_id"] == [self.application_id]
621+
assert params["permissions"] == [str(DiscordIntegrationProvider.bot_permissions)]
622+
requested_scopes = set(params["scope"][0].split(" "))
623+
assert requested_scopes == DiscordIntegrationProvider.oauth_scopes
624+
625+
@responses.activate
626+
def test_oauth_step_missing_guild_id(self) -> None:
627+
resp = self._initialize_pipeline()
628+
pipeline_signature = self._get_pipeline_signature(resp)
629+
resp = self._advance_step({"code": "auth-code", "state": pipeline_signature})
630+
assert resp.status_code == 400
631+
632+
@responses.activate
633+
@mock.patch("sentry.integrations.discord.client.DiscordClient.set_application_command")
634+
def test_full_pipeline_flow(self, mock_set_application_command: mock.MagicMock) -> None:
635+
responses.add(
636+
responses.GET,
637+
url=f"{DiscordClient.base_url}{GUILD_URL.format(guild_id=self.guild_id)}",
638+
match=[header_matcher({"Authorization": f"Bot {self.bot_token}"})],
639+
json={"id": self.guild_id, "name": self.guild_name},
640+
)
641+
responses.add(
642+
responses.GET,
643+
url=f"{DiscordClient.base_url}{APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}",
644+
match=[header_matcher({"Authorization": f"Bot {self.bot_token}"})],
645+
json=COMMANDS,
646+
)
647+
responses.add(
648+
responses.POST,
649+
url=f"{DISCORD_BASE_URL}/oauth2/token",
650+
json={"access_token": "access_token"},
651+
)
652+
responses.add(
653+
responses.GET,
654+
url=f"{DiscordClient.base_url}/users/@me",
655+
json={"id": "user_1234"},
656+
)
657+
responses.add(
658+
responses.GET,
659+
url=f"{DiscordClient.base_url}/users/@me/guilds/{self.guild_id}/member",
660+
json={},
661+
)
662+
663+
resp = self._initialize_pipeline()
664+
assert resp.data["step"] == "oauth_login"
665+
pipeline_signature = self._get_pipeline_signature(resp)
666+
667+
resp = self._advance_step(
668+
{
669+
"code": "discord-auth-code",
670+
"state": pipeline_signature,
671+
"guildId": self.guild_id,
672+
}
673+
)
674+
assert resp.status_code == 200
675+
assert resp.data["status"] == "complete"
676+
assert "data" in resp.data
677+
678+
integration = Integration.objects.get(provider="discord")
679+
assert integration.external_id == self.guild_id
680+
assert integration.name == self.guild_name
681+
682+
assert OrganizationIntegration.objects.filter(
683+
organization_id=self.organization.id,
684+
integration=integration,
685+
).exists()

0 commit comments

Comments
 (0)