Files
2024-02-15 17:01:50 +00:00

356 lines
16 KiB
Python

from __future__ import annotations
from unittest.mock import MagicMock, patch
import responses
from django.test.utils import override_settings
from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.response import Response
from fixtures.integrations.stub_service import StubService
from sentry.integrations.jira.webhooks.base import JiraTokenError, JiraWebhookBase
from sentry.integrations.mixins import IssueSyncMixin
from sentry.integrations.utils import AtlassianConnectValidationError
from sentry.services.hybrid_cloud.integration.serial import serialize_integration
from sentry.services.hybrid_cloud.organization.serial import serialize_rpc_organization
from sentry.shared_integrations.exceptions import ApiError
from sentry.testutils.cases import APITestCase, TestCase
from sentry.testutils.silo import region_silo_test
TOKEN = "JWT anexampletoken"
@region_silo_test
class JiraIssueUpdatedWebhookTest(APITestCase):
endpoint = "sentry-extensions-jira-issue-updated"
method = "post"
def setUp(self):
super().setUp()
integration, _ = self.create_provider_integration_for(
organization=self.organization,
user=self.user,
provider="jira",
name="Example Jira",
metadata={
"oauth_client_id": "oauth-client-id",
"shared_secret": "a-super-secret-key-from-atlassian",
"base_url": "https://example.atlassian.net",
"domain_name": "example.atlassian.net",
},
)
# Ensure this is region safe, and doesn't require the ORM integration model
self.integration = serialize_integration(integration=integration)
@patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
def test_simple_assign(self, mock_sync_group_assignee_inbound):
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
):
data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
mock_sync_group_assignee_inbound.assert_called_with(
self.integration, "[email protected]", "APP-123", assign=True
)
@override_settings(JIRA_USE_EMAIL_SCOPE=True)
@patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
@responses.activate
def test_assign_use_email_api(self, mock_sync_group_assignee_inbound):
responses.add(
responses.GET,
"https://example.atlassian.net/rest/api/3/user/email",
json={"accountId": "deadbeef123", "email": self.user.email},
)
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
):
data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
data["issue"]["fields"]["assignee"]["emailAddress"] = ""
self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
assert mock_sync_group_assignee_inbound.called
assert len(responses.calls) == 1
@override_settings(JIRA_USE_EMAIL_SCOPE=True)
@responses.activate
def test_assign_use_email_api_error(self):
responses.add(
responses.GET,
"https://example.atlassian.net/rest/api/3/user/email",
status=500,
)
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
):
data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
data["issue"]["fields"]["assignee"]["emailAddress"] = ""
response = self.get_success_response(
**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)
)
assert "error_message" in response.data
@patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
def test_assign_missing_email(self, mock_sync_group_assignee_inbound):
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
):
data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
data["issue"]["fields"]["assignee"]["emailAddress"] = ""
self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
assert not mock_sync_group_assignee_inbound.called
@patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
def test_simple_deassign(self, mock_sync_group_assignee_inbound):
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
):
data = StubService.get_stub_data("jira", "edit_issue_no_assignee_payload.json")
self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
mock_sync_group_assignee_inbound.assert_called_with(
self.integration, None, "APP-123", assign=False
)
@patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound")
def test_simple_deassign_assignee_missing(self, mock_sync_group_assignee_inbound):
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
):
data = StubService.get_stub_data("jira", "edit_issue_assignee_missing_payload.json")
self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
mock_sync_group_assignee_inbound.assert_called_with(
self.integration, None, "APP-123", assign=False
)
@patch.object(IssueSyncMixin, "sync_status_inbound")
def test_simple_status_sync_inbound(self, mock_sync_status_inbound):
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
) as mock_get_integration_from_jwt:
data = StubService.get_stub_data("jira", "edit_issue_status_payload.json")
self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
mock_get_integration_from_jwt.assert_called_with(
token="anexampletoken",
path="/extensions/jira/issue-updated/",
provider="jira",
query_params={},
method="POST",
)
mock_sync_status_inbound.assert_called_with(
"APP-123",
{
"changelog": {
"from": "10101",
"field": "status",
"fromString": "Done",
"to": "3",
"toString": "In Progress",
"fieldtype": "jira",
"fieldId": "status",
},
"issue": {
"fields": {"project": {"id": "10000", "key": "APP"}},
"key": "APP-123",
},
},
)
@patch("sentry_sdk.set_tag")
@patch("sentry.integrations.utils.scope.bind_organization_context")
def test_adds_context_data(self, mock_bind_org_context: MagicMock, mock_set_tag: MagicMock):
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
):
data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
mock_set_tag.assert_called_with("integration_id", self.integration.id)
mock_bind_org_context.assert_called_with(serialize_rpc_organization(self.organization))
def test_missing_changelog(self):
with patch(
"sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt",
return_value=self.integration,
):
data = StubService.get_stub_data("jira", "changelog_missing.json")
self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN))
class MockErroringJiraEndpoint(JiraWebhookBase):
permission_classes = ()
dummy_exception = Exception("whoops")
# In order to be able to use `as_view`'s `initkwargs` (in other words, in order to be able to
# pass kwargs to `as_view` and have `as_view` pass them onto the `__init__` method below), any
# kwarg we'd like to pass must already be an attibute of the class
error = BaseException("unreachable")
def __init__(self, error: Exception = dummy_exception, *args, **kwargs):
# We allow the error to be passed in so that we have access to it in the test for use
# in equality checks
self.error = error
super().__init__(*args, **kwargs)
def get(self, request):
raise self.error
class JiraWebhookBaseTest(TestCase):
@patch("sentry.utils.sdk.capture_exception")
def test_bad_request_errors(self, mock_capture_exception: MagicMock):
for error_type in [AtlassianConnectValidationError, JiraTokenError]:
mock_endpoint = MockErroringJiraEndpoint.as_view(error=error_type())
request = self.make_request(method="GET")
response = mock_endpoint(request)
assert response.status_code == status.HTTP_409_CONFLICT
# This kind of error shouldn't be sent to Sentry
assert mock_capture_exception.call_count == 0
@patch("sentry.integrations.jira.webhooks.base.logger")
@patch("sentry.utils.sdk.capture_exception")
def test_atlassian_pen_testing_bot(
self, mock_capture_exception: MagicMock, mock_logger: MagicMock
):
mock_endpoint = MockErroringJiraEndpoint.as_view(error=MethodNotAllowed("GET"))
request = self.make_request(method="GET")
request.META[
"HTTP_USER_AGENT"
] = "CSRT (github.com/atlassian-labs/connect-security-req-tester)"
response = mock_endpoint(request)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert (
mock_logger.info.call_args.args[0]
== "Atlassian Connect Security Request Tester tried disallowed method"
)
# This kind of error shouldn't be sent to Sentry
assert mock_capture_exception.call_count == 0
@patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
def test_APIError_host_and_path_added_as_tags(self, mock_super_handle_exception: MagicMock):
handler_error = ApiError("", url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks")
mock_endpoint = MockErroringJiraEndpoint.as_view(error=handler_error)
request = self.make_request(method="GET")
mock_endpoint(request)
# signature is super().handle_exception(request, error, handler_context, scope)
assert (
mock_super_handle_exception.call_args.args[3]._tags["jira.host"]
== "maiseycharlie.jira.com"
)
assert (
mock_super_handle_exception.call_args.args[3]._tags["jira.endpoint"]
== "/rest/api/3/dogs/tricks"
)
@patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
def test_handles_xml_as_error_message(self, mock_super_handle_exception: MagicMock):
"""Moves the XML to `handler_context` and replaces it with a human-friendly message"""
xml_string = '<?xml version="1.0"?><status><code>500</code><message>PSQLException: too many connections</message></status>'
handler_error = ApiError(
xml_string, url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks"
)
mock_endpoint = MockErroringJiraEndpoint.as_view(error=handler_error)
request = self.make_request(method="GET")
mock_endpoint(request)
# signature is super().handle_exception(request, error, handler_context, scope)
assert mock_super_handle_exception.call_args.args[1] == handler_error
assert str(handler_error) == "Unknown error when requesting /rest/api/3/dogs/tricks"
assert mock_super_handle_exception.call_args.args[2]["xml_response"] == xml_string
@patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
def test_handles_html_as_error_message(self, mock_super_handle_exception: MagicMock):
"""Moves the HTML to `handler_context` and replaces it with a human-friendly message"""
html_strings = [
# These aren't valid HTML (because they're cut off) but the `ApiError` constructor does
# that, too, if the error text is long enough (though after more characters than this)
'<!DOCTYPE html><html><head><title>Oops</title></head><body><div id="page"><div'
'<html lang="en"><head><title>Oops</title></head><body><div id="page"><div'
]
for html_string in html_strings:
handler_error = ApiError(
html_string, url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks"
)
mock_endpoint = MockErroringJiraEndpoint.as_view(error=handler_error)
request = self.make_request(method="GET")
mock_endpoint(request)
# signature is super().handle_exception(request, error, handler_context, scope)
assert mock_super_handle_exception.call_args.args[1] == handler_error
assert str(handler_error) == "Unknown error when requesting /rest/api/3/dogs/tricks"
assert mock_super_handle_exception.call_args.args[2]["html_response"] == html_string
@patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
def test_replacement_error_messages(self, mock_super_handle_exception: MagicMock):
replacement_messages_by_code = {
429: "Rate limit hit when requesting /rest/api/3/dogs/tricks",
401: "Unauthorized request to /rest/api/3/dogs/tricks",
502: "Bad gateway when connecting to /rest/api/3/dogs/tricks",
504: "Gateway timeout when connecting to /rest/api/3/dogs/tricks",
}
for code, new_message in replacement_messages_by_code.items():
handler_error = ApiError(
"<!DOCTYPE html><html>Some HTML here</html>",
url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks",
code=code,
)
mock_endpoint = MockErroringJiraEndpoint.as_view(error=handler_error)
request = self.make_request(method="GET")
mock_endpoint(request)
# signature is super().handle_exception(request, error, handler_context, scope)
assert mock_super_handle_exception.call_args.args[1] == handler_error
assert str(handler_error) == new_message
@patch("sentry.integrations.jira.webhooks.base.logger")
@patch("sentry.api.base.Endpoint.handle_exception", return_value=Response())
def test_unexpected_jira_errors(
self, mock_super_handle_exception: MagicMock, mock_logger: MagicMock
):
unknown_errors = [
(
Exception(
"not a known error",
),
"not a known error",
),
(
ApiError(
"<!DOCTYPE html><html>Some HTML here</html>",
url="http://maiseycharlie.jira.com/rest/api/3/dogs/tricks",
code=403,
),
"Unknown error when requesting /rest/api/3/dogs/tricks",
),
]
for unknown_error, expected_error_message in unknown_errors:
mock_endpoint = MockErroringJiraEndpoint.as_view(error=unknown_error)
request = self.make_request(method="GET")
mock_endpoint(request)
assert mock_super_handle_exception.call_args.args[1] == unknown_error
assert str(unknown_error) == expected_error_message
assert mock_logger.error.call_args.args[0] == "Unclear JIRA exception"