diff --git a/src/sentry/integrations/bitbucket/installed.py b/src/sentry/integrations/bitbucket/installed.py index 4be5cda97459d8..175cf9919dd115 100644 --- a/src/sentry/integrations/bitbucket/installed.py +++ b/src/sentry/integrations/bitbucket/installed.py @@ -1,14 +1,24 @@ from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.views.decorators.csrf import csrf_exempt +from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import Endpoint, control_silo_endpoint +from sentry.integrations.base import IntegrationDomain from sentry.integrations.pipeline import ensure_integration from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.utils.atlassian_connect import ( + AtlassianConnectTokenValidator, + AtlassianConnectValidationError, +) +from sentry.integrations.utils.metrics import ( + IntegrationPipelineViewEvent, + IntegrationPipelineViewType, +) from .integration import BitbucketIntegrationProvider @@ -27,8 +37,23 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) def post(self, request: Request, *args, **kwargs) -> Response: - state = request.data - data = BitbucketIntegrationProvider().build_integration(state) - ensure_integration(IntegrationProviderSlug.BITBUCKET.value, data) + with IntegrationPipelineViewEvent( + interaction_type=IntegrationPipelineViewType.VERIFY_INSTALLATION, + domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT, + provider_key=IntegrationProviderSlug.BITBUCKET.value, + ).capture() as lifecycle: + state = request.data - return self.respond() + try: + AtlassianConnectTokenValidator(request, method="POST").get_token() + except AtlassianConnectValidationError as e: + lifecycle.record_halt(halt_reason=str(e)) + return self.respond( + {"detail": "Request Token Validation Failed"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + data = BitbucketIntegrationProvider().build_integration(state) + ensure_integration(IntegrationProviderSlug.BITBUCKET.value, data) + + return self.respond() diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index d43c478f913100..8b4615497a3518 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -1,6 +1,5 @@ import sentry_sdk from django.db import router, transaction -from jwt import DecodeError, ExpiredSignatureError, InvalidKeyError, InvalidSignatureError from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -14,12 +13,14 @@ from sentry.integrations.jira.webhooks.base import JiraWebhookBase from sentry.integrations.pipeline import ensure_integration from sentry.integrations.project_management.metrics import ProjectManagementFailuresReason -from sentry.integrations.utils.atlassian_connect import authenticate_asymmetric_jwt, verify_claims +from sentry.integrations.utils.atlassian_connect import ( + AtlassianConnectTokenValidator, + AtlassianConnectValidationError, +) from sentry.integrations.utils.metrics import ( IntegrationPipelineViewEvent, IntegrationPipelineViewType, ) -from sentry.utils import jwt @control_silo_endpoint @@ -38,56 +39,26 @@ def post(self, request: Request, *args, **kwargs) -> Response: domain=IntegrationDomain.PROJECT_MANAGEMENT, provider_key=self.provider, ).capture() as lifecycle: - token = self.get_token(request) state = request.data if not state: lifecycle.record_failure(ProjectManagementFailuresReason.INSTALLATION_STATE_MISSING) return self.respond(status=status.HTTP_400_BAD_REQUEST) - try: - key_id = jwt.peek_header(token).get("kid") - except DecodeError: - lifecycle.record_halt(halt_reason="Failed to fetch key id") - return self.respond( - {"detail": "Failed to fetch key id"}, status=status.HTTP_400_BAD_REQUEST - ) - lifecycle.add_extras( { - "key_id": key_id, "base_url": state.get("baseUrl", ""), "description": state.get("description", ""), "clientKey": state.get("clientKey", ""), } ) - if not key_id: - lifecycle.record_halt(halt_reason="Missing key_id (kid)") - return self.respond( - {"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST - ) try: - decoded_claims = authenticate_asymmetric_jwt(token, key_id) - verify_claims(decoded_claims, request.path, request.GET, method="POST") - except InvalidKeyError: - lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)") - return self.respond( - {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST - ) - except ExpiredSignatureError as e: - lifecycle.record_failure(e) - return self.respond( - {"detail": "Expired signature"}, status=status.HTTP_400_BAD_REQUEST - ) - except InvalidSignatureError: - lifecycle.record_halt(halt_reason="JWT contained invalid signature") - return self.respond( - {"detail": "Invalid signature"}, status=status.HTTP_400_BAD_REQUEST - ) - except DecodeError: - lifecycle.record_halt(halt_reason="Could not decode JWT token") + AtlassianConnectTokenValidator(request, method="POST").get_token() + except AtlassianConnectValidationError as e: + lifecycle.record_halt(halt_reason=str(e)) return self.respond( - {"detail": "Could not decode JWT token"}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Request Token Validation Failed"}, + status=status.HTTP_400_BAD_REQUEST, ) data = JiraIntegrationProvider().build_integration(state) diff --git a/src/sentry/integrations/utils/atlassian_connect.py b/src/sentry/integrations/utils/atlassian_connect.py index 509191f1e22856..7c142ec7bf28e5 100644 --- a/src/sentry/integrations/utils/atlassian_connect.py +++ b/src/sentry/integrations/utils/atlassian_connect.py @@ -2,10 +2,11 @@ import hashlib from collections.abc import Mapping, Sequence +from enum import StrEnum import requests from django.http import HttpRequest -from jwt import ExpiredSignatureError, InvalidSignatureError +from jwt import DecodeError, ExpiredSignatureError, InvalidKeyError, InvalidSignatureError from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration @@ -15,6 +16,23 @@ from sentry.utils.http import absolute_uri, percent_encode +class AtlassianConnectFailureReason(StrEnum): + MISSING_AUTHORIZATION_HEADER = "Missing/Invalid authorization header" + NO_TOKEN_PARAMETER = "No token parameter" + NO_INTEGRATION_FOUND = "No integration found" + INVALID_SIGNATURE = "Signature is invalid" + EXPIRED_SIGNATURE = "Signature is expired" + QUERY_HASH_MISMATCH = "Query hash mismatch" + UNABLE_TO_VERIFY_ASYMMETRIC_JWT = "Unable to verify asymmetric installation JWT" + FAILED_TO_RETRIEVE_TOKEN = "Failed to retrieve token from request headers" + FAILED_TO_FETCH_KEY_ID = "Failed to fetch key_id (kid)" + MISSING_KEY_ID = "Missing key_id (kid)" + INVALID_KEY_ID = "JWT contained invalid key_id (kid)" + EXPIRED_SIGNATURE_TOKEN = "Expired signature" + INVALID_SIGNATURE_TOKEN = "JWT contained invalid signature" + COULD_NOT_DECODE_JWT = "Could not decode JWT token" + + class AtlassianConnectValidationError(Exception): pass @@ -49,7 +67,9 @@ def get_token(request: HttpRequest) -> str: auth_header: str = request.META["HTTP_AUTHORIZATION"] return auth_header.split(" ", 1)[1] except (KeyError, IndexError): - raise AtlassianConnectValidationError("Missing/Invalid authorization header") + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.MISSING_AUTHORIZATION_HEADER + ) def get_integration_from_jwt( @@ -63,7 +83,7 @@ def get_integration_from_jwt( # Extract the JWT token from the request's jwt query # parameter or the authorization header. if token is None: - raise AtlassianConnectValidationError("No token parameter") + raise AtlassianConnectValidationError(AtlassianConnectFailureReason.NO_TOKEN_PARAMETER) # Decode the JWT token, without verification. This gives # you a header JSON object, a claims JSON object, and a signature. claims = jwt.peek_claims(token) @@ -77,7 +97,7 @@ def get_integration_from_jwt( # by the add-on during the installation handshake integration = integration_service.get_integration(provider=provider, external_id=issuer) if not integration: - raise AtlassianConnectValidationError("No integration found") + raise AtlassianConnectValidationError(AtlassianConnectFailureReason.NO_INTEGRATION_FOUND) # Verify the signature with the sharedSecret and the algorithm specified in the header's # alg field. We only need the token + shared secret and do not want to provide an # audience to the JWT validation that is require to match. Bitbucket does give us an @@ -93,9 +113,13 @@ def get_integration_from_jwt( else jwt.decode(token, integration.metadata["shared_secret"], audience=False) ) except InvalidSignatureError as e: - raise AtlassianConnectValidationError("Signature is invalid") from e + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.INVALID_SIGNATURE + ) from e except ExpiredSignatureError as e: - raise AtlassianConnectValidationError("Signature is expired") from e + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.EXPIRED_SIGNATURE + ) from e verify_claims(decoded_claims, path, query_params, method) @@ -112,7 +136,7 @@ def verify_claims( # and comparing it against the qsh claim on the verified token. qsh = get_query_hash(path, method, query_params) if qsh != claims["qsh"]: - raise AtlassianConnectValidationError("Query hash mismatch") + raise AtlassianConnectValidationError(AtlassianConnectFailureReason.QUERY_HASH_MISMATCH) def authenticate_asymmetric_jwt(token: str | None, key_id: str) -> dict[str, str]: @@ -121,7 +145,7 @@ def authenticate_asymmetric_jwt(token: str | None, key_id: str) -> dict[str, str See: https://community.developer.atlassian.com/t/action-required-atlassian-connect-installation-lifecycle-security-improvements/49046 """ if token is None: - raise AtlassianConnectValidationError("No token parameter") + raise AtlassianConnectValidationError(AtlassianConnectFailureReason.NO_TOKEN_PARAMETER) headers = jwt.peek_header(token) key_response = requests.get(f"https://connect-install-keys.atlassian.com/{key_id}") public_key = key_response.content.decode("utf-8").strip() @@ -129,7 +153,9 @@ def authenticate_asymmetric_jwt(token: str | None, key_id: str) -> dict[str, str token, public_key, audience=absolute_uri(), algorithms=[headers.get("alg")] ) if not decoded_claims: - raise AtlassianConnectValidationError("Unable to verify asymmetric installation JWT") + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.UNABLE_TO_VERIFY_ASYMMETRIC_JWT + ) return decoded_claims @@ -152,3 +178,46 @@ def parse_integration_from_request(request: HttpRequest, provider: str) -> Integ method=request.method if request.method else "POST", ) return Integration.objects.filter(id=rpc_integration.id).first() + + +class AtlassianConnectTokenValidator: + def __init__(self, request: HttpRequest, method: str) -> None: + self.request = request + self.method = method + + def get_token(self) -> str: + try: + token = get_token(self.request) + except Exception: + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.FAILED_TO_RETRIEVE_TOKEN + ) + self._validate_token(token) + return token + + def _validate_token(self, token: str) -> None: + try: + key_id = jwt.peek_header(token).get("kid") + except DecodeError: + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.FAILED_TO_FETCH_KEY_ID + ) + if not key_id: + raise AtlassianConnectValidationError(AtlassianConnectFailureReason.MISSING_KEY_ID) + try: + decoded_claims = authenticate_asymmetric_jwt(token, key_id) + verify_claims(decoded_claims, self.request.path, self.request.GET, self.method) + except InvalidKeyError: + raise AtlassianConnectValidationError(AtlassianConnectFailureReason.INVALID_KEY_ID) + except ExpiredSignatureError: + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.EXPIRED_SIGNATURE_TOKEN + ) + except InvalidSignatureError: + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.INVALID_SIGNATURE_TOKEN + ) + except DecodeError: + raise AtlassianConnectValidationError( + AtlassianConnectFailureReason.COULD_NOT_DECODE_JWT + ) diff --git a/tests/sentry/integrations/bitbucket/test_installed.py b/tests/sentry/integrations/bitbucket/test_installed.py index b7d0b37f575076..969b5823a3840c 100644 --- a/tests/sentry/integrations/bitbucket/test_installed.py +++ b/tests/sentry/integrations/bitbucket/test_installed.py @@ -1,21 +1,34 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from unittest import mock +from unittest.mock import MagicMock, patch +import jwt as pyjwt import responses +from jwt import DecodeError, ExpiredSignatureError, InvalidSignatureError from sentry.integrations.bitbucket.installed import BitbucketInstalledEndpoint from sentry.integrations.bitbucket.integration import BitbucketIntegrationProvider, scopes from sentry.integrations.models.integration import Integration +from sentry.integrations.types import EventLifecycleOutcome +from sentry.integrations.utils.atlassian_connect import ( + AtlassianConnectFailureReason, + AtlassianConnectValidationError, + get_query_hash, +) from sentry.models.project import Project from sentry.models.repository import Repository from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.plugins.base import plugins from sentry.plugins.bases.issue2 import IssueTrackingPlugin2 from sentry.silo.base import SiloMode +from sentry.testutils.asserts import assert_count_of_metric, assert_halt_metric from sentry.testutils.cases import APITestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test +from sentry.utils.http import absolute_uri +from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY class BitbucketPlugin(IssueTrackingPlugin2): @@ -26,6 +39,35 @@ class BitbucketPlugin(IssueTrackingPlugin2): @control_silo_test class BitbucketInstalledEndpointTest(APITestCase): + kid = "bitbucket-test-kid" + + def _jwt_token( + self, + signing_algorithm: str, + key: str, + headers: Mapping[str, Any] | None = None, + ) -> str: + return pyjwt.encode( + { + "iss": self.client_key, + "aud": absolute_uri(), + "qsh": get_query_hash(self.path, method="POST", query_params={}), + }, + key, + algorithm=signing_algorithm, + headers={**(headers or {}), "alg": signing_algorithm}, + ) + + def jwt_token_cdn(self) -> str: + return self._jwt_token("RS256", RS256_KEY, headers={"kid": self.kid}) + + def add_cdn_response(self) -> None: + responses.add( + responses.GET, + f"https://connect-install-keys.atlassian.com/{self.kid}", + body=RS256_PUB_KEY, + ) + def setUp(self) -> None: self.provider = "bitbucket" self.path = "/extensions/bitbucket/installed/" @@ -103,7 +145,8 @@ def test_default_permissions(self) -> None: assert BitbucketInstalledEndpoint.authentication_classes == () assert BitbucketInstalledEndpoint.permission_classes == () - def test_installed_with_public_key(self) -> None: + @mock.patch("sentry.integrations.bitbucket.installed.AtlassianConnectTokenValidator") + def test_installed_with_public_key(self, mock_validator: MagicMock) -> None: response = self.client.post(self.path, data=self.team_data_from_bitbucket) assert response.status_code == 200 integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) @@ -111,7 +154,8 @@ def test_installed_with_public_key(self) -> None: del integration.metadata["webhook_secret"] assert integration.metadata == self.metadata - def test_installed_without_public_key(self) -> None: + @mock.patch("sentry.integrations.bitbucket.installed.AtlassianConnectTokenValidator") + def test_installed_without_public_key(self, mock_validator: MagicMock) -> None: integration, created = Integration.objects.get_or_create( provider=self.provider, external_id=self.client_key, @@ -129,7 +173,8 @@ def test_installed_without_public_key(self) -> None: del integration_after.metadata["webhook_secret"] assert integration.metadata == integration_after.metadata - def test_installed_without_username(self) -> None: + @mock.patch("sentry.integrations.bitbucket.installed.AtlassianConnectTokenValidator") + def test_installed_without_username(self, mock_validator: MagicMock) -> None: """Test a user (not team) installation where the user has hidden their username from public view""" # Remove username to simulate privacy mode @@ -142,8 +187,11 @@ def test_installed_without_username(self) -> None: del integration.metadata["webhook_secret"] assert integration.metadata == self.user_metadata + @mock.patch("sentry.integrations.bitbucket.installed.AtlassianConnectTokenValidator") @mock.patch("sentry.integrations.bitbucket.integration.generate_token", return_value="0" * 64) - def test_installed_with_secret(self, mock_generate_token: mock.MagicMock) -> None: + def test_installed_with_secret( + self, mock_generate_token: mock.MagicMock, mock_validator: MagicMock + ) -> None: response = self.client.post(self.path, data=self.team_data_from_bitbucket) assert mock_generate_token.called assert response.status_code == 200 @@ -151,8 +199,9 @@ def test_installed_with_secret(self, mock_generate_token: mock.MagicMock) -> Non assert integration.name == self.username assert integration.metadata["webhook_secret"] == "0" * 64 + @mock.patch("sentry.integrations.bitbucket.installed.AtlassianConnectTokenValidator") @responses.activate - def test_plugin_migration(self) -> None: + def test_plugin_migration(self, mock_validator: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): accessible_repo = Repository.objects.create( organization_id=self.organization.id, @@ -201,8 +250,9 @@ def test_plugin_migration(self) -> None: assert Repository.objects.get(id=inaccessible_repo.id).integration_id is None + @mock.patch("sentry.integrations.bitbucket.installed.AtlassianConnectTokenValidator") @responses.activate - def test_disable_plugin_when_fully_migrated(self) -> None: + def test_disable_plugin_when_fully_migrated(self, mock_validator: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): project = Project.objects.create(organization_id=self.organization.id) @@ -239,3 +289,144 @@ def test_disable_plugin_when_fully_migrated(self) -> None: ) assert "bitbucket" not in [p.slug for p in plugins.for_project(project)] + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_missing_token(self, mock_record_event: MagicMock) -> None: + response = self.client.post(self.path, data=self.team_data_from_bitbucket) + assert response.status_code == 400 + assert response.data["detail"] == "Request Token Validation Failed" + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric( + mock_record_event, AtlassianConnectFailureReason.FAILED_TO_RETRIEVE_TOKEN + ) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_invalid_token(self, mock_record_event: MagicMock) -> None: + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION="invalid", + ) + assert response.status_code == 400 + assert response.data["detail"] == "Request Token Validation Failed" + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric( + mock_record_event, AtlassianConnectFailureReason.FAILED_TO_RETRIEVE_TOKEN + ) + + @patch( + "sentry.integrations.utils.atlassian_connect.authenticate_asymmetric_jwt", + side_effect=AtlassianConnectValidationError( + AtlassianConnectFailureReason.FAILED_TO_FETCH_KEY_ID + ), + ) + @responses.activate + def test_no_claims(self, mock_authenticate_asymmetric_jwt: MagicMock) -> None: + self.add_cdn_response() + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn(), + ) + assert response.status_code == 400 + + @patch( + "sentry.integrations.utils.atlassian_connect.authenticate_asymmetric_jwt", + side_effect=ExpiredSignatureError(), + ) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @responses.activate + def test_expired_signature( + self, mock_record_event: MagicMock, mock_authenticate_asymmetric_jwt: MagicMock + ) -> None: + self.add_cdn_response() + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn(), + ) + assert response.status_code == 400 + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric(mock_record_event, AtlassianConnectFailureReason.EXPIRED_SIGNATURE_TOKEN) + + @patch( + "sentry.integrations.utils.atlassian_connect.authenticate_asymmetric_jwt", + side_effect=InvalidSignatureError(), + ) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @responses.activate + def test_invalid_signature( + self, mock_record_event: MagicMock, mock_authenticate_asymmetric_jwt: MagicMock + ) -> None: + self.add_cdn_response() + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn(), + ) + assert response.status_code == 400 + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric(mock_record_event, AtlassianConnectFailureReason.INVALID_SIGNATURE_TOKEN) + + @patch( + "sentry.integrations.utils.atlassian_connect.authenticate_asymmetric_jwt", + side_effect=DecodeError(), + ) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @responses.activate + def test_decode_error( + self, mock_record_event: MagicMock, mock_authenticate_asymmetric_jwt: MagicMock + ) -> None: + self.add_cdn_response() + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn(), + ) + assert response.status_code == 400 + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric(mock_record_event, AtlassianConnectFailureReason.COULD_NOT_DECODE_JWT) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_without_key_id(self, mock_record_event: MagicMock) -> None: + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION="JWT " + self._jwt_token("RS256", RS256_KEY, headers={}), + ) + assert response.status_code == 400 + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric(mock_record_event, AtlassianConnectFailureReason.MISSING_KEY_ID) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @responses.activate + def test_with_invalid_key_id(self, mock_record_event: MagicMock) -> None: + responses.add( + responses.GET, + "https://connect-install-keys.atlassian.com/fake-kid", + body=b"Not Found", + status=404, + ) + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION="JWT " + + self._jwt_token("RS256", RS256_KEY, headers={"kid": "fake-kid"}), + ) + assert response.status_code == 400 + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric(mock_record_event, AtlassianConnectFailureReason.INVALID_KEY_ID) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index a5326d93b12686..c2349525df44cd 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -14,12 +14,12 @@ from sentry.integrations.project_management.metrics import ProjectManagementFailuresReason from sentry.integrations.types import EventLifecycleOutcome from sentry.integrations.utils.atlassian_connect import ( + AtlassianConnectFailureReason, AtlassianConnectValidationError, get_query_hash, ) from sentry.testutils.asserts import ( assert_count_of_metric, - assert_failure_metric, assert_halt_metric, ) from sentry.testutils.cases import APITestCase @@ -88,19 +88,23 @@ def test_missing_body(self, mock_record_failure: MagicMock) -> None: ProjectManagementFailuresReason.INSTALLATION_STATE_MISSING ) + @responses.activate def test_missing_token(self) -> None: - self.get_error_response(**self.body(), status_code=status.HTTP_409_CONFLICT) + self.get_error_response(**self.body(), status_code=status.HTTP_400_BAD_REQUEST) + @responses.activate def test_invalid_token(self) -> None: self.get_error_response( **self.body(), extra_headers=dict(HTTP_AUTHORIZATION="invalid"), - status_code=status.HTTP_409_CONFLICT, + status_code=status.HTTP_400_BAD_REQUEST, ) @patch( - "sentry.integrations.jira.webhooks.installed.authenticate_asymmetric_jwt", - side_effect=AtlassianConnectValidationError(), + "sentry.integrations.utils.atlassian_connect.authenticate_asymmetric_jwt", + side_effect=AtlassianConnectValidationError( + AtlassianConnectFailureReason.FAILED_TO_FETCH_KEY_ID + ), ) @responses.activate def test_no_claims(self, mock_authenticate_asymmetric_jwt: MagicMock) -> None: @@ -109,11 +113,11 @@ def test_no_claims(self, mock_authenticate_asymmetric_jwt: MagicMock) -> None: self.get_error_response( **self.body(), extra_headers=dict(HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn()), - status_code=status.HTTP_409_CONFLICT, + status_code=status.HTTP_400_BAD_REQUEST, ) @patch( - "sentry.integrations.jira.webhooks.installed.authenticate_asymmetric_jwt", + "sentry.integrations.utils.atlassian_connect.authenticate_asymmetric_jwt", side_effect=ExpiredSignatureError(), ) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @@ -131,15 +135,15 @@ def test_expired_signature( # SLO metric asserts # ENSURE_CONTROL_SILO (success) -> VERIFY_INSTALLATION (failure) -> GET_CONTROL_RESPONSE (success) assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) - assert_count_of_metric(mock_record_event, EventLifecycleOutcome.FAILURE, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) - assert_failure_metric( + assert_halt_metric( mock_record_event, - ExpiredSignatureError(), + AtlassianConnectFailureReason.EXPIRED_SIGNATURE_TOKEN, ) @patch( - "sentry.integrations.jira.webhooks.installed.authenticate_asymmetric_jwt", + "sentry.integrations.utils.atlassian_connect.authenticate_asymmetric_jwt", side_effect=InvalidSignatureError(), ) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @@ -161,11 +165,11 @@ def test_invalid_signature( assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) assert_halt_metric( mock_record_event, - "JWT contained invalid signature", + AtlassianConnectFailureReason.INVALID_SIGNATURE_TOKEN, ) @patch( - "sentry.integrations.jira.webhooks.installed.authenticate_asymmetric_jwt", + "sentry.integrations.utils.atlassian_connect.authenticate_asymmetric_jwt", side_effect=DecodeError(), ) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @@ -187,7 +191,7 @@ def test_decode_error( assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) assert_halt_metric( mock_record_event, - "Could not decode JWT token", + AtlassianConnectFailureReason.COULD_NOT_DECODE_JWT, ) @patch("sentry_sdk.set_tag") @@ -220,7 +224,7 @@ def test_without_key_id(self, mock_record_event: MagicMock) -> None: assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) assert_halt_metric( mock_record_event, - "Missing key_id (kid)", + AtlassianConnectFailureReason.MISSING_KEY_ID, ) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @@ -249,5 +253,5 @@ def test_with_invalid_key_id(self, mock_record_event: MagicMock) -> None: assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) assert_halt_metric( mock_record_event, - "JWT contained invalid key_id (kid)", + AtlassianConnectFailureReason.INVALID_KEY_ID, )