Skip to content

Commit 52cf128

Browse files
committed
feat(integrations): Add API driven pipeline for Slack
Ref VDY-42
1 parent cbb90a8 commit 52cf128

File tree

3 files changed

+146
-14
lines changed

3 files changed

+146
-14
lines changed

src/sentry/identity/oauth2.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -147,22 +147,24 @@ def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]:
147147
),
148148
]
149149

150-
def get_pipeline_api_steps(self) -> list[OAuth2ApiStep]:
150+
def make_oauth_api_step(self, **kwargs: Any) -> OAuth2ApiStep:
151151
redirect_url = self.config.get(
152152
"redirect_url",
153153
reverse("sentry-extension-setup", kwargs={"provider_id": "default"}),
154154
)
155-
return [
156-
OAuth2ApiStep(
157-
authorize_url=self.get_oauth_authorize_url(),
158-
client_id=self.get_oauth_client_id(),
159-
client_secret=self.get_oauth_client_secret(),
160-
access_token_url=self.get_oauth_access_token_url(),
161-
scope=" ".join(self.get_oauth_scopes()),
162-
redirect_url=redirect_url,
163-
verify_ssl=self.config.get("verify_ssl", True),
164-
),
165-
]
155+
return OAuth2ApiStep(
156+
authorize_url=self.get_oauth_authorize_url(),
157+
client_id=self.get_oauth_client_id(),
158+
client_secret=self.get_oauth_client_secret(),
159+
access_token_url=self.get_oauth_access_token_url(),
160+
scope=" ".join(self.get_oauth_scopes()),
161+
redirect_url=redirect_url,
162+
verify_ssl=self.config.get("verify_ssl", True),
163+
**kwargs,
164+
)
165+
166+
def get_pipeline_api_steps(self) -> list[OAuth2ApiStep]:
167+
return [self.make_oauth_api_step()]
166168

167169
def get_refresh_token_params(
168170
self, refresh_token: str, identity: Identity | RpcIdentity, **kwargs: Any

src/sentry/integrations/slack/integration.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from slack_sdk.errors import SlackApiError
1111

1212
from sentry.identity.pipeline import IdentityPipeline
13+
from sentry.identity.slack.provider import SlackIdentityProvider
1314
from sentry.integrations.base import (
1415
FeatureDescription,
1516
IntegrationData,
@@ -36,7 +37,7 @@
3637
)
3738
from sentry.notifications.platform.target import IntegrationNotificationTarget
3839
from sentry.organizations.services.organization.model import RpcOrganization
39-
from sentry.pipeline.views.base import PipelineView
40+
from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView
4041
from sentry.pipeline.views.nested import NestedPipelineView
4142
from sentry.shared_integrations.exceptions import IntegrationError
4243
from sentry.utils.http import absolute_uri
@@ -338,6 +339,20 @@ def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]:
338339
def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]:
339340
return [self._identity_pipeline_view()]
340341

342+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
343+
provider = SlackIdentityProvider(
344+
config={
345+
"oauth_scopes": self._get_oauth_scopes(),
346+
"redirect_url": absolute_uri("/extensions/slack/setup/"),
347+
}
348+
)
349+
return [
350+
provider.make_oauth_api_step(
351+
bind_key="oauth_data",
352+
extra_authorize_params={"user_scope": " ".join(self.user_scopes)},
353+
),
354+
]
355+
341356
def _get_team_info(self, access_token: str) -> Any:
342357
# Manually add authorization since this method is part of slack installation
343358

@@ -352,7 +367,13 @@ def _get_team_info(self, access_token: str) -> Any:
352367
raise IntegrationError("Could not retrieve Slack team information.")
353368

354369
def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
355-
data = state["identity"]["data"]
370+
# TODO: legacy views write token data to state["identity"]["data"] via
371+
# NestedPipelineView. API steps write directly to state["oauth_data"].
372+
# Remove the legacy path once the old views are retired.
373+
if "oauth_data" in state:
374+
data = state["oauth_data"]
375+
else:
376+
data = state["identity"]["data"]
356377
assert data["ok"]
357378

358379
access_token = data["access_token"]

tests/sentry/integrations/slack/test_integration.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
14
from unittest.mock import MagicMock, patch
25
from urllib.parse import parse_qs, urlencode, urlparse
36

47
import orjson
58
import pytest
69
import responses
10+
from django.urls import reverse
711
from responses.matchers import query_string_matcher
812
from slack_sdk.errors import SlackApiError
913
from slack_sdk.web import SlackResponse
1014

1115
from sentry import audit_log
1216
from sentry.integrations.models.integration import Integration
1317
from sentry.integrations.models.organization_integration import OrganizationIntegration
18+
from sentry.integrations.pipeline import IntegrationPipeline
1419
from sentry.integrations.slack import SlackIntegration, SlackIntegrationProvider
1520
from sentry.integrations.slack.utils.constants import SlackScope
1621
from sentry.integrations.slack.utils.users import SLACK_GET_USERS_PAGE_SIZE
@@ -653,3 +658,107 @@ def test_get_thread_history_error_returns_empty_list(
653658
channel_id=self.channel_id, thread_ts=self.thread_ts
654659
)
655660
assert result == []
661+
662+
663+
@control_silo_test
664+
class SlackApiPipelineTest(APITestCase):
665+
endpoint = "sentry-api-0-organization-pipeline"
666+
method = "post"
667+
668+
def setUp(self) -> None:
669+
super().setUp()
670+
self.login_as(self.user)
671+
672+
def tearDown(self) -> None:
673+
responses.reset()
674+
super().tearDown()
675+
676+
def _get_pipeline_url(self) -> str:
677+
return reverse(
678+
self.endpoint,
679+
args=[self.organization.slug, IntegrationPipeline.pipeline_name],
680+
)
681+
682+
def _initialize_pipeline(self) -> Any:
683+
return self.client.post(
684+
self._get_pipeline_url(),
685+
data={"action": "initialize", "provider": "slack"},
686+
format="json",
687+
)
688+
689+
def _advance_step(self, data: dict[str, Any]) -> Any:
690+
return self.client.post(self._get_pipeline_url(), data=data, format="json")
691+
692+
def _get_pipeline_signature(self, resp: Any) -> str:
693+
return resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0]
694+
695+
@responses.activate
696+
def test_initialize_pipeline(self) -> None:
697+
resp = self._initialize_pipeline()
698+
assert resp.status_code == 200
699+
assert resp.data["step"] == "oauth_login"
700+
assert resp.data["stepIndex"] == 0
701+
assert resp.data["totalSteps"] == 1
702+
assert resp.data["provider"] == "slack"
703+
oauth_url = resp.data["data"]["oauthUrl"]
704+
assert "slack.com/oauth/v2/authorize" in oauth_url
705+
assert "user_scope=" in oauth_url
706+
707+
@responses.activate
708+
def test_oauth_step_missing_code(self) -> None:
709+
self._initialize_pipeline()
710+
resp = self._advance_step({})
711+
assert resp.status_code == 400
712+
713+
@responses.activate
714+
@patch("sentry.integrations.slack.integration.WebClient")
715+
def test_full_pipeline_flow(self, mock_web_client_cls: MagicMock) -> None:
716+
mock_client = MagicMock()
717+
mock_web_client_cls.return_value = mock_client
718+
mock_client.team_info.return_value = SlackResponse(
719+
client=mock_client,
720+
http_verb="GET",
721+
api_url="https://slack.com/api/team.info",
722+
req_args={},
723+
data={
724+
"ok": True,
725+
"team": {
726+
"name": "Test Team",
727+
"id": "T1234",
728+
"domain": "test-team",
729+
"icon": {"image_132": "https://example.com/icon.png"},
730+
},
731+
},
732+
headers={},
733+
status_code=200,
734+
)
735+
736+
responses.add(
737+
responses.POST,
738+
"https://slack.com/api/oauth.v2.access",
739+
json={
740+
"ok": True,
741+
"access_token": "xoxb-test-token",
742+
"scope": "channels:read,chat:write",
743+
"team": {"name": "Test Team", "id": "T1234"},
744+
"authed_user": {"id": "U1234"},
745+
},
746+
)
747+
748+
resp = self._initialize_pipeline()
749+
assert resp.data["step"] == "oauth_login"
750+
pipeline_signature = self._get_pipeline_signature(resp)
751+
752+
resp = self._advance_step({"code": "slack-auth-code", "state": pipeline_signature})
753+
assert resp.status_code == 200
754+
assert resp.data["status"] == "complete"
755+
assert "data" in resp.data
756+
757+
integration = Integration.objects.get(provider="slack")
758+
assert integration.external_id == "T1234"
759+
assert integration.name == "Test Team"
760+
761+
assert OrganizationIntegration.objects.filter(
762+
organization_id=self.organization.id,
763+
integration=integration,
764+
).exists()

0 commit comments

Comments
 (0)