Skip to content

Commit c092d1c

Browse files
alexsohn1126claude
andcommitted
test(slack): Add integration tests for staging sidegrade flow
Add SlackStagingSidegradeFlowTest using IntegrationTestCase to test the end-to-end sidegrade flow (production -> staging -> production). Reuse assert_setup_flow from SlackIntegrationTest with a new init_params parameter to support passing query params like use_staging=1 during the OAuth init step. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 740ade0 commit c092d1c

File tree

2 files changed

+274
-1
lines changed

2 files changed

+274
-1
lines changed

tests/sentry/integrations/slack/test_integration.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,19 @@ def assert_setup_flow(
5959
expected_client_id="slack-client-id",
6060
expected_client_secret="slack-client-secret",
6161
customer_domain=None,
62+
init_params=None,
6263
):
6364
responses.reset()
6465

6566
kwargs = {}
6667
if customer_domain:
6768
kwargs["HTTP_HOST"] = customer_domain
6869

69-
resp = self.client.get(self.init_path, **kwargs)
70+
init_path = self.init_path
71+
if init_params:
72+
init_path = f"{init_path}?{urlencode(init_params)}"
73+
74+
resp = self.client.get(init_path, **kwargs)
7075
assert resp.status_code == 302
7176
redirect = urlparse(resp["Location"])
7277
assert redirect.scheme == "https"
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
"""Tests for the Slack staging sidegrade feature (Option 4).
2+
3+
This feature allows organizations to sidegrade their production Slack integration
4+
to a staging app and vice versa, by swapping credentials in-place on the existing
5+
Integration row.
6+
"""
7+
8+
from unittest import mock
9+
from unittest.mock import MagicMock, patch
10+
from urllib.parse import parse_qs, urlencode, urlparse
11+
12+
import orjson
13+
import pytest
14+
import responses
15+
16+
from sentry import options
17+
from sentry.integrations.models.integration import Integration
18+
from sentry.integrations.slack import SlackIntegrationProvider
19+
from sentry.integrations.slack.requests.base import SlackRequest, SlackRequestError
20+
from sentry.integrations.slack.utils.auth import set_signing_secret
21+
from sentry.testutils.cases import IntegrationTestCase, TestCase
22+
from sentry.testutils.helpers import override_options
23+
from sentry.testutils.helpers.features import with_feature
24+
from sentry.testutils.silo import control_silo_test
25+
from tests.sentry.integrations.slack.test_integration import SlackIntegrationTest
26+
27+
STAGING_CLIENT_ID = "staging-client-id"
28+
STAGING_CLIENT_SECRET = "staging-client-secret"
29+
STAGING_SIGNING_SECRET = "staging-signing-secret"
30+
31+
STAGING_OPTIONS = {
32+
"slack-staging.client-id": STAGING_CLIENT_ID,
33+
"slack-staging.client-secret": STAGING_CLIENT_SECRET,
34+
"slack-staging.signing-secret": STAGING_SIGNING_SECRET,
35+
}
36+
37+
38+
@control_silo_test
39+
class SlackStagingSetupGatingTest(TestCase):
40+
"""Tests for the use_staging query param gating in OrganizationIntegrationSetupView."""
41+
42+
def setUp(self) -> None:
43+
super().setUp()
44+
self.organization = self.create_organization(name="foo", owner=self.user)
45+
self.login_as(self.user)
46+
self.path = f"/organizations/{self.organization.slug}/integrations/slack/setup/"
47+
48+
def test_use_staging_without_feature_flag_returns_404(self) -> None:
49+
resp = self.client.get(f"{self.path}?use_staging=1")
50+
assert resp.status_code == 404
51+
52+
@with_feature({"organizations:slack-staging-app": True})
53+
def test_use_staging_with_feature_flag_proceeds(self) -> None:
54+
resp = self.client.get(f"{self.path}?use_staging=1")
55+
assert resp.status_code == 302
56+
57+
def test_non_slack_provider_ignores_use_staging(self) -> None:
58+
path = f"/organizations/{self.organization.slug}/integrations/example/setup/"
59+
resp = self.client.get(f"{path}?use_staging=1")
60+
assert resp.status_code == 200
61+
62+
@with_feature({"organizations:slack-staging-app": True})
63+
def test_use_staging_value_must_be_1(self) -> None:
64+
"""Only use_staging=1 triggers staging mode, not other truthy values."""
65+
resp = self.client.get(f"{self.path}?use_staging=true")
66+
assert resp.status_code == 302
67+
redirect = urlparse(resp["Location"])
68+
params = parse_qs(redirect.query)
69+
assert params["client_id"] == [options.get("slack.client-id")]
70+
71+
72+
@control_silo_test
73+
class SlackIdentityProviderStagingTest(TestCase):
74+
"""Tests for SlackIdentityProvider staging credential selection."""
75+
76+
def _make_provider(self, use_staging=False):
77+
from sentry.identity.slack.provider import SlackIdentityProvider
78+
79+
provider = SlackIdentityProvider()
80+
if use_staging:
81+
provider.update_config({"use_staging": True})
82+
return provider
83+
84+
def test_returns_production_credentials_by_default(self) -> None:
85+
provider = self._make_provider()
86+
assert provider.get_oauth_client_id() == options.get("slack.client-id")
87+
assert provider.get_oauth_client_secret() == options.get("slack.client-secret")
88+
89+
@override_options(STAGING_OPTIONS)
90+
def test_returns_staging_credentials_when_use_staging(self) -> None:
91+
provider = self._make_provider(use_staging=True)
92+
assert provider.get_oauth_client_id() == STAGING_CLIENT_ID
93+
assert provider.get_oauth_client_secret() == STAGING_CLIENT_SECRET
94+
95+
@override_options(STAGING_OPTIONS)
96+
def test_use_staging_false_returns_production(self) -> None:
97+
from sentry.identity.slack.provider import SlackIdentityProvider
98+
99+
provider = SlackIdentityProvider()
100+
provider.update_config({"use_staging": False})
101+
assert provider.get_oauth_client_id() == options.get("slack.client-id")
102+
assert provider.get_oauth_client_secret() == options.get("slack.client-secret")
103+
104+
105+
@control_silo_test
106+
class SlackIntegrationProviderBuildIntegrationTest(TestCase):
107+
"""Tests for the installation_type field in build_integration()."""
108+
109+
def _build_integration(self, use_staging=False):
110+
provider = SlackIntegrationProvider()
111+
if use_staging:
112+
provider.config = {"use_staging": True}
113+
114+
state = {
115+
"identity": {
116+
"data": {
117+
"ok": True,
118+
"access_token": "xoxb-token",
119+
"scope": "chat:write,commands",
120+
"team": {"id": "TXXXXXXX1", "name": "Example"},
121+
"authed_user": {"id": "UXXXXXXX1"},
122+
}
123+
}
124+
}
125+
with patch(
126+
"slack_sdk.web.client.WebClient._perform_urllib_http_request",
127+
return_value={
128+
"body": orjson.dumps(
129+
{
130+
"ok": True,
131+
"team": {
132+
"domain": "test-workspace",
133+
"icon": {"image_132": "http://example.com/icon.jpg"},
134+
},
135+
}
136+
).decode(),
137+
"headers": {},
138+
"status": 200,
139+
},
140+
):
141+
return provider.build_integration(state)
142+
143+
def test_production_installation_type(self) -> None:
144+
result = self._build_integration(use_staging=False)
145+
assert result["metadata"]["installation_type"] == "born_as_bot"
146+
147+
def test_staging_installation_type(self) -> None:
148+
result = self._build_integration(use_staging=True)
149+
assert result["metadata"]["installation_type"] == "staging"
150+
151+
152+
@control_silo_test
153+
class SlackRequestStagingAuthTest(TestCase):
154+
"""Tests for SlackRequest.authorize() staging signing secret fallback."""
155+
156+
def _make_signed_request(self, secret: str) -> mock.Mock:
157+
request = mock.Mock()
158+
request.data = {
159+
"type": "foo",
160+
"team_id": "T001",
161+
"channel": {"id": "1"},
162+
"user": {"id": "2"},
163+
"api_app_id": "S1",
164+
}
165+
request.body = urlencode(request.data).encode("utf-8")
166+
request.META = set_signing_secret(secret, request.body)
167+
return request
168+
169+
def test_production_signing_secret_accepted(self) -> None:
170+
request = self._make_signed_request(options.get("slack.signing-secret"))
171+
SlackRequest(request).authorize()
172+
173+
@override_options(STAGING_OPTIONS)
174+
def test_staging_signing_secret_accepted(self) -> None:
175+
request = self._make_signed_request(STAGING_SIGNING_SECRET)
176+
SlackRequest(request).authorize()
177+
178+
def test_invalid_signing_secret_rejected(self) -> None:
179+
request = self._make_signed_request("totally-wrong-secret")
180+
with pytest.raises(SlackRequestError) as exc_info:
181+
SlackRequest(request).authorize()
182+
assert exc_info.value.status == 401
183+
184+
185+
@control_silo_test
186+
@patch(
187+
"slack_sdk.web.client.WebClient._perform_urllib_http_request",
188+
return_value={
189+
"body": orjson.dumps(
190+
{
191+
"ok": True,
192+
"team": {
193+
"domain": "test-slack-workspace",
194+
"icon": {"image_132": "http://example.com/ws_icon.jpg"},
195+
},
196+
}
197+
).decode(),
198+
"headers": {},
199+
"status": 200,
200+
},
201+
)
202+
class SlackStagingSidegradeFlowTest(IntegrationTestCase):
203+
"""Tests for the end-to-end sidegrade: production -> staging -> production."""
204+
205+
provider = SlackIntegrationProvider
206+
assert_setup_flow = SlackIntegrationTest.assert_setup_flow
207+
208+
@responses.activate
209+
@with_feature({"organizations:slack-staging-app": True})
210+
@override_options(STAGING_OPTIONS)
211+
def test_staging_flow_uses_staging_credentials(self, mock_api_call: MagicMock) -> None:
212+
"""The staging OAuth flow uses staging client ID/secret and sets installation_type."""
213+
with self.tasks():
214+
self.assert_setup_flow(
215+
expected_client_id=STAGING_CLIENT_ID,
216+
expected_client_secret=STAGING_CLIENT_SECRET,
217+
init_params={"use_staging": "1"},
218+
)
219+
220+
integration = Integration.objects.get(provider=self.provider.key)
221+
assert integration.metadata["installation_type"] == "staging"
222+
223+
@responses.activate
224+
@with_feature({"organizations:slack-staging-app": True})
225+
@override_options(STAGING_OPTIONS)
226+
def test_sidegrade_updates_existing_integration_in_place(
227+
self, mock_api_call: MagicMock
228+
) -> None:
229+
"""Sidegrading replaces credentials on the existing Integration row."""
230+
with self.tasks():
231+
self.assert_setup_flow()
232+
233+
integration = Integration.objects.get(provider=self.provider.key)
234+
assert integration.metadata["installation_type"] == "born_as_bot"
235+
original_id = integration.id
236+
237+
with self.tasks():
238+
self.assert_setup_flow(
239+
expected_client_id=STAGING_CLIENT_ID,
240+
expected_client_secret=STAGING_CLIENT_SECRET,
241+
init_params={"use_staging": "1"},
242+
)
243+
244+
integration.refresh_from_db()
245+
assert integration.id == original_id
246+
assert integration.metadata["installation_type"] == "staging"
247+
assert Integration.objects.filter(provider=self.provider.key).count() == 1
248+
249+
@responses.activate
250+
@with_feature({"organizations:slack-staging-app": True})
251+
@override_options(STAGING_OPTIONS)
252+
def test_sidegrade_back_to_production(self, mock_api_call: MagicMock) -> None:
253+
"""Re-installing without staging after a sidegrade restores production metadata."""
254+
with self.tasks():
255+
self.assert_setup_flow(
256+
expected_client_id=STAGING_CLIENT_ID,
257+
expected_client_secret=STAGING_CLIENT_SECRET,
258+
init_params={"use_staging": "1"},
259+
)
260+
261+
integration = Integration.objects.get(provider=self.provider.key)
262+
assert integration.metadata["installation_type"] == "staging"
263+
264+
with self.tasks():
265+
self.assert_setup_flow()
266+
267+
integration.refresh_from_db()
268+
assert integration.metadata["installation_type"] == "born_as_bot"

0 commit comments

Comments
 (0)