From 33a22d3fcc0fe7faeb059bed0341cc765841036d Mon Sep 17 00:00:00 2001 From: Eric Gustin Date: Sat, 13 Jun 2026 23:57:26 -0700 Subject: [PATCH] [TOO-1171] feat(arcade-core): add Sentry OAuth2 provider class Adds `class Sentry(OAuth2)` (provider_id "sentry") so toolkit authors can declare `requires_auth=Sentry()`, mirroring the existing well-known provider classes, and re-exports it from the arcade-tdk and arcade-mcp-server auth modules. Sister to the Engine well-known provider (TOO-1170). Adds the dynamic test_auth_providers.py, which constructs every OAuth2 subclass so a newly added provider is covered with no per-provider edit; arcade_core/auth.py reports 100% coverage with Sentry included. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/arcade-core/arcade_core/auth.py | 9 ++++ .../arcade_mcp_server/auth/__init__.py | 2 + libs/arcade-tdk/arcade_tdk/auth/__init__.py | 2 + libs/tests/core/test_auth_providers.py | 54 +++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 libs/tests/core/test_auth_providers.py diff --git a/libs/arcade-core/arcade_core/auth.py b/libs/arcade-core/arcade_core/auth.py index fc0dc1afa..1b339d954 100644 --- a/libs/arcade-core/arcade_core/auth.py +++ b/libs/arcade-core/arcade_core/auth.py @@ -177,6 +177,15 @@ def __init__(self, *, id: Optional[str] = None, scopes: Optional[list[str]] = No super().__init__(id=id, scopes=scopes) +class Sentry(OAuth2): + """Marks a tool as requiring Sentry authorization.""" + + provider_id: str = "sentry" + + def __init__(self, *, id: Optional[str] = None, scopes: Optional[list[str]] = None): # noqa: A002 + super().__init__(id=id, scopes=scopes) + + class Slack(OAuth2): """Marks a tool as requiring Slack (user token) authorization.""" diff --git a/libs/arcade-mcp-server/arcade_mcp_server/auth/__init__.py b/libs/arcade-mcp-server/arcade_mcp_server/auth/__init__.py index 31426e8f5..6b0cfcc5b 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/auth/__init__.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/auth/__init__.py @@ -16,6 +16,7 @@ OAuth2, PagerDuty, Reddit, + Sentry, Slack, Spotify, ToolAuthorization, @@ -42,6 +43,7 @@ "OAuth2", "PagerDuty", "Reddit", + "Sentry", "Slack", "Spotify", "ToolAuthorization", diff --git a/libs/arcade-tdk/arcade_tdk/auth/__init__.py b/libs/arcade-tdk/arcade_tdk/auth/__init__.py index 31426e8f5..6b0cfcc5b 100644 --- a/libs/arcade-tdk/arcade_tdk/auth/__init__.py +++ b/libs/arcade-tdk/arcade_tdk/auth/__init__.py @@ -16,6 +16,7 @@ OAuth2, PagerDuty, Reddit, + Sentry, Slack, Spotify, ToolAuthorization, @@ -42,6 +43,7 @@ "OAuth2", "PagerDuty", "Reddit", + "Sentry", "Slack", "Spotify", "ToolAuthorization", diff --git a/libs/tests/core/test_auth_providers.py b/libs/tests/core/test_auth_providers.py new file mode 100644 index 000000000..56559d4b3 --- /dev/null +++ b/libs/tests/core/test_auth_providers.py @@ -0,0 +1,54 @@ +"""Every OAuth2 provider class in ``arcade_core.auth`` must construct and carry its metadata. + +This walks the provider classes dynamically rather than naming them, so a newly added +provider is covered the moment it lands, with no per-provider test edit. Each subclass's +``__init__`` (its ``super().__init__`` call) only runs on instantiation, so constructing +every provider here is also what exercises those lines under coverage. +""" + +import inspect + +import pytest +from arcade_core import auth as auth_module +from arcade_core.auth import AuthProviderType, OAuth2 + + +def _provider_classes() -> list[type[OAuth2]]: + """Concrete OAuth2 provider classes defined in ``arcade_core.auth`` (OAuth2 itself excluded).""" + return [ + obj + for _, obj in inspect.getmembers(auth_module, inspect.isclass) + if issubclass(obj, OAuth2) + and obj is not OAuth2 + and obj.__module__ == auth_module.__name__ + ] + + +def test_provider_classes_are_discovered(): + # Guards the parametrized tests below from silently collecting zero cases. + assert _provider_classes(), "no OAuth2 provider classes found in arcade_core.auth" + + +@pytest.mark.parametrize("provider_cls", _provider_classes(), ids=lambda c: c.__name__) +def test_provider_constructs_with_default_metadata(provider_cls: type[OAuth2]): + provider = provider_cls() + assert isinstance(provider.provider_id, str) + assert provider.provider_id + assert provider.provider_type == AuthProviderType.oauth2 + assert provider.id is None + assert provider.scopes is None + + +@pytest.mark.parametrize("provider_cls", _provider_classes(), ids=lambda c: c.__name__) +def test_provider_accepts_id_and_scopes(provider_cls: type[OAuth2]): + provider = provider_cls(id="custom-provider-id", scopes=["scope.read", "scope.write"]) + assert provider.id == "custom-provider-id" + assert provider.scopes == ["scope.read", "scope.write"] + # provider_id is the fixed well-known key and is not displaced by a custom id. + assert provider.provider_id == provider_cls().provider_id + + +def test_provider_ids_are_unique(): + # A copy-paste codegen slip that reused another provider's id would collide here. + ids = [cls().provider_id for cls in _provider_classes()] + assert len(ids) == len(set(ids)), f"duplicate provider_id values: {sorted(ids)}"