File size: 9,168 Bytes
93a887a
 
 
 
 
203ee4f
93a887a
 
 
 
 
 
 
 
 
 
 
 
 
203ee4f
93a887a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
effc0fc
93a887a
 
effc0fc
93a887a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
effc0fc
93a887a
 
effc0fc
 
 
93a887a
 
 
effc0fc
93a887a
 
 
 
 
 
 
 
 
effc0fc
93a887a
effc0fc
93a887a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203ee4f
 
 
93a887a
203ee4f
93a887a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203ee4f
 
 
93a887a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
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"]