Browse Source

feat(appconnect): Get dSYMs URL from the App Store Connect API [NATIVE-294] (#29513)

Instead of using the legacy iTunes endpoints to download the dSYMs
start using the App Store Connect API for this.

Co-authored-by: Betty Da <bda@sentry.io>
Floris Bruynooghe 3 years ago
parent
commit
88c7af7452

+ 1 - 0
mypy.ini

@@ -70,6 +70,7 @@ files = src/sentry/api/bases/external_actor.py,
         src/sentry/utils/kvstore,
         src/sentry/utils/time_window.py,
         src/sentry/web/decorators.py,
+        tests/sentry/lang/native/test_appconnect.py,
         tests/sentry/processing/realtime_metrics/,
         tests/sentry/tasks/test_low_priority_symbolication.py,
         tests/sentry/utils/appleconnect/

+ 31 - 104
src/sentry/lang/native/appconnect.py

@@ -5,10 +5,8 @@ this.
 """
 
 import dataclasses
-import io
 import logging
 import pathlib
-import time
 from datetime import datetime
 from typing import Any, Dict, List
 
@@ -20,11 +18,16 @@ from django.db import transaction
 
 from sentry.lang.native.symbolicator import APP_STORE_CONNECT_SCHEMA, secret_fields
 from sentry.models import Project
-from sentry.utils import json, sdk
+from sentry.utils import json
 from sentry.utils.appleconnect import appstore_connect, itunes_connect
 
 logger = logging.getLogger(__name__)
 
+# This might be odd, but it convinces mypy that this is part of this module's API.
+BuildInfo = appstore_connect.BuildInfo
+NoDsymUrl = appstore_connect.NoDsymUrl
+PublicProviderId = itunes_connect.PublicProviderId
+
 
 # The key in the project options under which all symbol sources are stored.
 SYMBOL_SOURCES_PROP_NAME = "sentry:symbol_sources"
@@ -33,14 +36,14 @@ SYMBOL_SOURCES_PROP_NAME = "sentry:symbol_sources"
 SYMBOL_SOURCE_TYPE_NAME = "appStoreConnect"
 
 
-class InvalidCredentialsError(Exception):
-    """Invalid credentials for the App Store Connect API."""
+class InvalidConfigError(Exception):
+    """Invalid configuration for the appStoreConnect symbol source."""
 
     pass
 
 
-class InvalidConfigError(Exception):
-    """Invalid configuration for the appStoreConnect symbol source."""
+class PendingDsymsError(Exception):
+    """dSYM url is currently unavailable."""
 
     pass
 
@@ -114,7 +117,7 @@ class AppStoreConnectConfig:
     #
     # An iTunes session can have multiple organisations and needs this ID to be able to
     # select the correct organisation to operate on.
-    orgPublicId: itunes_connect.PublicProviderId
+    orgPublicId: PublicProviderId
 
     # The name of an organisation, as supplied by iTunes.
     orgName: str
@@ -250,74 +253,6 @@ class AppStoreConnectConfig:
         return all_sources
 
 
-@dataclasses.dataclass(frozen=True)
-class BuildInfo:
-    """Information about an App Store Connect build.
-
-    A build is identified by the tuple of (app_id, platform, version, build_number), though
-    Apple mostly names these differently.
-    """
-
-    # The app ID
-    app_id: str
-
-    # A platform identifier, e.g. iOS, TvOS etc.
-    #
-    # These are not always human-readable and can be some opaque string supplied by Apple.
-    platform: str
-
-    # The human-readable version, e.g. "7.2.0".
-    #
-    # Each version can have multiple builds, Apple naming is a little confusing and calls
-    # this "bundle_short_version".
-    version: str
-
-    # The build number, typically just a monotonically increasing number.
-    #
-    # Apple naming calls this the "bundle_version".
-    build_number: str
-
-    # The date and time the build was uploaded to App Store Connect.
-    uploaded_date: datetime
-
-
-class ITunesClient:
-    """A client for the legacy iTunes API.
-
-    Create this by calling :class:`AppConnectClient.itunes_client()`.
-
-    On creation this will contact iTunes and will fail if it does not have a valid iTunes
-    session.
-    """
-
-    def __init__(self, itunes_cookie: str, itunes_org: itunes_connect.PublicProviderId):
-        self._client = itunes_connect.ITunesClient.from_session_cookie(itunes_cookie)
-        self._client.set_provider(itunes_org)
-
-    def download_dsyms(self, build: BuildInfo, path: pathlib.Path) -> None:
-        with sentry_sdk.start_span(op="dsyms", description="Download dSYMs"):
-            url = self._client.get_dsym_url(
-                build.app_id, build.version, build.build_number, build.platform
-            )
-            if not url:
-                raise NoDsymsError
-            logger.debug("Fetching dSYM from: %s", url)
-            # The 315s is just above how long it would take a 4MB/s connection to download
-            # 2GB.
-            with requests.get(url, stream=True, timeout=15) as req:
-                req.raise_for_status()
-                start = time.time()
-                bytes_count = 0
-                with open(path, "wb") as fp:
-                    for chunk in req.iter_content(chunk_size=io.DEFAULT_BUFFER_SIZE):
-                        if (time.time() - start) > 315:
-                            with sdk.configure_scope() as scope:
-                                scope.set_extra("dSYM.bytes_fetched", bytes_count)
-                            raise requests.Timeout("Timeout during dSYM download")
-                        bytes_count += len(chunk)
-                        fp.write(chunk)
-
-
 class AppConnectClient:
     """Client to interact with a single app from App Store Connect.
 
@@ -329,15 +264,11 @@ class AppConnectClient:
     def __init__(
         self,
         api_credentials: appstore_connect.AppConnectCredentials,
-        itunes_cookie: str,
-        itunes_org: itunes_connect.PublicProviderId,
         app_id: str,
     ) -> None:
         """Internal init, use one of the classmethods instead."""
         self._api_credentials = api_credentials
         self._session = requests.Session()
-        self._itunes_cookie = itunes_cookie
-        self._itunes_org = itunes_org
         self._app_id = app_id
 
     @classmethod
@@ -365,33 +296,29 @@ class AppConnectClient:
         )
         return cls(
             api_credentials=api_credentials,
-            itunes_cookie=config.itunesSession,
-            itunes_org=config.orgPublicId,
             app_id=config.appId,
         )
 
-    def itunes_client(self) -> ITunesClient:
-        """Returns an iTunes client capable of downloading dSYMs.
-
-        :raises itunes_connect.SessionExpired: if the session cookie is expired.
-        """
-        return ITunesClient(itunes_cookie=self._itunes_cookie, itunes_org=self._itunes_org)
-
     def list_builds(self) -> List[BuildInfo]:
         """Returns the available AppStore builds."""
-        builds = []
-        all_results = appstore_connect.get_build_info(
-            self._session, self._api_credentials, self._app_id
-        )
-        for build in all_results:
-            builds.append(
-                BuildInfo(
-                    app_id=self._app_id,
-                    platform=build["platform"],
-                    version=build["version"],
-                    build_number=build["build_number"],
-                    uploaded_date=build["uploaded_date"],
-                )
-            )
+        return appstore_connect.get_build_info(self._session, self._api_credentials, self._app_id)
 
-        return builds
+    def download_dsyms(self, build: BuildInfo, path: pathlib.Path) -> None:
+        """Downloads the dSYMs from the build into the filename given by `path`.
+
+        The dSYMs are downloaded as a zipfile so when this call succeeds the file at `path`
+        will contain a zipfile.
+        """
+        with sentry_sdk.start_span(op="dsym", description="Download dSYMs"):
+            if not isinstance(build.dsym_url, str):
+                if build.dsym_url is NoDsymUrl.NOT_NEEDED:
+                    raise NoDsymsError
+                elif build.dsym_url is NoDsymUrl.PENDING:
+                    raise PendingDsymsError
+                else:
+                    raise ValueError(f"dSYM URL missing: {build.dsym_url}")
+
+            logger.debug("Fetching dSYMs from: %s", build.dsym_url)
+            appstore_connect.download_dsyms(
+                self._session, self._api_credentials, build.dsym_url, path
+            )

+ 31 - 31
src/sentry/tasks/app_store_connect.py

@@ -23,7 +23,7 @@ from sentry.models import (
 )
 from sentry.tasks.base import instrumented_task
 from sentry.utils import json, metrics, sdk
-from sentry.utils.appleconnect import itunes_connect
+from sentry.utils.appleconnect import appstore_connect as appstoreconnect_api
 
 logger = logging.getLogger(__name__)
 
@@ -51,55 +51,55 @@ def inner_dsym_download(project_id: int, config_id: str) -> None:
     builds = process_builds(project=project, config=config, to_process=listed_builds)
 
     if not builds:
-        # No point in trying to see if we have valid iTunes credentials.
         return
-    try:
-        itunes_client = client.itunes_client()
-    except itunes_connect.SessionExpiredError:
-        logger.debug("No valid iTunes session, can not download dSYMs")
-        metrics.incr(
-            "sentry.tasks.app_store_connect.itunes_session.needed",
-            tags={"valid": "false"},
-            sample_rate=1,
-        )
-        return
-    else:
-        metrics.incr(
-            "sentry.tasks.app_store_connect.itunes_session.needed",
-            tags={"valid": "true"},
-            sample_rate=1,
-        )
+
     for i, (build, build_state) in enumerate(builds):
         with sdk.configure_scope() as scope:
             scope.set_context("dsym_downloads", {"total": len(builds), "completed": i})
         with tempfile.NamedTemporaryFile() as dsyms_zip:
             try:
-                itunes_client.download_dsyms(build, pathlib.Path(dsyms_zip.name))
+                client.download_dsyms(build, pathlib.Path(dsyms_zip.name))
+            # For no dSYMs, let the build be marked as fetched so they're not
+            # repeatedly re-checked every time this task is run.
             except appconnect.NoDsymsError:
                 logger.debug("No dSYMs for build %s", build)
-            except itunes_connect.SessionExpiredError:
-                logger.debug("Error fetching dSYMs: expired iTunes session")
-                # we early-return here to avoid trying all the other builds
-                # as well, since an expired token will error for all of them.
-                # we also swallow the error and not report it because this is
-                # a totally expected error and not actionable.
+            # Moves on to the next build so we don't check off fetched. This url will
+            # eventuallyTM be populated, so revisit it at a later time.
+            except appconnect.PendingDsymsError:
+                logger.debug("dSYM url currently unavailable for build %s", build)
+                continue
+            # early-return in unauthorized and forbidden to avoid trying all the other builds
+            # as well, since an expired token will error for all of them.
+            # the error is also swallowed unreported because this is an expected and actionable
+            # error.
+            except appstoreconnect_api.UnauthorizedError:
+                sentry_sdk.capture_message(
+                    "Not authorized to download dSYM using current App Store Connect credentials",
+                    level="info",
+                )
                 return
-            except itunes_connect.ForbiddenError:
+            except appstoreconnect_api.ForbiddenError:
                 sentry_sdk.capture_message(
-                    "Forbidden iTunes dSYM download, probably switched to wrong org", level="info"
+                    "Forbidden from downloading dSYM using current App Store Connect credentials",
+                    level="info",
                 )
                 return
+            # Don't let malformed URLs abort all pending downloads in case it's an isolated instance
+            except ValueError as e:
+                sdk.capture_exception(e)
+                continue
+            # Assume request errors are a server side issue and do not abort all the
+            # pending downloads.
+            except appstoreconnect_api.RequestError as e:
+                sdk.capture_exception(e)
+                continue
             except requests.RequestException as e:
-                # Assume these are errors with the server side and do not abort all the
-                # pending downloads.
                 sdk.capture_exception(e)
                 continue
             else:
                 create_difs_from_dsyms_zip(dsyms_zip.name, project)
                 logger.debug("Uploaded dSYMs for build %s", build)
 
-        # If we either downloaded, or didn't need to download the dSYMs
-        # (there was no dSYM url), we check off this build.
         build_state.fetched = True
         build_state.save()
 

+ 182 - 24
src/sentry/utils/appleconnect/appstore_connect.py

@@ -1,14 +1,19 @@
+import dataclasses
+import datetime
+import enum
+import io
 import logging
+import pathlib
 import time
 from collections import namedtuple
 from http import HTTPStatus
-from typing import Any, Dict, Generator, List, Mapping, NewType, Optional, Tuple
+from typing import Any, Dict, Generator, List, Mapping, NewType, Optional, Tuple, Union
 
 import sentry_sdk
 from dateutil.parser import parse as parse_date
-from requests import Session
+from requests import Session, Timeout
 
-from sentry.utils import jwt, safe
+from sentry.utils import jwt, safe, sdk
 from sentry.utils.json import JSONData
 
 logger = logging.getLogger(__name__)
@@ -30,6 +35,57 @@ class UnauthorizedError(RequestError):
     pass
 
 
+class ForbiddenError(RequestError):
+    """The App Store Connect session does not have access to the requested dSYM."""
+
+    pass
+
+
+class NoDsymUrl(enum.Enum):
+    """Indicates the reason of absense of a dSYM URL from :class:`BuildInfo`."""
+
+    # Currently unused because we haven't seen scenarios where this can happen yet.
+    PENDING = enum.auto()
+    NOT_NEEDED = enum.auto()
+
+
+@dataclasses.dataclass(frozen=True)
+class BuildInfo:
+    """Information about an App Store Connect build.
+
+    A build is identified by the tuple of (app_id, platform, version, build_number), though
+    Apple mostly names these differently.
+    """
+
+    # The app ID
+    app_id: str
+
+    # A platform identifier, e.g. iOS, TvOS etc.
+    #
+    # These are not always human-readable and can be some opaque string supplied by Apple.
+    platform: str
+
+    # The human-readable version, e.g. "7.2.0".
+    #
+    # Each version can have multiple builds, Apple naming is a little confusing and calls
+    # this "bundle_short_version".
+    version: str
+
+    # The build number, typically just a monotonically increasing number.
+    #
+    # Apple naming calls this the "bundle_version".
+    build_number: str
+
+    # The date and time the build was uploaded to App Store Connect.
+    uploaded_date: datetime.datetime
+
+    # The URL where we can download a zip file with dSYMs from.
+    #
+    # Empty string if no dSYMs exist, None if there are dSYMs but they're not immediately available,
+    # and is some string value if there are dSYMs and they're available.
+    dsym_url: Union[NoDsymUrl, str]
+
+
 def _get_authorization_header(
     credentials: AppConnectCredentials, expiry_sec: Optional[int] = None
 ) -> Mapping[str, str]:
@@ -173,18 +229,45 @@ class _IncludedRelations:
         """
         rel_ptr_data = safe.get_path(data, "relationships", relation, "data")
         if rel_ptr_data is None:
+            # Because the related information was requested in the query does not mean a
+            # relation of that type did exist.
             # E.g. a query asks for both the appStoreVersion and preReleaseVersion relations
             # to be included.  However for each build there could be only one of these that
             # will have the data with type and id, the other will have None for data.
             return None
+        assert isinstance(rel_ptr_data, dict)
         rel_type = _RelType(rel_ptr_data["type"])
         rel_id = _RelId(rel_ptr_data["id"])
         return self._items[(rel_type, rel_id)]
 
+    def get_multiple_related(self, data: JSONData, relation: str) -> Optional[List[JSONData]]:
+        """Returns a list of all the related objects of the named relation type.
+
+        This is like :meth:`get_related` but is for relation types which have a list of
+        related objects instead of exactly one.  An example of this is a ``build`` can have
+        multiple ``buildBundles`` related to it.
+
+        Having this as a separate method makes it easier to handle the type checking.
+        """
+        rel_ptr_data = safe.get_path(data, "relationships", relation, "data")
+        if rel_ptr_data is None:
+            # Because the related information was requested in the query does not mean a
+            # relation of that type did exist.
+            return None
+        assert isinstance(rel_ptr_data, list)
+        all_related = []
+        for relationship in rel_ptr_data:
+            rel_type = _RelType(relationship["type"])
+            rel_id = _RelId(relationship["id"])
+            related_item = self._items[(rel_type, rel_id)]
+            if related_item:
+                all_related.append(related_item)
+        return all_related
+
 
 def get_build_info(
     session: Session, credentials: AppConnectCredentials, app_id: str
-) -> List[Dict[str, Any]]:
+) -> List[BuildInfo]:
     """Returns the build infos for an application.
 
     The release build version information has the following structure:
@@ -199,16 +282,16 @@ def get_build_info(
     ):
         # https://developer.apple.com/documentation/appstoreconnectapi/list_builds
         url = (
-            f"v1/builds?filter[app]={app_id}"
+            "v1/builds"
+            # filter for this app only, our API key may give us access to more than one app
+            f"?filter[app]={app_id}"
             # we can fetch a maximum of 200 builds at once, so do that
             "&limit=200"
-            # include related AppStore/PreRelease versions with the response
-            # NOTE: the `iris` web API has related `buildBundles` objects,
-            # which have very useful `includesSymbols` and `dSYMUrl` attributes,
-            # but this is sadly not available in the official API. :-(
-            # Open this in your browser when you are signed into AppStoreConnect:
-            # https://appstoreconnect.apple.com/iris/v1/builds?filter[processingState]=VALID&include=appStoreVersion,preReleaseVersion,buildBundles&limit=1&filter[app]=XYZ
-            "&include=appStoreVersion,preReleaseVersion"
+            # include related AppStore/PreRelease versions with the response as well as
+            # buildBundles which contains metadata on the debug resources (dSYMs)
+            "&include=appStoreVersion,preReleaseVersion,buildBundles"
+            # fetch the maximum number of build bundles
+            "&limit[buildBundles]=50"
             # sort newer releases first
             "&sort=-uploadedDate"
             # only include valid builds
@@ -217,12 +300,10 @@ def get_build_info(
             "&filter[expired]=false"
         )
         pages = _get_appstore_info_paged(session, credentials, url)
-        result = []
+        build_info = []
 
         for page in pages:
-
             relations = _IncludedRelations(page)
-
             for build in page["data"]:
                 try:
                     related_appstore_version = relations.get_related(build, "appStoreVersion")
@@ -244,20 +325,68 @@ def get_build_info(
                     build_number = build["attributes"]["version"]
                     uploaded_date = parse_date(build["attributes"]["uploadedDate"])
 
-                    result.append(
-                        {
-                            "platform": platform,
-                            "version": version,
-                            "build_number": build_number,
-                            "uploaded_date": uploaded_date,
-                        }
+                    build_bundles = relations.get_multiple_related(build, "buildBundles")
+                    with sentry_sdk.push_scope() as scope:
+                        scope.set_context(
+                            "App Store Connect Build",
+                            {
+                                "build": build,
+                                "build_bundles": build_bundles,
+                            },
+                        )
+                        dsym_url = _get_dsym_url(build_bundles)
+
+                    build_info.append(
+                        BuildInfo(
+                            app_id=app_id,
+                            platform=platform,
+                            version=version,
+                            build_number=build_number,
+                            uploaded_date=uploaded_date,
+                            dsym_url=dsym_url,
+                        )
                     )
                 except Exception:
                     logger.error(
-                        "Failed to process AppStoreConnect build from API: %s", build, exc_info=True
+                        "Failed to process AppStoreConnect build from API: %s",
+                        build,
+                        exc_info=True,
                     )
 
-        return result
+        return build_info
+
+
+def _get_dsym_url(bundles: Optional[List[JSONData]]) -> Union[NoDsymUrl, str]:
+    """Returns the dSYMs URL from the extracted from the build bundles."""
+    # https://developer.apple.com/documentation/appstoreconnectapi/build/relationships/buildbundles
+    # https://developer.apple.com/documentation/appstoreconnectapi/buildbundle/attributes
+    # If you ever write code for this here you probably will find an
+    # includesSymbols attribute in the buildBundles and wonder why we ignore
+    # it.  Then you'll look at it and wonder why it doesn't match anything
+    # to do with whether dSYMs can be downloaded or not.  This is because
+    # the includesSymbols only indicates whether App Store Connect has full
+    # symbol names or not, it does not have anything to do with whether it
+    # was a bitcode upload or a native upload.  And whether dSYMs are
+    # available for download only depends on whether it was a bitcode
+    # upload.
+    if bundles is None or len(bundles) == 0:
+        return NoDsymUrl.NOT_NEEDED
+
+    if len(bundles) > 1:
+        # We currently do not know how to handle these, we'll carry on
+        # with the first bundle but report this as an error.
+        sentry_sdk.capture_message("len(buildBundles) != 1")
+
+    # Because we only ask for processingState=VALID builds we expect the
+    # builds to be finished and if there are no dSYMs that means the
+    # build doesn't need dSYMs, i.e. it not a bitcode build.
+    bundle = bundles[0]
+    url = safe.get_path(bundle, "attributes", "dSYMUrl", default=NoDsymUrl.NOT_NEEDED)
+
+    if isinstance(url, (NoDsymUrl, str)):
+        return url
+    else:
+        raise ValueError(f"Unexpected value in build bundle's dSYMUrl: {url}")
 
 
 AppInfo = namedtuple("AppInfo", ["name", "bundle_id", "app_id"])
@@ -291,3 +420,32 @@ def get_apps(session: Session, credentials: AppConnectCredentials) -> Optional[L
     except ValueError:
         return None
     return ret_val
+
+
+def download_dsyms(
+    session: Session, credentials: AppConnectCredentials, url: str, path: pathlib.Path
+) -> None:
+    """Downloads dSYMs at `url` into `path` which must be a filename."""
+    headers = _get_authorization_header(credentials)
+
+    with session.get(url, headers=headers, stream=True, timeout=15) as res:
+        status = res.status_code
+        if status == HTTPStatus.UNAUTHORIZED:
+            raise UnauthorizedError
+        elif status == HTTPStatus.FORBIDDEN:
+            raise ForbiddenError
+        elif status != HTTPStatus.OK:
+            raise RequestError(f"Bad status code downloading dSYM: {status}")
+
+        start = time.time()
+        bytes_count = 0
+        with open(path, "wb") as fp:
+            for chunk in res.iter_content(chunk_size=io.DEFAULT_BUFFER_SIZE):
+                # The 315s is just above how long it would take a 4MB/s connection to download
+                # 2GB.
+                if (time.time() - start) > 315:
+                    with sdk.configure_scope() as scope:
+                        scope.set_extra("dSYM.bytes_fetched", bytes_count)
+                    raise Timeout("Timeout during dSYM download")
+                bytes_count += len(chunk)
+                fp.write(chunk)

+ 110 - 22
tests/sentry/lang/native/test_appconnect.py

@@ -1,20 +1,27 @@
+import pathlib
 import uuid
 from datetime import datetime
+from typing import TYPE_CHECKING, Union
+from unittest import mock
 
 import pytest
+from django.utils import timezone
 
 from sentry.lang.native import appconnect
 from sentry.utils import json
 
+if TYPE_CHECKING:
+    from sentry.models import Project
+
 
 class TestAppStoreConnectConfig:
-    @pytest.fixture
-    def now(self):
+    @pytest.fixture  # type: ignore
+    def now(self) -> datetime:
         # Fixture so we can have one "now" for the entire test and its fixtures.
         return datetime.utcnow()
 
-    @pytest.fixture
-    def data(self, now):
+    @pytest.fixture  # type: ignore
+    def data(self, now: datetime) -> json.JSONData:
         return {
             "type": "appStoreConnect",
             "id": "abc123",
@@ -33,7 +40,7 @@ class TestAppStoreConnectConfig:
             "orgName": "Example Organisation",
         }
 
-    def test_from_json_basic(self, data, now):
+    def test_from_json_basic(self, data: json.JSONData, now: datetime) -> None:
         config = appconnect.AppStoreConnectConfig.from_json(data)
         assert config.type == "appStoreConnect"
         assert config.id == data["id"]
@@ -49,17 +56,17 @@ class TestAppStoreConnectConfig:
         assert config.orgPublicId == data["orgPublicId"]
         assert config.orgName == data["orgName"]
 
-    def test_from_json_isoformat(self, data, now):
+    def test_from_json_isoformat(self, data: json.JSONData, now: datetime) -> None:
         data["itunesCreated"] = now.isoformat()
         config = appconnect.AppStoreConnectConfig.from_json(data)
         assert config.itunesCreated == now
 
-    def test_from_json_datetime(self, data, now):
+    def test_from_json_datetime(self, data: json.JSONData, now: datetime) -> None:
         data["itunesCreated"] = now
         config = appconnect.AppStoreConnectConfig.from_json(data)
         assert config.itunesCreated == now
 
-    def test_to_json(self, data, now):
+    def test_to_json(self, data: json.JSONData, now: datetime) -> None:
         config = appconnect.AppStoreConnectConfig.from_json(data)
         new_data = config.to_json()
 
@@ -68,7 +75,7 @@ class TestAppStoreConnectConfig:
 
         assert new_data == data
 
-    def test_to_redacted_json(self, data, now):
+    def test_to_redacted_json(self, data: json.JSONData, now: datetime) -> None:
         config = appconnect.AppStoreConnectConfig.from_json(data)
         new_data = config.to_redacted_json()
 
@@ -82,15 +89,17 @@ class TestAppStoreConnectConfig:
 
         assert new_data == data
 
-    @pytest.mark.django_db
-    def test_from_project_config_empty_sources(self, default_project, data):
+    @pytest.mark.django_db  # type: ignore
+    def test_from_project_config_empty_sources(
+        self, default_project: "Project", data: json.JSONData
+    ) -> None:
         with pytest.raises(KeyError):
             appconnect.AppStoreConnectConfig.from_project_config(default_project, "not-an-id")
 
 
 class TestAppStoreConnectConfigUpdateProjectSymbolSource:
-    @pytest.fixture
-    def config(self):
+    @pytest.fixture  # type: ignore
+    def config(self) -> appconnect.AppStoreConnectConfig:
         return appconnect.AppStoreConnectConfig(
             type="appStoreConnect",
             id=uuid.uuid4().hex,
@@ -105,12 +114,14 @@ class TestAppStoreConnectConfigUpdateProjectSymbolSource:
             appName="My App",
             appId="123",
             bundleId="com.example.app",
-            orgPublicId="71105f98-7743-4844-ab70-2c901e2ea13d",
+            orgPublicId=appconnect.PublicProviderId("71105f98-7743-4844-ab70-2c901e2ea13d"),
             orgName="Example Com",
         )
 
-    @pytest.mark.django_db
-    def test_new_source(self, default_project, config):
+    @pytest.mark.django_db  # type: ignore
+    def test_new_source(
+        self, default_project: "Project", config: appconnect.AppStoreConnectConfig
+    ) -> None:
         sources = config.update_project_symbol_source(default_project, allow_multiple=False)
 
         cfg = appconnect.AppStoreConnectConfig.from_json(sources[0].copy())
@@ -120,8 +131,10 @@ class TestAppStoreConnectConfigUpdateProjectSymbolSource:
         stored_sources = json.loads(raw)
         assert stored_sources == sources
 
-    @pytest.mark.django_db
-    def test_new_sources_with_existing(self, default_project, config):
+    @pytest.mark.django_db  # type: ignore
+    def test_new_sources_with_existing(
+        self, default_project: "Project", config: appconnect.AppStoreConnectConfig
+    ) -> None:
         old_sources = json.dumps(
             [{"type": "not-this-one", "id": "a"}, {"type": "not-this-one", "id": "b"}]
         )
@@ -140,8 +153,10 @@ class TestAppStoreConnectConfigUpdateProjectSymbolSource:
         new_sources.append(cfg.to_json())
         assert stored_sources == new_sources
 
-    @pytest.mark.django_db
-    def test_update(self, default_project, config):
+    @pytest.mark.django_db  # type: ignore
+    def test_update(
+        self, default_project: "Project", config: appconnect.AppStoreConnectConfig
+    ) -> None:
         config.update_project_symbol_source(default_project, allow_multiple=False)
 
         updated = appconnect.AppStoreConnectConfig(
@@ -167,8 +182,10 @@ class TestAppStoreConnectConfigUpdateProjectSymbolSource:
         current = appconnect.AppStoreConnectConfig.from_project_config(default_project, config.id)
         assert current.itunesSession == "A NEW COOKIE"
 
-    @pytest.mark.django_db
-    def test_update_no_matching_id(self, default_project, config):
+    @pytest.mark.django_db  # type: ignore
+    def test_update_no_matching_id(
+        self, default_project: "Project", config: appconnect.AppStoreConnectConfig
+    ) -> None:
         config.update_project_symbol_source(default_project, allow_multiple=False)
 
         updated = appconnect.AppStoreConnectConfig(
@@ -191,3 +208,74 @@ class TestAppStoreConnectConfigUpdateProjectSymbolSource:
 
         with pytest.raises(ValueError):
             updated.update_project_symbol_source(default_project, allow_multiple=False)
+
+
+class TestDownloadDsyms:
+    @pytest.fixture  # type: ignore
+    def client(self) -> appconnect.AppConnectClient:
+        return appconnect.AppConnectClient(
+            app_id="honk",
+            api_credentials=appconnect.appstore_connect.AppConnectCredentials(
+                key_id="beep",
+                key="honkbeep",
+                issuer_id="beeper",
+            ),
+        )
+
+    def build_with_url(self, url: Union[str, appconnect.NoDsymUrl]) -> appconnect.BuildInfo:
+        return appconnect.BuildInfo(
+            app_id="honk",
+            platform="macOS",
+            version="3.1.0",
+            build_number="20101010",
+            uploaded_date=timezone.now(),
+            dsym_url=url,
+        )
+
+    def test_empty_string_url(
+        self, client: appconnect.AppConnectClient, tmp_path: pathlib.Path
+    ) -> None:
+        build_info = self.build_with_url("")
+
+        with mock.patch(
+            "sentry.utils.appleconnect.appstore_connect.download_dsyms"
+        ) as mock_api_download_dsyms:
+            client.download_dsyms(build_info, tmp_path / "dsyms.zip")
+
+            assert mock_api_download_dsyms.call_count == 1
+
+    def test_no_dsyms(self, client: appconnect.AppConnectClient, tmp_path: pathlib.Path) -> None:
+        build_info = self.build_with_url(appconnect.NoDsymUrl.NOT_NEEDED)
+
+        with mock.patch(
+            "sentry.utils.appleconnect.appstore_connect.download_dsyms"
+        ) as mock_api_download_dsyms:
+            with pytest.raises(appconnect.NoDsymsError):
+                client.download_dsyms(build_info, tmp_path / "dsyms.zip")
+
+            assert mock_api_download_dsyms.call_count == 0
+
+    def test_no_unfetched(
+        self, client: appconnect.AppConnectClient, tmp_path: pathlib.Path
+    ) -> None:
+        build_info = self.build_with_url(appconnect.NoDsymUrl.PENDING)
+
+        with mock.patch(
+            "sentry.utils.appleconnect.appstore_connect.download_dsyms"
+        ) as mock_api_download_dsyms:
+            with pytest.raises(appconnect.PendingDsymsError):
+                client.download_dsyms(build_info, tmp_path / "dsyms.zip")
+
+            assert mock_api_download_dsyms.call_count == 0
+
+    def test_valid_url(self, client: appconnect.AppConnectClient, tmp_path: pathlib.Path) -> None:
+        build_info = self.build_with_url(
+            "http://iosapps.itunes.apple.com/itunes-assets/very-real-url"
+        )
+
+        with mock.patch(
+            "sentry.utils.appleconnect.appstore_connect.download_dsyms"
+        ) as mock_api_download_dsyms:
+            client.download_dsyms(build_info, tmp_path / "dsyms.zip")
+
+            assert mock_api_download_dsyms.call_count == 1

+ 2 - 0
tests/sentry/tasks/test_app_store_connect.py

@@ -39,6 +39,7 @@ class TestUpdateDsyms:
             version="3.1.5",
             build_number="20200220",
             uploaded_date=timezone.now(),
+            dsym_url="http://iosapps.itunes.apple.com/itunes-assets/Purple116/v4/20/ba/a0/20baa026-2410-b32f-1fde-b227bc2ea7ae/appDsyms.zip?accessKey=very-cool-key",
         )
 
     @pytest.mark.django_db
@@ -86,6 +87,7 @@ class TestUpdateDsyms:
             version="3.1.9",
             build_number="20200224",
             uploaded_date=timezone.now(),
+            dsym_url="http://iosapps.itunes.apple.com/itunes-assets/Purple116/v4/20/ba/a0/20baa026-2410-b32f-1fde-b227bc2ea7ae/appDsyms.zip?accessKey=very-cool-key",
         )
 
         pending = process_builds(

+ 121 - 0
tests/sentry/utils/appleconnect/test_appstore_connect.py

@@ -0,0 +1,121 @@
+import pytest
+
+from sentry.lang.native.appconnect import NoDsymUrl
+from sentry.utils.appleconnect import appstore_connect
+
+
+class TestGetDsymUrl:
+    def test_none_bundles(self) -> None:
+        assert appstore_connect._get_dsym_url(None) is NoDsymUrl.NOT_NEEDED
+
+    def test_empty_bundle_list(self) -> None:
+        assert appstore_connect._get_dsym_url([]) is NoDsymUrl.NOT_NEEDED
+
+    def test_one_bundle_strange_url(self) -> None:
+        bundles = [
+            {
+                "type": "buildBundles",
+                "id": "59467f37-371e-4755-afcd-0116775a6eab",
+                "attributes": {
+                    "dSYMUrl": 1,
+                },
+            }
+        ]
+
+        with pytest.raises(ValueError):
+            appstore_connect._get_dsym_url(bundles)
+
+    def test_one_bundle_no_url(self) -> None:
+        bundles = [
+            {
+                "type": "buildBundles",
+                "id": "59467f37-371e-4755-afcd-0116775a6eab",
+                "attributes": {
+                    "dSYMUrl": None,
+                },
+            }
+        ]
+
+        assert appstore_connect._get_dsym_url(bundles) is NoDsymUrl.NOT_NEEDED
+
+    def test_one_bundle_has_url(self) -> None:
+        url = "http://iosapps.itunes.apple.com/itunes-assets/very-real-url"
+        bundles = [
+            {
+                "type": "buildBundles",
+                "id": "59467f37-371e-4755-afcd-0116775a6eab",
+                "attributes": {
+                    "dSYMUrl": url,
+                },
+            }
+        ]
+
+        assert appstore_connect._get_dsym_url(bundles) == url
+
+    def test_multi_bundle_no_url(self) -> None:
+        bundles = [
+            {
+                "type": "buildBundles",
+                "id": "59467f37-371e-4755-afcd-0116775a6eab",
+                "attributes": {
+                    "dSYMUrl": None,
+                },
+            },
+            {
+                "type": "buildBundles",
+                "id": "5e231f58-31c6-47cc-b4f8-56952d44a158",
+                "attributes": {
+                    "dSYMUrl": None,
+                },
+            },
+        ]
+
+        assert appstore_connect._get_dsym_url(bundles) is NoDsymUrl.NOT_NEEDED
+
+    def test_multi_bundle_has_url(self) -> None:
+        first_url = "http://iosapps.itunes.apple.com/itunes-assets/very-real-url"
+        second_url = "http://iosapps.itunes.apple.com/itunes-assets/very-fake-url"
+        bundles = [
+            {
+                "type": "buildBundles",
+                "id": "59467f37-371e-4755-afcd-0116775a6eab",
+                "attributes": {
+                    "dSYMUrl": first_url,
+                },
+            },
+            {
+                "type": "buildBundles",
+                "id": "5e231f58-31c6-47cc-b4f8-56952d44a158",
+                "attributes": {
+                    "dSYMUrl": second_url,
+                },
+            },
+        ]
+        assert appstore_connect._get_dsym_url(bundles) is first_url
+
+        bundles.reverse()
+        assert appstore_connect._get_dsym_url(bundles) is second_url
+
+    def test_multi_bundle_mixed_urls(self) -> None:
+        url = "http://iosapps.itunes.apple.com/itunes-assets/very-real-url"
+        bundles = [
+            {
+                "type": "buildBundles",
+                "id": "59467f37-371e-4755-afcd-0116775a6eab",
+                "attributes": {
+                    "dSYMUrl": url,
+                },
+            },
+            {
+                "type": "buildBundles",
+                "id": "5e231f58-31c6-47cc-b4f8-56952d44a158",
+                "attributes": {
+                    "dSYMUrl": None,
+                },
+            },
+        ]
+
+        assert appstore_connect._get_dsym_url(bundles) is url
+
+        bundles.reverse()
+        assert appstore_connect._get_dsym_url(bundles) is NoDsymUrl.NOT_NEEDED