33from typing import Any
44from unittest import mock
55
6+ import jwt as pyjwt
67import responses
78
89from sentry .integrations .bitbucket .installed import BitbucketInstalledEndpoint
910from sentry .integrations .bitbucket .integration import BitbucketIntegrationProvider , scopes
1011from sentry .integrations .models .integration import Integration
12+ from sentry .integrations .utils .atlassian_connect import get_query_hash
1113from sentry .models .project import Project
1214from sentry .models .repository import Repository
1315from sentry .organizations .services .organization .serial import serialize_rpc_organization
1618from sentry .silo .base import SiloMode
1719from sentry .testutils .cases import APITestCase
1820from sentry .testutils .silo import assume_test_silo_mode , control_silo_test
21+ from sentry .utils .http import absolute_uri
22+ from tests .sentry .utils .test_jwt import RS256_KEY , RS256_PUB_KEY
1923
2024
2125class BitbucketPlugin (IssueTrackingPlugin2 ):
@@ -29,6 +33,7 @@ class BitbucketInstalledEndpointTest(APITestCase):
2933 def setUp (self ) -> None :
3034 self .provider = "bitbucket"
3135 self .path = "/extensions/bitbucket/installed/"
36+ self .kid = "bitbucket-kid"
3237 self .username = "sentryuser"
3338 self .client_key = "connection:123"
3439 self .public_key = "123abcDEFg"
@@ -98,27 +103,118 @@ def tearDown(self) -> None:
98103 plugins .unregister (BitbucketPlugin )
99104 super ().tearDown ()
100105
106+ def jwt_token_cdn (self ) -> str :
107+ return pyjwt .encode (
108+ {
109+ "iss" : self .client_key ,
110+ "aud" : absolute_uri (),
111+ "qsh" : get_query_hash (self .path , method = "POST" , query_params = {}),
112+ },
113+ RS256_KEY ,
114+ algorithm = "RS256" ,
115+ headers = {"kid" : self .kid , "alg" : "RS256" },
116+ )
117+
118+ def add_cdn_response (self ) -> None :
119+ responses .add (
120+ responses .GET ,
121+ f"https://connect-install-keys.atlassian.com/{ self .kid } " ,
122+ body = RS256_PUB_KEY ,
123+ )
124+
101125 def test_default_permissions (self ) -> None :
102126 # Permissions must be empty so that it will be accessible to bitbucket.
103127 assert BitbucketInstalledEndpoint .authentication_classes == ()
104128 assert BitbucketInstalledEndpoint .permission_classes == ()
105129
106- def test_installed_with_public_key (self ) -> None :
130+ def test_missing_token (self ) -> None :
107131 response = self .client .post (self .path , data = self .team_data_from_bitbucket )
132+ assert response .status_code == 400
133+
134+ def test_invalid_token (self ) -> None :
135+ response = self .client .post (
136+ self .path ,
137+ data = self .team_data_from_bitbucket ,
138+ HTTP_AUTHORIZATION = "invalid" ,
139+ )
140+ assert response .status_code == 400
141+
142+ @responses .activate
143+ def test_missing_key_id (self ) -> None :
144+ token = pyjwt .encode (
145+ {
146+ "iss" : self .client_key ,
147+ "aud" : absolute_uri (),
148+ "qsh" : get_query_hash (self .path , method = "POST" , query_params = {}),
149+ },
150+ RS256_KEY ,
151+ algorithm = "RS256" ,
152+ headers = {"alg" : "RS256" },
153+ )
154+ response = self .client .post (
155+ self .path ,
156+ data = self .team_data_from_bitbucket ,
157+ HTTP_AUTHORIZATION = f"JWT { token } " ,
158+ )
159+ assert response .status_code == 400
160+
161+ @responses .activate
162+ def test_invalid_key_id (self ) -> None :
163+ token = pyjwt .encode (
164+ {
165+ "iss" : self .client_key ,
166+ "aud" : absolute_uri (),
167+ "qsh" : get_query_hash (self .path , method = "POST" , query_params = {}),
168+ },
169+ RS256_KEY ,
170+ algorithm = "RS256" ,
171+ headers = {"kid" : "fake-kid" , "alg" : "RS256" },
172+ )
173+ response = self .client .post (
174+ self .path ,
175+ data = self .team_data_from_bitbucket ,
176+ HTTP_AUTHORIZATION = f"JWT { token } " ,
177+ )
178+ assert response .status_code == 400
179+
180+ @responses .activate
181+ def test_jwt_issuer_mismatch (self ) -> None :
182+ self .add_cdn_response ()
183+ response = self .client .post (
184+ self .path ,
185+ data = {** self .team_data_from_bitbucket , "clientKey" : "different-client-key" },
186+ HTTP_AUTHORIZATION = f"JWT { self .jwt_token_cdn ()} " ,
187+ )
188+ assert response .status_code == 400
189+
190+ @responses .activate
191+ def test_installed_with_public_key (self ) -> None :
192+ self .add_cdn_response ()
193+ response = self .client .post (
194+ self .path ,
195+ data = self .team_data_from_bitbucket ,
196+ HTTP_AUTHORIZATION = f"JWT { self .jwt_token_cdn ()} " ,
197+ )
108198 assert response .status_code == 200
109199 integration = Integration .objects .get (provider = self .provider , external_id = self .client_key )
110200 assert integration .name == self .username
111201 del integration .metadata ["webhook_secret" ]
112202 assert integration .metadata == self .metadata
113203
204+ @responses .activate
114205 def test_installed_without_public_key (self ) -> None :
115206 integration , created = Integration .objects .get_or_create (
116207 provider = self .provider ,
117208 external_id = self .client_key ,
118209 defaults = {"name" : self .user_display_name , "metadata" : self .user_metadata },
119210 )
120211 del self .user_data_from_bitbucket ["principal" ]["username" ]
121- response = self .client .post (self .path , data = self .user_data_from_bitbucket )
212+ self .add_cdn_response ()
213+ response = self .client .post (
214+ self .path ,
215+ data = self .user_data_from_bitbucket ,
216+ HTTP_AUTHORIZATION = f"JWT { self .jwt_token_cdn ()} " ,
217+ )
122218 assert response .status_code == 200
123219
124220 # assert no changes have been made to the integration
@@ -129,22 +225,34 @@ def test_installed_without_public_key(self) -> None:
129225 del integration_after .metadata ["webhook_secret" ]
130226 assert integration .metadata == integration_after .metadata
131227
228+ @responses .activate
132229 def test_installed_without_username (self ) -> None :
133230 """Test a user (not team) installation where the user has hidden their username from public view"""
134231
135232 # Remove username to simulate privacy mode
136233 del self .user_data_from_bitbucket ["principal" ]["username" ]
137234
138- response = self .client .post (self .path , data = self .user_data_from_bitbucket )
235+ self .add_cdn_response ()
236+ response = self .client .post (
237+ self .path ,
238+ data = self .user_data_from_bitbucket ,
239+ HTTP_AUTHORIZATION = f"JWT { self .jwt_token_cdn ()} " ,
240+ )
139241 assert response .status_code == 200
140242 integration = Integration .objects .get (provider = self .provider , external_id = self .client_key )
141243 assert integration .name == self .user_display_name
142244 del integration .metadata ["webhook_secret" ]
143245 assert integration .metadata == self .user_metadata
144246
247+ @responses .activate
145248 @mock .patch ("sentry.integrations.bitbucket.integration.generate_token" , return_value = "0" * 64 )
146249 def test_installed_with_secret (self , mock_generate_token : mock .MagicMock ) -> None :
147- response = self .client .post (self .path , data = self .team_data_from_bitbucket )
250+ self .add_cdn_response ()
251+ response = self .client .post (
252+ self .path ,
253+ data = self .team_data_from_bitbucket ,
254+ HTTP_AUTHORIZATION = f"JWT { self .jwt_token_cdn ()} " ,
255+ )
148256 assert mock_generate_token .called
149257 assert response .status_code == 200
150258 integration = Integration .objects .get (provider = self .provider , external_id = self .client_key )
@@ -172,7 +280,12 @@ def test_plugin_migration(self) -> None:
172280 config = {"name" : "otheruser/otherrepo" },
173281 )
174282
175- self .client .post (self .path , data = self .team_data_from_bitbucket )
283+ self .add_cdn_response ()
284+ self .client .post (
285+ self .path ,
286+ data = self .team_data_from_bitbucket ,
287+ HTTP_AUTHORIZATION = f"JWT { self .jwt_token_cdn ()} " ,
288+ )
176289
177290 integration = Integration .objects .get (provider = self .provider , external_id = self .client_key )
178291
@@ -219,7 +332,12 @@ def test_disable_plugin_when_fully_migrated(self) -> None:
219332 config = {"name" : "sentryuser/repo" },
220333 )
221334
222- self .client .post (self .path , data = self .team_data_from_bitbucket )
335+ self .add_cdn_response ()
336+ self .client .post (
337+ self .path ,
338+ data = self .team_data_from_bitbucket ,
339+ HTTP_AUTHORIZATION = f"JWT { self .jwt_token_cdn ()} " ,
340+ )
223341
224342 integration = Integration .objects .get (provider = self .provider , external_id = self .client_key )
225343
0 commit comments