test_webhooks.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. from __future__ import annotations
  2. from unittest.mock import MagicMock, patch
  3. import responses
  4. from django.test.utils import override_settings
  5. from rest_framework import status
  6. from rest_framework.exceptions import MethodNotAllowed
  7. from rest_framework.response import Response
  8. from fixtures.integrations.stub_service import StubService
  9. from sentry.integrations.jira.webhooks.base import JiraTokenError, JiraWebhookBase
  10. from sentry.integrations.mixins import IssueSyncMixin
  11. from sentry.integrations.utils import AtlassianConnectValidationError
  12. from sentry.services.hybrid_cloud.integration.serial import serialize_integration
  13. from sentry.services.hybrid_cloud.organization.serial import serialize_rpc_organization
  14. from sentry.shared_integrations.exceptions import ApiError
  15. from sentry.testutils.cases import APITestCase, TestCase
  16. from sentry.testutils.silo import region_silo_test
  17. TOKEN = "JWT anexampletoken"
  18. @region_silo_test
  19. class JiraIssueUpdatedWebhookTest(APITestCase):
  20. endpoint = "sentry-extensions-jira-issue-updated"
  21. method = "post"
  22. def setUp(self):
  23. super().setUp()
  24. integration, _ = self.create_provider_integration_for(
  25. organization=self.organization,
  26. user=self.user,
  27. provider="jira",
  28. name="Example Jira",
  29. metadata={
  30. "oauth_client_id": "oauth-client-id",
  31. "shared_secret": "a-super-secret-key-from-atlassian",
  32. "base_url": "https://example.atlassian.net",
  33. "domain_name": "example.atlassian.net",
  34. },
  35. )
  36. # Ensure this is region safe, and doesn't require the ORM integration model
  37. self.integration = serialize_integration(integration=integration)
  38. @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
  39. def test_simple_assign(self, mock_sync_group_assignee_inbound):
  40. with patch(
  41. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  42. return_value=self.integration,
  43. ):
  44. data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
  45. self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
  46. mock_sync_group_assignee_inbound.assert_called_with(
  47. self.integration, "jess@sentry.io", "APP-123", assign=True
  48. )
  49. @override_settings(JIRA_USE_EMAIL_SCOPE=True)
  50. @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
  51. @responses.activate
  52. def test_assign_use_email_api(self, mock_sync_group_assignee_inbound):
  53. responses.add(
  54. responses.GET,
  55. "https://example.atlassian.net/rest/api/3/user/email",
  56. json={"accountId": "deadbeef123", "email": self.user.email},
  57. )
  58. with patch(
  59. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  60. return_value=self.integration,
  61. ):
  62. data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
  63. data["issue"]["fields"]["assignee"]["emailAddress"] = ""
  64. self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
  65. assert mock_sync_group_assignee_inbound.called
  66. assert len(responses.calls) == 1
  67. @override_settings(JIRA_USE_EMAIL_SCOPE=True)
  68. @responses.activate
  69. def test_assign_use_email_api_error(self):
  70. responses.add(
  71. responses.GET,
  72. "https://example.atlassian.net/rest/api/3/user/email",
  73. status=500,
  74. )
  75. with patch(
  76. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  77. return_value=self.integration,
  78. ):
  79. data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
  80. data["issue"]["fields"]["assignee"]["emailAddress"] = ""
  81. response = self.get_success_response(
  82. **data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)
  83. )
  84. assert "error_message" in response.data
  85. @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
  86. def test_assign_missing_email(self, mock_sync_group_assignee_inbound):
  87. with patch(
  88. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  89. return_value=self.integration,
  90. ):
  91. data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
  92. data["issue"]["fields"]["assignee"]["emailAddress"] = ""
  93. self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
  94. assert not mock_sync_group_assignee_inbound.called
  95. @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
  96. def test_simple_deassign(self, mock_sync_group_assignee_inbound):
  97. with patch(
  98. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  99. return_value=self.integration,
  100. ):
  101. data = StubService.get_stub_data("jira", "edit_issue_no_assignee_payload.json")
  102. self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
  103. mock_sync_group_assignee_inbound.assert_called_with(
  104. self.integration, None, "APP-123", assign=False
  105. )
  106. @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
  107. def test_simple_deassign_assignee_missing(self, mock_sync_group_assignee_inbound):
  108. with patch(
  109. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  110. return_value=self.integration,
  111. ):
  112. data = StubService.get_stub_data("jira", "edit_issue_assignee_missing_payload.json")
  113. self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
  114. mock_sync_group_assignee_inbound.assert_called_with(
  115. self.integration, None, "APP-123", assign=False
  116. )
  117. @patch.object(IssueSyncMixin, "sync_status_inbound")
  118. def test_simple_status_sync_inbound(self, mock_sync_status_inbound):
  119. with patch(
  120. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  121. return_value=self.integration,
  122. ) as mock_get_integration_from_jwt:
  123. data = StubService.get_stub_data("jira", "edit_issue_status_payload.json")
  124. self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
  125. mock_get_integration_from_jwt.assert_called_with(
  126. token="anexampletoken",
  127. path="/extensions/jira/issue-updated/",
  128. provider="jira",
  129. query_params={},
  130. method="POST",
  131. )
  132. mock_sync_status_inbound.assert_called_with(
  133. "APP-123",
  134. {
  135. "changelog": {
  136. "from": "10101",
  137. "field": "status",
  138. "fromString": "Done",
  139. "to": "3",
  140. "toString": "In Progress",
  141. "fieldtype": "jira",
  142. "fieldId": "status",
  143. },
  144. "issue": {
  145. "fields": {"project": {"id": "10000", "key": "APP"}},
  146. "key": "APP-123",
  147. },
  148. },
  149. )
  150. @patch("sentry_sdk.set_tag")
  151. @patch("sentry.integrations.utils.scope.bind_organization_context")
  152. def test_adds_context_data(self, mock_bind_org_context: MagicMock, mock_set_tag: MagicMock):
  153. with patch(
  154. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  155. return_value=self.integration,
  156. ):
  157. data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
  158. self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
  159. mock_set_tag.assert_called_with("integration_id", self.integration.id)
  160. mock_bind_org_context.assert_called_with(serialize_rpc_organization(self.organization))
  161. def test_missing_changelog(self):
  162. with patch(
  163. "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
  164. return_value=self.integration,
  165. ):
  166. data = StubService.get_stub_data("jira", "changelog_missing.json")
  167. self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
  168. class MockErroringJiraEndpoint(JiraWebhookBase):
  169. permission_classes = ()
  170. dummy_exception = Exception("whoops")
  171. # In order to be able to use `as_view`'s `initkwargs` (in other words, in order to be able to
  172. # pass kwargs to `as_view` and have `as_view` pass them onto the `__init__` method below), any
  173. # kwarg we'd like to pass must already be an attibute of the class
  174. error = BaseException("unreachable")
  175. def __init__(self, error: Exception = dummy_exception, *args, **kwargs):
  176. # We allow the error to be passed in so that we have access to it in the test for use
  177. # in equality checks
  178. self.error = error
  179. super().__init__(*args, **kwargs)
  180. def get(self, request):
  181. raise self.error
  182. class JiraWebhookBaseTest(TestCase):
  183. @patch("sentry.utils.sdk.capture_exception")
  184. def test_bad_request_errors(self, mock_capture_exception: MagicMock):
  185. for error_type in [AtlassianConnectValidationError, JiraTokenError]:
  186. mock_endpoint = MockErroringJiraEndpoint.as_view(error=error_type())
  187. request = self.make_request(method="GET")
  188. response = mock_endpoint(request)
  189. assert response.status_code == status.HTTP_409_CONFLICT
  190. # This kind of error shouldn't be sent to Sentry
  191. assert mock_capture_exception.call_count == 0
  192. @patch("sentry.integrations.jira.webhooks.base.logger")
  193. @patch("sentry.utils.sdk.capture_exception")
  194. def test_atlassian_pen_testing_bot(
  195. self, mock_capture_exception: MagicMock, mock_logger: MagicMock
  196. ):
  197. mock_endpoint = MockErroringJiraEndpoint.as_view(error=MethodNotAllowed("GET"))
  198. request = self.make_request(method="GET")
  199. request.META[
  200. "HTTP_USER_AGENT"
  201. ] = "CSRT (github.com/atlassian-labs/connect-security-req-tester)"
  202. response = mock_endpoint(request)
  203. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  204. assert (
  205. mock_logger.info.call_args.args[0]
  206. == "Atlassian Connect Security Request Tester tried disallowed method"
  207. )
  208. # This kind of error shouldn't be sent to Sentry
  209. assert mock_capture_exception.call_count == 0
  210. @patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
  211. def test_APIError_host_and_path_added_as_tags(self, mock_super_handle_exception: MagicMock):
  212. handler_error = ApiError("", url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks")
  213. mock_endpoint = MockErroringJiraEndpoint.as_view(error=handler_error)
  214. request = self.make_request(method="GET")
  215. mock_endpoint(request)
  216. # signature is super().handle_exception(request, error, handler_context, scope)
  217. assert (
  218. mock_super_handle_exception.call_args.args[3]._tags["jira.host"]
  219. == "maiseycharlie.jira.com"
  220. )
  221. assert (
  222. mock_super_handle_exception.call_args.args[3]._tags["jira.endpoint"]
  223. == "/rest/api/3/dogs/tricks"
  224. )
  225. @patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
  226. def test_handles_xml_as_error_message(self, mock_super_handle_exception: MagicMock):
  227. """Moves the XML to `handler_context` and replaces it with a human-friendly message"""
  228. xml_string = '<?xml version="1.0"?><status><code>500</code><message>PSQLException: too many connections</message></status>'
  229. handler_error = ApiError(
  230. xml_string, url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks"
  231. )
  232. mock_endpoint = MockErroringJiraEndpoint.as_view(error=handler_error)
  233. request = self.make_request(method="GET")
  234. mock_endpoint(request)
  235. # signature is super().handle_exception(request, error, handler_context, scope)
  236. assert mock_super_handle_exception.call_args.args[1] == handler_error
  237. assert str(handler_error) == "Unknown error when requesting /rest/api/3/dogs/tricks"
  238. assert mock_super_handle_exception.call_args.args[2]["xml_response"] == xml_string
  239. @patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
  240. def test_handles_html_as_error_message(self, mock_super_handle_exception: MagicMock):
  241. """Moves the HTML to `handler_context` and replaces it with a human-friendly message"""
  242. html_strings = [
  243. # These aren't valid HTML (because they're cut off) but the `ApiError` constructor does
  244. # that, too, if the error text is long enough (though after more characters than this)
  245. '<!DOCTYPE html><html><head><title>Oops</title></head><body><div id="page"><div'
  246. '<html lang="en"><head><title>Oops</title></head><body><div id="page"><div'
  247. ]
  248. for html_string in html_strings:
  249. handler_error = ApiError(
  250. html_string, url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks"
  251. )
  252. mock_endpoint = MockErroringJiraEndpoint.as_view(error=handler_error)
  253. request = self.make_request(method="GET")
  254. mock_endpoint(request)
  255. # signature is super().handle_exception(request, error, handler_context, scope)
  256. assert mock_super_handle_exception.call_args.args[1] == handler_error
  257. assert str(handler_error) == "Unknown error when requesting /rest/api/3/dogs/tricks"
  258. assert mock_super_handle_exception.call_args.args[2]["html_response"] == html_string
  259. @patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
  260. def test_replacement_error_messages(self, mock_super_handle_exception: MagicMock):
  261. replacement_messages_by_code = {
  262. 429: "Rate limit hit when requesting /rest/api/3/dogs/tricks",
  263. 401: "Unauthorized request to /rest/api/3/dogs/tricks",
  264. 502: "Bad gateway when connecting to /rest/api/3/dogs/tricks",
  265. 504: "Gateway timeout when connecting to /rest/api/3/dogs/tricks",
  266. }
  267. for code, new_message in replacement_messages_by_code.items():
  268. handler_error = ApiError(
  269. "<!DOCTYPE html><html>Some HTML here</html>",
  270. url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks",
  271. code=code,
  272. )
  273. mock_endpoint = MockErroringJiraEndpoint.as_view(error=handler_error)
  274. request = self.make_request(method="GET")
  275. mock_endpoint(request)
  276. # signature is super().handle_exception(request, error, handler_context, scope)
  277. assert mock_super_handle_exception.call_args.args[1] == handler_error
  278. assert str(handler_error) == new_message
  279. @patch("sentry.integrations.jira.webhooks.base.logger")
  280. @patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
  281. def test_unexpected_jira_errors(
  282. self, mock_super_handle_exception: MagicMock, mock_logger: MagicMock
  283. ):
  284. unknown_errors = [
  285. (
  286. Exception(
  287. "not a known error",
  288. ),
  289. "not a known error",
  290. ),
  291. (
  292. ApiError(
  293. "<!DOCTYPE html><html>Some HTML here</html>",
  294. url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks",
  295. code=403,
  296. ),
  297. "Unknown error when requesting /rest/api/3/dogs/tricks",
  298. ),
  299. ]
  300. for unknown_error, expected_error_message in unknown_errors:
  301. mock_endpoint = MockErroringJiraEndpoint.as_view(error=unknown_error)
  302. request = self.make_request(method="GET")
  303. mock_endpoint(request)
  304. assert mock_super_handle_exception.call_args.args[1] == unknown_error
  305. assert str(unknown_error) == expected_error_message
  306. assert mock_logger.error.call_args.args[0] == "Unclear JIRA exception"