/
/
/
1"""Tests for evicting owner-bound (guest) Sendspin pairings."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from types import SimpleNamespace
8from typing import TYPE_CHECKING, cast
9
10from aiosendspin.noise.keys import generate_psk, psk_id_for
11from aiosendspin.noise.trust_store import InMemoryServerPairingStore, ServerPairingRecord
12from aiosendspin.server import ClientDisconnectedEvent
13from music_assistant_models.auth import User, UserRole
14
15import music_assistant.providers.sendspin.provider as provider_module
16from music_assistant.helpers.guest_access import credential_owners_for_user_id
17from music_assistant.providers.sendspin.provider import (
18 SendspinProvider,
19 _evict_session_pairing_task_id,
20 _evict_stale_pairings,
21)
22
23from .test_pin_session import _FakeMass, _timers
24
25if TYPE_CHECKING:
26 import pytest
27 from aiosendspin.server import SendspinServer
28 from aiosendspin.server.client import SendspinClient
29
30 from music_assistant.controllers.webserver.auth import AuthenticationManager
31 from music_assistant.mass import MusicAssistant
32
33
34class _EvictionServerApi:
35 """Server stand-in with the pairing store and unpair surface eviction uses."""
36
37 def __init__(self) -> None:
38 self.pairing_store = InMemoryServerPairingStore()
39 self.clients: dict[str, SendspinClient] = {}
40 self.unpaired: list[str] = []
41
42 def add_client(self, client_id: str, *, connected: bool) -> None:
43 self.clients[client_id] = cast("SendspinClient", SimpleNamespace(is_connected=connected))
44
45 def get_client(self, client_id: str) -> SendspinClient | None:
46 return self.clients.get(client_id)
47
48 async def unpair(self, client_id: str) -> None:
49 client = self.clients.get(client_id)
50 if client is None or not client.is_connected:
51 # Mirrors aiosendspin, which resolves the connection before unpairing.
52 raise ValueError(f"client {client_id} is not connected")
53 await self.pairing_store.remove_record(client_id)
54 self.unpaired.append(client_id)
55
56
57class _FakeAuth:
58 """Auth stand-in resolving only the account lookup the reconciliation needs."""
59
60 def __init__(self, *user_ids: str) -> None:
61 self._user_ids = set(user_ids)
62
63 async def get_user(self, user_id: str) -> User | None:
64 # mirrors the real lookup, which answers None for a disabled account too
65 if user_id not in self._user_ids:
66 return None
67 return User(user_id=user_id, username=user_id, role=UserRole.USER)
68
69
70def _record(client_id: str, owner: str | None = None) -> ServerPairingRecord:
71 psk = generate_psk()
72 return ServerPairingRecord(
73 psk_id=psk_id_for(psk), psk=psk, client_id=client_id, pair_methods=[], owner=owner
74 )
75
76
77def _make_provider(
78 api: _EvictionServerApi, monkeypatch: pytest.MonkeyPatch
79) -> tuple[SendspinProvider, list[str]]:
80 provider = SendspinProvider.__new__(SendspinProvider)
81 provider.mass = cast("MusicAssistant", _FakeMass(asyncio.get_running_loop()))
82 provider.server_api = cast("SendspinServer", api)
83 provider.logger = logging.getLogger("test.sendspin.eviction")
84 provider._unloading = False
85 provider._pending_pairing_evictions = set()
86 provider._running_pairing_evictions = set()
87 refreshed: list[str] = []
88
89 async def _record_refresh(client_id: str) -> None:
90 refreshed.append(client_id)
91
92 monkeypatch.setattr(provider, "_refresh_player", _record_refresh)
93 return provider, refreshed
94
95
96async def test_a_disconnect_schedules_the_eviction_with_a_grace_period(
97 monkeypatch: pytest.MonkeyPatch,
98) -> None:
99 """A disconnect defers the eviction, so a network blip can reconnect onto the record."""
100 api = _EvictionServerApi()
101 record = _record("c1", owner="guest-g1")
102 await api.pairing_store.store_record(record)
103 provider, _refreshed = _make_provider(api, monkeypatch)
104 provider.event_cb(
105 cast("SendspinServer", api),
106 ClientDisconnectedEvent(client_id="c1", goodbye_reason=None),
107 )
108 assert _evict_session_pairing_task_id("c1") in _timers(provider)
109 assert await api.pairing_store.record_by_client_id("c1") == record
110
111
112async def test_the_delayed_eviction_removes_the_session_pairing(
113 monkeypatch: pytest.MonkeyPatch,
114) -> None:
115 """Once the grace period lapses without a reconnect, the session-scoped record is removed."""
116 monkeypatch.setattr(provider_module, "SESSION_PAIRING_EVICTION_GRACE", 0.0)
117 api = _EvictionServerApi()
118 await api.pairing_store.store_record(_record("c1", owner="guest-g1"))
119 provider, refreshed = _make_provider(api, monkeypatch)
120 provider.event_cb(
121 cast("SendspinServer", api),
122 ClientDisconnectedEvent(client_id="c1", goodbye_reason=None),
123 )
124 await asyncio.sleep(0.05)
125 assert await api.pairing_store.record_by_client_id("c1") is None
126 assert refreshed == ["c1"]
127
128
129async def test_a_disconnect_keeps_durable_pairings(monkeypatch: pytest.MonkeyPatch) -> None:
130 """Standalone and account-bound records survive their client disconnecting."""
131 api = _EvictionServerApi()
132 standalone = _record("c1")
133 account_bound = _record("c2", owner="user-u1")
134 await api.pairing_store.store_record(standalone)
135 await api.pairing_store.store_record(account_bound)
136 provider, refreshed = _make_provider(api, monkeypatch)
137 await provider._evict_session_pairing("c1")
138 await provider._evict_session_pairing("c2")
139 assert await api.pairing_store.record_by_client_id("c1") == standalone
140 assert await api.pairing_store.record_by_client_id("c2") == account_bound
141 assert refreshed == []
142
143
144async def test_a_reconnected_client_keeps_its_session_pairing(
145 monkeypatch: pytest.MonkeyPatch,
146) -> None:
147 """A client that reconnected before the eviction ran keeps its pairing."""
148 api = _EvictionServerApi()
149 record = _record("c1", owner="guest-g1")
150 await api.pairing_store.store_record(record)
151 api.add_client("c1", connected=True)
152 provider, refreshed = _make_provider(api, monkeypatch)
153 await provider._evict_session_pairing("c1")
154 assert await api.pairing_store.record_by_client_id("c1") == record
155 assert refreshed == []
156
157
158async def test_revoking_an_owner_evicts_only_their_pairings(
159 monkeypatch: pytest.MonkeyPatch,
160) -> None:
161 """Withdrawing a user's access drops both its owner forms, sparing everyone else's."""
162 api = _EvictionServerApi()
163 # Stamped with the same owner formats pair_web_player mints, so this also pins
164 # that the revocation hook and the minting side cannot drift apart.
165 guest_owner, user_owner = credential_owners_for_user_id("g1")
166 await api.pairing_store.store_record(_record("connected", owner=guest_owner))
167 await api.pairing_store.store_record(_record("offline", owner=guest_owner))
168 await api.pairing_store.store_record(_record("account", owner=user_owner))
169 other = _record("other", owner=credential_owners_for_user_id("g2")[0])
170 durable = _record("durable")
171 await api.pairing_store.store_record(other)
172 await api.pairing_store.store_record(durable)
173 api.add_client("connected", connected=True)
174 provider, refreshed = _make_provider(api, monkeypatch)
175
176 provider._on_user_access_revoked(
177 User(user_id="g1", username="party_guest", role=UserRole.GUEST)
178 )
179 for _ in range(5):
180 await asyncio.sleep(0)
181
182 assert await api.pairing_store.record_by_client_id("connected") is None
183 assert await api.pairing_store.record_by_client_id("offline") is None
184 assert await api.pairing_store.record_by_client_id("account") is None
185 assert await api.pairing_store.record_by_client_id("other") == other
186 assert await api.pairing_store.record_by_client_id("durable") == durable
187 # Only the connected client had a live session to notify.
188 assert api.unpaired == ["connected"]
189 assert sorted(refreshed) == ["account", "connected", "offline"]
190
191
192async def test_the_startup_eviction_clears_session_pairings() -> None:
193 """Leftover session-scoped records go, while records of live accounts stay."""
194 store = InMemoryServerPairingStore()
195 standalone = _record("standalone")
196 account_bound = _record("account", owner="user-u1")
197 await store.store_record(standalone)
198 await store.store_record(account_bound)
199 await store.store_record(_record("guest-a", owner="guest-g1"))
200 await store.store_record(_record("guest-b", owner="guest-g2"))
201
202 auth = cast("AuthenticationManager", _FakeAuth("u1"))
203 assert await _evict_stale_pairings(store, auth) == (2, 0)
204 assert list(await store.list_records()) == [standalone, account_bound]
205
206
207async def test_the_startup_eviction_clears_pairings_of_gone_accounts() -> None:
208 """A record survives a restart only while its account does, so a revocation cannot be missed."""
209 store = InMemoryServerPairingStore()
210 standalone = _record("standalone")
211 live = _record("live", owner="user-u1")
212 await store.store_record(standalone)
213 await store.store_record(live)
214 # deleted or disabled since this record was written (get_user answers None for both)
215 await store.store_record(_record("gone", owner="user-u2"))
216
217 auth = cast("AuthenticationManager", _FakeAuth("u1"))
218 assert await _evict_stale_pairings(store, auth) == (0, 1)
219 assert list(await store.list_records()) == [standalone, live]
220
221
222async def test_revoking_skips_a_pairing_that_changed_hands(
223 monkeypatch: pytest.MonkeyPatch,
224) -> None:
225 """A record re-paired since the listing belongs to its new owner, so it is left alone."""
226 api = _EvictionServerApi()
227 restamped = _record("c1", owner="user-u2")
228 await api.pairing_store.store_record(restamped)
229 api.add_client("c1", connected=True)
230 provider, refreshed = _make_provider(api, monkeypatch)
231
232 async def _stale_listing(_owner: str) -> list[ServerPairingRecord]:
233 return [_record("c1", owner="guest-g1")]
234
235 monkeypatch.setattr(api.pairing_store, "records_by_owner", _stale_listing)
236 await provider._evict_pairings_for_owner("guest-g1")
237
238 assert await api.pairing_store.record_by_client_id("c1") == restamped
239 assert api.unpaired == []
240 assert refreshed == []
241
242
243async def test_an_in_flight_eviction_is_tracked_for_unload(
244 monkeypatch: pytest.MonkeyPatch,
245) -> None:
246 """Unload can only await store writes it knows of, so a running eviction stays tracked."""
247 api = _EvictionServerApi()
248 await api.pairing_store.store_record(_record("c1", owner="guest-g1"))
249 provider, _refreshed = _make_provider(api, monkeypatch)
250 removing = asyncio.Event()
251 release = asyncio.Event()
252 original_remove = api.pairing_store.remove_record
253
254 async def _slow_remove(client_id: str) -> None:
255 removing.set()
256 await release.wait()
257 await original_remove(client_id)
258
259 monkeypatch.setattr(api.pairing_store, "remove_record", _slow_remove)
260 provider._on_user_access_revoked(
261 User(user_id="g1", username="party_guest", role=UserRole.GUEST)
262 )
263 await removing.wait()
264 assert provider._running_pairing_evictions
265
266 release.set()
267 await asyncio.gather(*provider._running_pairing_evictions)
268 for _ in range(3):
269 await asyncio.sleep(0)
270 assert await api.pairing_store.record_by_client_id("c1") is None
271 assert not provider._running_pairing_evictions # and nothing left behind
272
273
274async def test_an_eviction_scheduled_during_unload_does_not_run(
275 monkeypatch: pytest.MonkeyPatch,
276) -> None:
277 """A revocation arriving while unloading leaves the store to the next provider."""
278 api = _EvictionServerApi()
279 record = _record("c1", owner="guest-g1")
280 await api.pairing_store.store_record(record)
281 provider, refreshed = _make_provider(api, monkeypatch)
282 provider._unloading = True
283
284 provider._on_user_access_revoked(
285 User(user_id="g1", username="party_guest", role=UserRole.GUEST)
286 )
287 for _ in range(5):
288 await asyncio.sleep(0)
289
290 assert await api.pairing_store.record_by_client_id("c1") == record
291 assert refreshed == []
292