Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Julia/discord setup url #61584

Merged
merged 13 commits into from
Dec 13, 2023
42 changes: 37 additions & 5 deletions src/sentry/integrations/discord/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Mapping, Sequence
from enum import Enum
from typing import Any
from urllib.parse import urlencode

import requests
from django.utils.translation import gettext_lazy as _
Expand All @@ -21,6 +22,7 @@
from sentry.pipeline.views.base import PipelineView
from sentry.services.hybrid_cloud.organization.model import RpcOrganizationSummary
from sentry.shared_integrations.exceptions import ApiError, IntegrationError
from sentry.utils import json
from sentry.utils.http import absolute_uri

from .utils import logger
Expand Down Expand Up @@ -152,23 +154,27 @@ def __init__(self) -> None:
self.client_secret = options.get("discord.client-secret")
self.client = DiscordClient()
self.setup_url = absolute_uri("extensions/discord/setup/")
self.configure_url = absolute_uri("extensions/discord/configure/")
super().__init__()

def get_pipeline_views(self) -> Sequence[PipelineView]:
return [DiscordInstallPipeline(self._get_bot_install_url())]

def build_integration(self, state: Mapping[str, object]) -> Mapping[str, object]:
guild_id = str(state.get("guild_id"))
use_setup = state.get("use_setup")
try:
guild_name = self.client.get_guild_name(guild_id=guild_id)
except (ApiError, AttributeError):
guild_name = guild_id

discord_user_id = self._get_discord_user_id(str(state.get("code")))
url = self.setup_url if use_setup == "1" else self.configure_url
discord_user_id = self._get_discord_user_id(str(state.get("code")), url)

return {
"name": guild_name,
"external_id": guild_id,
"use_setup": use_setup,
"user_identity": {
"type": "discord",
"external_id": discord_user_id,
Expand Down Expand Up @@ -212,7 +218,7 @@ def post_install(
)
raise ApiError(str(e))

def _get_discord_user_id(self, auth_code: str) -> str:
def _get_discord_user_id(self, auth_code: str, url: str) -> str:
"""
Helper function for completing the oauth2 flow and grabbing the
installing user's Discord user id so we can link their identities.
Expand All @@ -225,7 +231,7 @@ def _get_discord_user_id(self, auth_code: str) -> str:
integration.

"""
form_data = f"client_id={self.application_id}&client_secret={self.client_secret}&grant_type=authorization_code&code={auth_code}&redirect_uri={self.setup_url}"
form_data = f"client_id={self.application_id}&client_secret={self.client_secret}&grant_type=authorization_code&code={auth_code}&redirect_uri={url}"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
}
Expand Down Expand Up @@ -258,8 +264,21 @@ def _get_discord_user_id(self, auth_code: str) -> str:
)
raise IntegrationError("Could not retrieve Discord user information.")

def _get_bot_install_url(self):
return f"https://discord.com/api/oauth2/authorize?client_id={self.application_id}&permissions={self.bot_permissions}&redirect_uri={self.setup_url}&response_type=code&scope={' '.join(self.oauth_scopes)}"
def _get_bot_install_url(
self,
):
state = json.dumps({"useSetup": 1})
params = urlencode(
{
"client_id": self.application_id,
"permissions": self.bot_permissions,
"redirect_uri": self.setup_url,
"state": state,
"scope": " ".join(self.oauth_scopes),
"response_type": "code",
}
) # typing
return f"https://discord.com/api/oauth2/authorize?{params}"

def _credentials_exist(self) -> bool:
has_credentials = all(
Expand Down Expand Up @@ -289,4 +308,17 @@ def dispatch(self, request, pipeline):

pipeline.bind_state("guild_id", request.GET["guild_id"])
pipeline.bind_state("code", request.GET["code"])
try:
raw_state = json.loads(request.GET["state"])
pipeline.bind_state("use_setup", raw_state.get("useSetup"))
except Exception as error:
logger.info(
"identity.discord.request-token",
extra={
"state": request.GET("state"),
"guild_id": request.GET["guild_id"],
"code": request.GET["code"],
},
)
return pipeline.error(error)
return pipeline.next_step()
6 changes: 6 additions & 0 deletions src/sentry/integrations/discord/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,10 @@
DiscordExtensionConfigurationView.as_view(),
name="discord-extension-configuration",
),
# Install flow from Sentry
re_path(
r"^setup/$",
DiscordExtensionConfigurationView.as_view(),
name="discord-integration-setup",
),
]
43 changes: 38 additions & 5 deletions tests/sentry/integrations/discord/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from sentry.models.integrations.integration import Integration
from sentry.shared_integrations.exceptions import IntegrationError
from sentry.testutils.cases import IntegrationTestCase
from sentry.utils import json


class DiscordIntegrationTest(IntegrationTestCase):
Expand All @@ -36,8 +37,10 @@ def assert_setup_flow(
guild_id="1234567890",
server_name="Cool server",
auth_code="auth_code",
useSetup=None,
command_response_empty=True,
):
state = json.dumps(useSetup if useSetup else {"useSetup": 1})
responses.reset()

resp = self.client.get(self.init_path)
Expand Down Expand Up @@ -94,7 +97,10 @@ def assert_setup_flow(
)

resp = self.client.get(
"{}?{}".format(self.setup_path, urlencode({"guild_id": guild_id, "code": auth_code}))
"{}?{}".format(
self.setup_path,
urlencode({"guild_id": guild_id, "code": auth_code, "state": state}),
)
)

call_list = responses.calls
Expand All @@ -110,6 +116,13 @@ def assert_setup_flow(
else:
assert mock_set_application_command.call_count == 0

@responses.activate
def test_bot_flow_raises_error(self):
with self.tasks():
bad_state = "{"
with pytest.raises(TypeError):
self.assert_setup_flow(useSetup=bad_state)

@responses.activate
def test_bot_flow(self):
with self.tasks():
Expand Down Expand Up @@ -210,7 +223,7 @@ def test_get_discord_user_id(self):
responses.GET, url=f"{DiscordClient.base_url}/users/@me", json={"id": user_id}
)

result = provider._get_discord_user_id("auth_code")
result = provider._get_discord_user_id("auth_code", "1")

assert result == user_id

Expand All @@ -219,7 +232,7 @@ def test_get_discord_user_id_oauth_failure(self):
provider = self.provider()
responses.add(responses.POST, url="https://discord.com/api/v10/oauth2/token", status=500)
with pytest.raises(IntegrationError):
provider._get_discord_user_id("auth_code")
provider._get_discord_user_id("auth_code", "1")

@responses.activate
def test_get_discord_user_id_oauth_no_token(self):
Expand All @@ -230,7 +243,7 @@ def test_get_discord_user_id_oauth_no_token(self):
json={},
)
with pytest.raises(IntegrationError):
provider._get_discord_user_id("auth_code")
provider._get_discord_user_id("auth_code", "1")

@responses.activate
def test_get_discord_user_id_request_fail(self):
Expand All @@ -248,7 +261,27 @@ def test_get_discord_user_id_request_fail(self):
status=401,
)
with pytest.raises(IntegrationError):
provider._get_discord_user_id("auth_code")
provider._get_discord_user_id("auth_code", "1")

@responses.activate
@mock.patch("sentry.integrations.discord.client.DiscordClient.set_application_command")
def test_post_install(self, mock_set_application_command):
provider = self.provider()

responses.add(
responses.GET,
url=f"{DiscordClient.base_url}{APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}",
match=[header_matcher({"Authorization": f"Bot {self.bot_token}"})],
json=[],
)
responses.add(
responses.POST,
url=f"{DiscordClient.base_url}{APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}",
status=200,
)

provider.post_install(integration=self.integration, organization=self.organization)
assert mock_set_application_command.call_count == 3 # one for each command

@mock.patch("sentry.integrations.discord.client.DiscordClient.set_application_command")
def test_post_install_missing_credentials(self, mock_set_application_command):
Expand Down
Loading