Skip to content

Commit

Permalink
🐛 fix(azure devops): fix migration version on a fresh install (#83290)
Browse files Browse the repository at this point in the history
  • Loading branch information
iamrajjoshi authored and andrewshie-sentry committed Jan 22, 2025
1 parent 21144dd commit 6fabb1e
Show file tree
Hide file tree
Showing 3 changed files with 252 additions and 3 deletions.
14 changes: 11 additions & 3 deletions src/sentry/integrations/vsts/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from typing import Any
from urllib.parse import parse_qs, quote, urlencode, urlparse

import sentry_sdk
from django import forms
from django.http import HttpRequest
from django.http.response import HttpResponseBase
Expand Down Expand Up @@ -552,8 +551,17 @@ def build_integration(self, state: Mapping[str, Any]) -> Mapping[str, Any]:
sample_rate=1.0,
)

except (IntegrationModel.DoesNotExist, AssertionError, KeyError) as e:
sentry_sdk.capture_exception(e)
# Assertion error happens when org_integration does not exist
# KeyError happens when subscription is not found
except (IntegrationModel.DoesNotExist, AssertionError, KeyError):
if features.has(
"organizations:migrate-azure-devops-integration", self.pipeline.organization
):
# If there is a new integration, we need to set the migration version to 1
integration["metadata"][
"integration_migration_version"
] = VstsIntegrationProvider.CURRENT_MIGRATION_VERSION

logger.warning(
"vsts.build_integration.error",
extra={
Expand Down
50 changes: 50 additions & 0 deletions tests/sentry/integrations/vsts/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from sentry.shared_integrations.exceptions import ApiError
from sentry.silo.base import SiloMode
from sentry.silo.util import PROXY_BASE_PATH, PROXY_OI_HEADER, PROXY_PATH, PROXY_SIGNATURE_HEADER
from sentry.testutils.helpers import with_feature
from sentry.testutils.helpers.integrations import get_installation_of_type
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
from sentry.users.models.identity import Identity, IdentityProvider
Expand Down Expand Up @@ -70,6 +71,44 @@ def test_refreshes_expired_token(self):
assert identity.data["refresh_token"] == "new-refresh-token"
assert identity.data["expires"] > int(time())

@with_feature("organizations:migrate-azure-devops-integration")
def test_refreshes_expired_token_new_integration(self):
self.assert_installation(new=True)
integration, installation = self._get_integration_and_install()

# Make the Identity have an expired token
idp = IdentityProvider.objects.get(external_id=self.vsts_account_id)
identity = Identity.objects.get(idp_id=idp.id)
identity.data["expires"] = int(time()) - int(123456789)
identity.save()

# New values VSTS will return on refresh
self.access_token = "new-access-token"
self.refresh_token = "new-refresh-token"
self._stub_vsts()

# Make a request with expired token
installation.get_client().get_projects()

# Second to last request, before the Projects request, was to refresh
# the Access Token.
assert (
responses.calls[-2].request.url
== "https://login.microsoftonline.com/common/oauth2/v2.0/token"
)

# Then we request the Projects with the new token
assert (
responses.calls[-1].request.url.split("?")[0]
== f"{self.vsts_base_url.lower()}_apis/projects"
)

identity = Identity.objects.get(id=identity.id)
assert set(identity.scopes) == set(VstsIntegrationProvider.NEW_SCOPES)
assert identity.data["access_token"] == "new-access-token"
assert identity.data["refresh_token"] == "new-refresh-token"
assert identity.data["expires"] > int(time())

@responses.activate
def test_does_not_refresh_valid_tokens(self):
self.assert_installation()
Expand Down Expand Up @@ -125,6 +164,17 @@ def request_callback(request):
projects = installation.get_client().get_projects()
assert len(projects) == 220

@with_feature("organizations:migrate-azure-devops-integration")
def test_metadata_is_correct(self):
self.assert_installation(new=True)
integration, installation = self._get_integration_and_install()
assert integration.metadata["domain_name"] == "https://MyVSTSAccount.visualstudio.com/"
assert set(integration.metadata["scopes"]) == set(VstsIntegrationProvider.NEW_SCOPES)
assert (
integration.metadata["integration_migration_version"]
== VstsIntegrationProvider.CURRENT_MIGRATION_VERSION
)

@responses.activate
def test_simple(self):
responses.add(
Expand Down
191 changes: 191 additions & 0 deletions tests/sentry/integrations/vsts/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,197 @@ def test_get_repositories_identity_error(self):
installation.get_repositories()


@control_silo_test
@with_feature("organizations:migrate-azure-devops-integration")
class NewVstsIntegrationTest(VstsIntegrationTestCase):
def test_get_organization_config(self):
self.assert_installation()
integration, installation = self._get_integration_and_install()

fields = installation.get_organization_config()

assert [field["name"] for field in fields] == [
"sync_status_forward",
"sync_forward_assignment",
"sync_comments",
"sync_status_reverse",
"sync_reverse_assignment",
]

def test_get_organization_config_failure(self):
self.assert_installation()
integration, installation = self._get_integration_and_install()

# Set the `default_identity` property and force token expiration
installation.get_client()
assert installation.default_identity is not None
identity = Identity.objects.get(id=installation.default_identity.id)
identity.data["expires"] = 1566851050
identity.save()

responses.replace(
responses.POST,
"https://app.vssps.visualstudio.com/oauth2/token",
status=400,
json={"error": "invalid_grant", "message": "The provided authorization grant failed"},
)
fields = installation.get_organization_config()
assert fields[0]["disabled"], "Fields should be disabled"

def test_update_organization_config_remove_all(self):
self.assert_installation()

model = Integration.objects.get(provider="vsts")
integration = VstsIntegration(model, self.organization.id)

org_integration = OrganizationIntegration.objects.get(organization_id=self.organization.id)

data = {"sync_status_forward": {}, "other_option": "hello"}
IntegrationExternalProject.objects.create(
organization_integration_id=org_integration.id,
external_id=1,
resolved_status="ResolvedStatus1",
unresolved_status="UnresolvedStatus1",
)
IntegrationExternalProject.objects.create(
organization_integration_id=org_integration.id,
external_id=2,
resolved_status="ResolvedStatus2",
unresolved_status="UnresolvedStatus2",
)
IntegrationExternalProject.objects.create(
organization_integration_id=org_integration.id,
external_id=3,
resolved_status="ResolvedStatus3",
unresolved_status="UnresolvedStatus3",
)

integration.update_organization_config(data)

external_projects = IntegrationExternalProject.objects.all().values_list(
"external_id", flat=True
)

assert list(external_projects) == []

config = OrganizationIntegration.objects.get(
organization_id=org_integration.organization_id,
integration_id=org_integration.integration_id,
).config

assert config == {"sync_status_forward": False, "other_option": "hello"}

def test_update_organization_config(self):
self.assert_installation()

org_integration = OrganizationIntegration.objects.get(organization_id=self.organization.id)

model = Integration.objects.get(provider="vsts")
integration = VstsIntegration(model, self.organization.id)

# test validation
data: dict[str, Any] = {
"sync_status_forward": {1: {"on_resolve": "", "on_unresolve": "UnresolvedStatus1"}}
}
with pytest.raises(IntegrationError):
integration.update_organization_config(data)

data = {
"sync_status_forward": {
1: {"on_resolve": "ResolvedStatus1", "on_unresolve": "UnresolvedStatus1"},
2: {"on_resolve": "ResolvedStatus2", "on_unresolve": "UnresolvedStatus2"},
4: {"on_resolve": "ResolvedStatus4", "on_unresolve": "UnresolvedStatus4"},
},
"other_option": "hello",
}
IntegrationExternalProject.objects.create(
organization_integration_id=org_integration.id,
external_id=1,
resolved_status="UpdateMe",
unresolved_status="UpdateMe",
)
IntegrationExternalProject.objects.create(
organization_integration_id=org_integration.id,
external_id=2,
resolved_status="ResolvedStatus2",
unresolved_status="UnresolvedStatus2",
)
IntegrationExternalProject.objects.create(
organization_integration_id=org_integration.id,
external_id=3,
resolved_status="ResolvedStatus3",
unresolved_status="UnresolvedStatus3",
)

integration.update_organization_config(data)

external_projects = IntegrationExternalProject.objects.all().order_by("external_id")

assert external_projects[0].external_id == "1"
assert external_projects[0].resolved_status == "ResolvedStatus1"
assert external_projects[0].unresolved_status == "UnresolvedStatus1"

assert external_projects[1].external_id == "2"
assert external_projects[1].resolved_status == "ResolvedStatus2"
assert external_projects[1].unresolved_status == "UnresolvedStatus2"

assert external_projects[2].external_id == "4"
assert external_projects[2].resolved_status == "ResolvedStatus4"
assert external_projects[2].unresolved_status == "UnresolvedStatus4"

config = OrganizationIntegration.objects.get(
organization_id=org_integration.organization_id,
integration_id=org_integration.integration_id,
).config

assert config == {"sync_status_forward": True, "other_option": "hello"}

def test_update_domain_name(self):
account_name = "MyVSTSAccount.visualstudio.com"
account_uri = "https://MyVSTSAccount.visualstudio.com/"

self.assert_installation()

model = Integration.objects.get(provider="vsts")
model.metadata["domain_name"] = account_name
model.save()

integration = VstsIntegration(model, self.organization.id)
integration.get_client()

domain_name = integration.model.metadata["domain_name"]
assert domain_name == account_uri
assert Integration.objects.get(provider="vsts").metadata["domain_name"] == account_uri

def test_get_repositories(self):
self.assert_installation()
integration, installation = self._get_integration_and_install()

result = installation.get_repositories()
assert len(result) == 1
assert {"name": "ProjectA/cool-service", "identifier": self.repo_id} == result[0]

def test_get_repositories_identity_error(self):
self.assert_installation()
integration, installation = self._get_integration_and_install()

# Set the `default_identity` property and force token expiration
installation.get_client()
assert installation.default_identity is not None
identity = Identity.objects.get(id=installation.default_identity.id)
identity.data["expires"] = 1566851050
identity.save()

responses.replace(
responses.POST,
"https://app.vssps.visualstudio.com/oauth2/token",
status=400,
json={"error": "invalid_grant", "message": "The provided authorization grant failed"},
)
with pytest.raises(IntegrationError):
installation.get_repositories()


class RegionVstsIntegrationTest(VstsIntegrationTestCase):
def setUp(self):
super().setUp()
Expand Down

0 comments on commit 6fabb1e

Please sign in to comment.