spotify-mcp-server / tests /test_auth.py
LeoWalker's picture
Add authenticated hosted deployment (#5)
effc0fc unverified
Raw
History Blame Contribute Delete
9.17 kB
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"]