Spaces:
Running
Running
| from __future__ import annotations | |
| import socket | |
| import threading | |
| from urllib.error import HTTPError | |
| from urllib.parse import parse_qs, urlparse | |
| from urllib.request import urlopen | |
| import httpx2 | |
| import pytest | |
| import spotify_mcp_server.spotify.auth as auth_module | |
| from spotify_mcp_server.spotify.auth import ( | |
| AuthenticationError, | |
| KeyringRefreshTokenStore, | |
| SpotifyTokenProvider, | |
| _pkce_pair, | |
| authorize, | |
| ) | |
| from spotify_mcp_server.spotify.config import SCOPES, Settings | |
| pytestmark = pytest.mark.anyio | |
| def settings() -> Settings: | |
| return Settings( | |
| client_id="client", | |
| redirect_uri="http://127.0.0.1:8765/callback", | |
| accounts_base_url="https://accounts.test", | |
| ) | |
| class MemoryStore: | |
| def __init__(self, refresh_token: str | None = None) -> None: | |
| self.refresh_token = refresh_token | |
| self.saved: list[str] = [] | |
| async def load(self) -> str | None: | |
| return self.refresh_token | |
| async def save(self, refresh_token: str) -> None: | |
| self.refresh_token = refresh_token | |
| self.saved.append(refresh_token) | |
| class FakeKeyring: | |
| def __init__(self) -> None: | |
| self.values: dict[tuple[str, str], str] = {} | |
| def get_password(self, service_name: str, username: str) -> str | None: | |
| return self.values.get((service_name, username)) | |
| def set_password(self, service_name: str, username: str, password: str) -> None: | |
| self.values[(service_name, username)] = password | |
| async def test_keyring_store_round_trip_contains_only_refresh_token() -> None: | |
| backend = FakeKeyring() | |
| store = KeyringRefreshTokenStore("spotify-mcp", "client", backend=backend) | |
| assert await store.load() is None | |
| await store.save("refresh") | |
| assert await store.load() == "refresh" | |
| assert backend.values == {("spotify-mcp", "spotify-client:client"): "refresh"} | |
| async def test_keyring_store_normalizes_backend_failures() -> None: | |
| class BrokenKeyring(FakeKeyring): | |
| def get_password(self, service_name: str, username: str) -> str | None: | |
| raise RuntimeError("backend details") | |
| def set_password(self, service_name: str, username: str, password: str) -> None: | |
| raise RuntimeError("backend details") | |
| store = KeyringRefreshTokenStore("spotify-mcp", "client", backend=BrokenKeyring()) | |
| with pytest.raises(AuthenticationError, match="read"): | |
| await store.load() | |
| with pytest.raises(AuthenticationError, match="save"): | |
| await store.save("secret") | |
| async def test_provider_loads_refresh_token_but_keeps_access_token_in_memory() -> None: | |
| store = MemoryStore("keep") | |
| calls = 0 | |
| async def handler(request: httpx2.Request) -> httpx2.Response: | |
| nonlocal calls | |
| calls += 1 | |
| assert request.url.path == "/api/token" | |
| assert b"refresh_token=keep" in request.content | |
| return httpx2.Response( | |
| 200, json={"access_token": "access", "expires_in": 3600, "scope": "scope"} | |
| ) | |
| async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: | |
| provider = SpotifyTokenProvider(settings(), client=http, store=store) | |
| assert await provider.access_token() == "access" | |
| assert await provider.access_token() == "access" | |
| assert calls == 1 | |
| assert store.saved == [] | |
| assert store.refresh_token == "keep" | |
| async def test_provider_persists_only_rotated_refresh_token() -> None: | |
| store = MemoryStore("old") | |
| async def handler(_: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response( | |
| 200, | |
| json={ | |
| "access_token": "new-access", | |
| "refresh_token": "rotated", | |
| "expires_in": 3600, | |
| }, | |
| ) | |
| async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: | |
| provider = SpotifyTokenProvider(settings(), client=http, store=store) | |
| assert await provider.access_token(force_refresh=True) == "new-access" | |
| assert store.saved == ["rotated"] | |
| assert "new-access" not in store.saved | |
| async def test_provider_requires_prior_authorization() -> None: | |
| async with httpx2.AsyncClient() as http: | |
| provider = SpotifyTokenProvider(settings(), client=http, store=MemoryStore()) | |
| with pytest.raises(AuthenticationError, match="spotify-mcp-auth"): | |
| await provider.access_token() | |
| async def test_provider_normalizes_refresh_failure() -> None: | |
| async def handler(_: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(400, json={"error": "invalid_grant"}) | |
| async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: | |
| provider = SpotifyTokenProvider(settings(), client=http, store=MemoryStore("expired")) | |
| with pytest.raises(AuthenticationError, match="refresh failed"): | |
| await provider.access_token() | |
| async def test_authorize_requests_all_scopes_and_persists_refresh_token_only( | |
| monkeypatch: pytest.MonkeyPatch, | |
| ) -> None: | |
| store = MemoryStore() | |
| opened_urls: list[str] = [] | |
| class FakeClient: | |
| async def __aenter__(self) -> FakeClient: | |
| return self | |
| async def __aexit__(self, *args: object) -> None: | |
| return None | |
| async def post(self, url: str, *, data: dict[str, str]) -> httpx2.Response: | |
| assert url == "https://accounts.test/api/token" | |
| assert data["grant_type"] == "authorization_code" | |
| assert data["code"] == "callback-code" | |
| return httpx2.Response( | |
| 200, | |
| json={ | |
| "access_token": "must-not-persist", | |
| "refresh_token": "persist-me", | |
| "expires_in": 3600, | |
| }, | |
| ) | |
| monkeypatch.setattr(auth_module, "_receive_callback", lambda *_: "callback-code") | |
| monkeypatch.setattr(auth_module.httpx2, "AsyncClient", lambda **_: FakeClient()) | |
| monkeypatch.setattr(auth_module.webbrowser, "open", opened_urls.append) | |
| await authorize(settings(), open_browser=True, store=store) | |
| assert parse_qs(urlparse(opened_urls[0]).query)["scope"][0].split() == list(SCOPES) | |
| assert store.saved == ["persist-me"] | |
| assert "must-not-persist" not in store.saved | |
| async def test_authorize_requires_client_id() -> None: | |
| config = settings() | |
| config = Settings(client_id="", redirect_uri=config.redirect_uri) | |
| with pytest.raises(AuthenticationError, match="SPOTIFY_CLIENT_ID"): | |
| await authorize(config, open_browser=False, store=MemoryStore()) | |
| def test_pkce_pair_is_url_safe() -> None: | |
| verifier, challenge = _pkce_pair() | |
| assert len(verifier) >= 43 | |
| assert "=" not in challenge | |
| assert verifier != challenge | |
| def _callback_target(monkeypatch: pytest.MonkeyPatch) -> tuple[str, threading.Event]: | |
| with socket.socket() as available: | |
| available.bind(("127.0.0.1", 0)) | |
| port = available.getsockname()[1] | |
| ready = threading.Event() | |
| original_server = auth_module.HTTPServer | |
| def make_server(*args: object, **kwargs: object): | |
| server = original_server(*args, **kwargs) | |
| ready.set() | |
| return server | |
| monkeypatch.setattr(auth_module, "HTTPServer", make_server) | |
| return f"http://127.0.0.1:{port}/callback", ready | |
| def test_receive_callback_rejects_non_loopback_redirect() -> None: | |
| with pytest.raises(AuthenticationError, match="loopback"): | |
| auth_module._receive_callback("https://example.com/callback", "state") | |
| def test_callback_handler_rejects_mismatched_state(monkeypatch: pytest.MonkeyPatch) -> None: | |
| redirect, ready = _callback_target(monkeypatch) | |
| outcome: list[str | Exception] = [] | |
| def receive() -> None: | |
| try: | |
| outcome.append(auth_module._receive_callback(redirect, "expected")) | |
| except Exception as exc: | |
| outcome.append(exc) | |
| thread = threading.Thread(target=receive, daemon=True) | |
| thread.start() | |
| assert ready.wait(timeout=2) | |
| with pytest.raises(HTTPError) as raised: | |
| urlopen(f"{redirect}?code=abc&state=forged", timeout=2) | |
| assert raised.value.code == 400 | |
| assert b"authorization failed" in raised.value.read() | |
| thread.join(timeout=2) | |
| assert not thread.is_alive() | |
| assert isinstance(outcome[0], AuthenticationError) | |
| assert "state" in str(outcome[0]) | |
| def test_callback_handler_ignores_unrelated_path(monkeypatch: pytest.MonkeyPatch) -> None: | |
| redirect, ready = _callback_target(monkeypatch) | |
| outcome: list[str | Exception] = [] | |
| def receive() -> None: | |
| try: | |
| outcome.append(auth_module._receive_callback(redirect, "expected")) | |
| except Exception as exc: | |
| outcome.append(exc) | |
| thread = threading.Thread(target=receive, daemon=True) | |
| thread.start() | |
| assert ready.wait(timeout=2) | |
| with pytest.raises(HTTPError) as raised: | |
| urlopen(redirect.replace("/callback", "/favicon.ico"), timeout=2) | |
| assert raised.value.code == 404 | |
| body = urlopen(f"{redirect}?code=abc&state=expected", timeout=2).read() | |
| thread.join(timeout=2) | |
| assert b"authorization received" in body | |
| assert not thread.is_alive() | |
| assert outcome == ["abc"] | |