Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 fix(azure devops): fix migration version on a fresh install #83290

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 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,
)

# Assertion error happens when org_integration does not exist
# KeyError happens when subscription is not found
except (IntegrationModel.DoesNotExist, AssertionError, KeyError) as e:
sentry_sdk.capture_exception(e)
if isinstance(e, IntegrationModel.DoesNotExist) and 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
39 changes: 39 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
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
Loading