/
/
/
1"""Tests for the Sendspin operator PIN pairing session state machine and orchestration."""
2
3from __future__ import annotations
4
5import asyncio
6import inspect
7import logging
8from collections import deque
9from types import SimpleNamespace
10from typing import TYPE_CHECKING, Any, cast
11
12import pytest
13from aiosendspin.models.core import PairMethodDescriptor
14from aiosendspin.models.management import ManagementResultData, PairingMethodConfig
15from aiosendspin.models.types import ManagementResult, PairAbortReason, PairMethod
16from aiosendspin.noise.driver import HandshakeAbortedError
17from aiosendspin.noise.pairing import (
18 LocalPairingAbortError,
19 PairingError,
20 PairingTimeoutError,
21 RemotePairingAbortError,
22)
23
24import music_assistant.providers.sendspin.provider as provider_module
25from music_assistant.providers.sendspin.helpers import SecurityActionError
26from music_assistant.providers.sendspin.provider import (
27 PinPairingSession,
28 SendspinProvider,
29 _pin_idle_task_id,
30)
31
32if TYPE_CHECKING:
33 from aiosendspin.noise.pairing import PairingAttempt
34 from aiosendspin.server import SendspinServer
35 from aiosendspin.server.client import SendspinClient
36 from aiosendspin.server.connection import SendspinConnection
37
38 from music_assistant.mass import MusicAssistant
39
40
41def _desc(method: PairMethod, *, min_pin_length: int | None = None) -> PairMethodDescriptor:
42 return PairMethodDescriptor(method=method, min_pin_length=min_pin_length)
43
44
45async def _blocked() -> None:
46 await asyncio.Event().wait()
47
48
49async def _submit_and_settle(provider: SendspinProvider, pin: str, client_id: str = "c") -> None:
50 """Submit the PIN and wait for the attempt task to reach its outcome."""
51 provider.submit_pin(client_id, pin)
52 session = provider.get_pin_session(client_id)
53 assert session is not None
54 assert session.task is not None
55 await session.task
56
57
58class _FakeMass:
59 """MusicAssistant stand-in mirroring create_task / call_later timer bookkeeping."""
60
61 def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
62 self.loop = loop
63 self.timers: dict[str, asyncio.TimerHandle] = {}
64 self.tasks: dict[str, asyncio.Task[Any]] = {}
65 self.metadata = SimpleNamespace(locale="nl_NL")
66
67 def create_task(
68 self, coro: Any, *, task_id: str | None = None, abort_existing: bool = False
69 ) -> asyncio.Task[Any]:
70 if task_id and (existing := self.tasks.get(task_id)) and not existing.done():
71 if abort_existing:
72 existing.cancel()
73 else:
74 if asyncio.iscoroutine(coro):
75 coro.close()
76 return existing
77 task = self.loop.create_task(coro)
78 if task_id is not None:
79 key = task_id
80 self.tasks[key] = task
81
82 def _discard(_task: asyncio.Task[Any]) -> None:
83 self.tasks.pop(key, None)
84
85 task.add_done_callback(_discard)
86 return task
87
88 def call_later(
89 self, delay: float, target: Any, *args: Any, task_id: str, **kwargs: Any
90 ) -> asyncio.TimerHandle:
91 if existing := self.timers.get(task_id):
92 existing.cancel()
93
94 def _fire() -> None:
95 self.timers.pop(task_id, None)
96 if inspect.iscoroutinefunction(target) or inspect.iscoroutine(target):
97 self.create_task(target(*args, **kwargs), task_id=task_id, abort_existing=True)
98 else:
99 target(*args, **kwargs)
100
101 handle = self.loop.call_later(delay, _fire)
102 self.timers[task_id] = handle
103 return handle
104
105 def cancel_timer(self, task_id: str) -> None:
106 if handle := self.timers.pop(task_id, None):
107 handle.cancel()
108
109 def cancel_task(self, task_id: str) -> None:
110 if task := self.tasks.pop(task_id, None):
111 task.cancel()
112
113
114def _timers(provider: SendspinProvider) -> dict[str, asyncio.TimerHandle]:
115 """Pending call_later timers on the provider's fake mass."""
116 return cast("_FakeMass", provider.mass).timers
117
118
119class _FakeConnection:
120 """Connection stand-in serving management requests, recording what the provider asked for."""
121
122 def __init__(self, calls: list[str]) -> None:
123 self._calls = calls
124 self.management_active = False
125 self.window_result = ManagementResult.OK
126 self.window_error: BaseException | None = None
127 self.window_calls = 0
128
129 async def open_pairing_window(self) -> ManagementResult:
130 self._calls.append("window")
131 self.window_calls += 1
132 if self.window_error is not None:
133 raise self.window_error
134 return self.window_result
135
136 def disable_management(self) -> None:
137 self.management_active = False
138
139
140class _FakeServerApi:
141 """Scripts pairing outcomes and records end_pairing calls, mirroring the real wiring."""
142
143 def __init__(
144 self,
145 methods: list[PairMethodDescriptor],
146 *,
147 await_pin: bool = True,
148 gesture: asyncio.Event | None = None,
149 management_capable: bool = False,
150 connected: bool = True,
151 ) -> None:
152 self.calls: list[str] = []
153 self.connection = _FakeConnection(self.calls)
154 self._client = cast(
155 "SendspinClient",
156 SimpleNamespace(
157 info_or_none=SimpleNamespace(supported_pair_methods=methods),
158 connection=self.connection,
159 is_connected=connected,
160 ),
161 )
162 self._await_pin = await_pin
163 self._gesture = gesture
164 self._connected = connected
165 self.min_pin_length = 6
166 self.management_capable = management_capable
167 self._active_cancel: asyncio.Event | None = None
168 self._cancel_requested = False
169 self.outcomes: deque[BaseException | None] = deque()
170 self.attempts: list[PairingAttempt] = []
171 self.initiate_calls = 0
172 self.end_pairing_calls = 0
173
174 def get_client(self, client_id: str) -> SendspinClient | None:
175 return self._client
176
177 def enable_management(self, client_id: str) -> _FakeConnection:
178 if not self._connected:
179 # Mirrors aiosendspin, which resolves the connection before enabling management.
180 raise ValueError(f"client {client_id} is not connected")
181 if not self.management_capable:
182 msg = "management requires a paired (long-term Sendspin PSK) connection"
183 raise RuntimeError(msg)
184 self.connection.management_active = True
185 return self.connection
186
187 async def initiate_pairing(self, client_id: str, attempt: PairingAttempt) -> None:
188 self.calls.append("pair")
189 self.initiate_calls += 1
190 self.attempts.append(attempt)
191 if self._gesture is not None:
192 if attempt.on_pair_pending is not None:
193 attempt.on_pair_pending()
194 await self._gesture.wait()
195 if self._await_pin and attempt.pin_provider is not None:
196 if not self._cancel_requested:
197 cancel = asyncio.Event()
198 self._active_cancel = cancel
199 cancel_task = asyncio.ensure_future(cancel.wait())
200 pin_task = asyncio.ensure_future(attempt.pin_provider())
201 try:
202 await asyncio.wait(
203 {pin_task, cancel_task},
204 return_when=asyncio.FIRST_COMPLETED,
205 )
206 finally:
207 cancel_task.cancel()
208 self._active_cancel = None
209 if self._cancel_requested:
210 # end_pairing cancels the attempt regardless of ordering, as the real one does.
211 raise LocalPairingAbortError(PairAbortReason.USER_CANCELLED)
212 outcome = self.outcomes.popleft() if self.outcomes else None
213 if isinstance(outcome, BaseException):
214 raise outcome
215
216 async def end_pairing(self, client_id: str) -> None:
217 self.end_pairing_calls += 1
218 self._cancel_requested = True
219 if self._active_cancel is not None:
220 self._active_cancel.set()
221
222
223def _make_provider(
224 server_api: _FakeServerApi, monkeypatch: pytest.MonkeyPatch
225) -> tuple[SendspinProvider, list[str]]:
226 provider = SendspinProvider.__new__(SendspinProvider)
227 provider.mass = cast("MusicAssistant", _FakeMass(asyncio.get_running_loop()))
228 provider.server_api = cast("SendspinServer", server_api)
229 provider._pin_sessions = {}
230 provider._management_sessions = {}
231 provider._pairing_config_snapshots = {}
232 provider.logger = logging.getLogger("test.sendspin.pin")
233 refreshed: list[str] = []
234
235 async def _record_refresh(client_id: str) -> None:
236 refreshed.append(client_id)
237
238 monkeypatch.setattr(provider, "_refresh_player", _record_refresh)
239 return provider, refreshed
240
241
242async def test_session_running_states() -> None:
243 """A running attempt tracks the gesture and PIN waits independently."""
244 loop = asyncio.get_running_loop()
245 running: asyncio.Task[None] = loop.create_task(_blocked())
246
247 awaiting_future: asyncio.Future[str] = loop.create_future()
248 awaiting = PinPairingSession(
249 client_id="c", method=PairMethod.DYNAMIC_PIN, pin_future=awaiting_future, task=running
250 )
251 assert awaiting.attempt_running
252 assert awaiting.awaiting_pin
253 assert awaiting.awaiting_first_message
254 assert not awaiting.awaiting_gesture
255 assert not awaiting.can_retry
256 assert not awaiting.finished
257
258 gated = PinPairingSession(
259 client_id="c", method=PairMethod.STATIC_PIN, pin_future=awaiting_future, task=running
260 )
261 gated.gesture_event.set()
262 assert gated.awaiting_gesture
263 assert not gated.awaiting_first_message
264
265 submitted_future: asyncio.Future[str] = loop.create_future()
266 submitted_future.set_result("123456")
267 submitted_early = PinPairingSession(
268 client_id="c", method=PairMethod.DYNAMIC_PIN, pin_future=submitted_future, task=running
269 )
270 assert submitted_early.attempt_running
271 assert not submitted_early.awaiting_pin
272 assert submitted_early.awaiting_first_message
273
274 in_progress = PinPairingSession(
275 client_id="c", method=PairMethod.DYNAMIC_PIN, pin_future=submitted_future, task=running
276 )
277 in_progress.pin_request_event.set()
278 assert in_progress.attempt_running
279 assert not in_progress.awaiting_pin
280 assert not in_progress.awaiting_gesture
281 assert not in_progress.awaiting_first_message
282 running.cancel()
283
284
285async def test_session_terminal_states() -> None:
286 """A completed attempt is retryable while retryable is set, terminal once cleared."""
287 loop = asyncio.get_running_loop()
288 done: asyncio.Task[None] = loop.create_task(asyncio.sleep(0))
289 await done
290 pin_future: asyncio.Future[str] = loop.create_future()
291 pin_future.cancel()
292
293 retryable = PinPairingSession(
294 client_id="c",
295 method=PairMethod.DYNAMIC_PIN,
296 pin_future=pin_future,
297 task=done,
298 retryable=True,
299 )
300 assert retryable.can_retry
301 assert not retryable.finished
302 assert not retryable.awaiting_pin
303 assert not retryable.awaiting_gesture
304 assert not retryable.awaiting_first_message
305
306 terminal = PinPairingSession(
307 client_id="c", method=PairMethod.DYNAMIC_PIN, pin_future=pin_future, task=done
308 )
309 assert terminal.finished
310 assert not terminal.can_retry
311
312
313async def test_pin_pairing_success(monkeypatch: pytest.MonkeyPatch) -> None:
314 """A submitted PIN that succeeds finishes the session and refreshes the player."""
315 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)])
316 provider, refreshed = _make_provider(api, monkeypatch)
317 session = await provider.start_pin_pairing("c")
318 assert session.awaiting_pin
319 await _submit_and_settle(provider, "123456")
320 assert session.finished
321 assert session.error is None
322 assert refreshed == ["c"]
323 assert api.end_pairing_calls == 0
324
325
326async def test_pin_submitted_before_gesture(monkeypatch: pytest.MonkeyPatch) -> None:
327 """A PIN submitted while the gesture is pending is consumed once the client enters pairing."""
328 gesture = asyncio.Event()
329 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], gesture=gesture)
330 provider, refreshed = _make_provider(api, monkeypatch)
331 monkeypatch.setattr(provider_module, "PIN_REQUEST_FEEDBACK_TIMEOUT", 0)
332 session = await provider.start_pin_pairing("c")
333 await asyncio.sleep(0)
334 assert (session.awaiting_gesture, session.awaiting_pin) == (True, True)
335
336 provider.submit_pin("c", "12345678")
337 assert (session.awaiting_gesture, session.awaiting_pin) == (True, False)
338 assert session.attempt_running
339
340 gesture.set()
341 assert session.task is not None
342 await session.task
343 assert not session.awaiting_gesture
344 assert session.finished
345 assert session.error is None
346 assert refreshed == ["c"]
347
348
349async def test_default_prefers_dynamic_pin(monkeypatch: pytest.MonkeyPatch) -> None:
350 """When both PIN methods are offered, the default pick is dynamic."""
351 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN), _desc(PairMethod.DYNAMIC_PIN)])
352 provider, _refreshed = _make_provider(api, monkeypatch)
353 session = await provider.start_pin_pairing("c")
354 assert session.method is PairMethod.DYNAMIC_PIN
355 await provider.cancel_pin_pairing("c")
356
357
358async def test_static_override_picks_static_pin(monkeypatch: pytest.MonkeyPatch) -> None:
359 """The static override pairs with the static PIN even when dynamic is offered."""
360 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN), _desc(PairMethod.STATIC_PIN)])
361 provider, refreshed = _make_provider(api, monkeypatch)
362 session = await provider.start_pin_pairing("c", static=True)
363 assert session.method is PairMethod.STATIC_PIN
364 await _submit_and_settle(provider, "12345678")
365 assert session.finished
366 assert session.error is None
367 assert refreshed == ["c"]
368
369
370async def test_static_override_requires_static_offer(monkeypatch: pytest.MonkeyPatch) -> None:
371 """The static override fails when the device does not offer a static PIN."""
372 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)])
373 provider, _refreshed = _make_provider(api, monkeypatch)
374 with pytest.raises(SecurityActionError) as excinfo:
375 await provider.start_pin_pairing("c", static=True)
376 assert excinfo.value.alert_key == "pairing_error_no_pin_method"
377
378
379async def test_pin_mismatch_is_retryable(monkeypatch: pytest.MonkeyPatch) -> None:
380 """A PIN mismatch leaves the session retryable, parked, with an idle deadline armed."""
381 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)])
382 api.outcomes.append(RemotePairingAbortError(PairAbortReason.PIN_MISMATCH))
383 provider, refreshed = _make_provider(api, monkeypatch)
384 session = await provider.start_pin_pairing("c")
385 await _submit_and_settle(provider, "000000")
386 assert session.can_retry
387 assert isinstance(session.error, RemotePairingAbortError)
388 assert _pin_idle_task_id("c") in _timers(provider)
389 assert api.end_pairing_calls == 0
390 assert refreshed == []
391 provider._cancel_pin_idle_timeout("c")
392
393
394async def test_retry_resumes_in_place(monkeypatch: pytest.MonkeyPatch) -> None:
395 """A same-mode retry reuses the session and can then succeed."""
396 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)])
397 api.outcomes.append(RemotePairingAbortError(PairAbortReason.PIN_MISMATCH))
398 provider, refreshed = _make_provider(api, monkeypatch)
399 session = await provider.start_pin_pairing("c", verify=True)
400 await _submit_and_settle(provider, "000000")
401 assert session.can_retry
402
403 same = await provider.start_pin_pairing("c", verify=True)
404 assert same is session
405 assert session.verify is True
406 assert session.method is PairMethod.DYNAMIC_PIN
407 assert session.error is None
408 assert not session.retryable
409 assert _pin_idle_task_id("c") not in _timers(provider)
410 assert session.awaiting_pin
411 # start_pin_pairing waited for the immediate PIN request, so no gesture is claimed.
412 assert not session.awaiting_gesture
413
414 await _submit_and_settle(provider, "123456")
415 assert session.finished
416 assert api.initiate_calls == 2
417 assert refreshed == ["c"]
418
419
420async def test_parked_mode_mismatch_restarts(monkeypatch: pytest.MonkeyPatch) -> None:
421 """A stale parked session never resumes under a different mode."""
422 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)])
423 api.outcomes.append(RemotePairingAbortError(PairAbortReason.PIN_MISMATCH))
424 provider, _refreshed = _make_provider(api, monkeypatch)
425 session = await provider.start_pin_pairing("c", verify=True)
426 await _submit_and_settle(provider, "000000")
427 assert session.can_retry
428
429 fresh = await provider.start_pin_pairing("c")
430 assert fresh is not session
431 assert fresh.verify is False
432 assert api.end_pairing_calls == 1
433 assert api.initiate_calls == 2
434
435
436async def test_parked_static_session_not_resumed_by_default_mode(
437 monkeypatch: pytest.MonkeyPatch,
438) -> None:
439 """A parked static-PIN session is not resumed by a later dynamic-first request."""
440 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN), _desc(PairMethod.STATIC_PIN)])
441 api.outcomes.append(RemotePairingAbortError(PairAbortReason.PIN_MISMATCH))
442 provider, _refreshed = _make_provider(api, monkeypatch)
443 session = await provider.start_pin_pairing("c", static=True)
444 assert session.method is PairMethod.STATIC_PIN
445 await _submit_and_settle(provider, "00000000")
446 assert session.can_retry
447
448 fresh = await provider.start_pin_pairing("c")
449 assert fresh is not session
450 assert fresh.method is PairMethod.DYNAMIC_PIN
451 assert api.end_pairing_calls == 1
452
453
454async def test_running_mode_mismatch_raises_concurrent(monkeypatch: pytest.MonkeyPatch) -> None:
455 """An attempt in flight with a different mode cannot be co-opted."""
456 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)])
457 provider, _refreshed = _make_provider(api, monkeypatch)
458 session = await provider.start_pin_pairing("c")
459 assert session.attempt_running
460
461 with pytest.raises(SecurityActionError) as excinfo:
462 await provider.start_pin_pairing("c", verify=True)
463 assert excinfo.value.alert_key == "pairing_error_concurrent"
464 assert provider.get_pin_session("c") is session
465 await provider.cancel_pin_pairing("c")
466
467
468async def test_retry_awaits_gesture_again(monkeypatch: pytest.MonkeyPatch) -> None:
469 """A retried attempt waits for the client's pair-init anew."""
470 gesture = asyncio.Event()
471 gesture.set()
472 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], gesture=gesture)
473 api.outcomes.append(RemotePairingAbortError(PairAbortReason.PIN_MISMATCH))
474 provider, _refreshed = _make_provider(api, monkeypatch)
475 monkeypatch.setattr(provider_module, "PIN_REQUEST_FEEDBACK_TIMEOUT", 0)
476 session = await provider.start_pin_pairing("c")
477 await _submit_and_settle(provider, "00000000")
478 assert session.can_retry
479
480 gesture.clear()
481 same = await provider.start_pin_pairing("c")
482 assert same is session
483 await asyncio.sleep(0)
484 assert session.awaiting_gesture
485
486 gesture.set()
487 await _submit_and_settle(provider, "12345678")
488 assert session.finished
489 assert session.error is None
490
491
492async def test_pairing_timeout_is_retryable(monkeypatch: pytest.MonkeyPatch) -> None:
493 """A device that never answers leaves the session retryable, with the connection intact."""
494 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)], await_pin=False)
495 api.outcomes.append(PairingTimeoutError("client/pair-init did not arrive in time"))
496 provider, _refreshed = _make_provider(api, monkeypatch)
497 session = await provider.start_pin_pairing("c")
498 assert session.task is not None
499 await session.task
500 assert session.can_retry
501 assert isinstance(session.error, PairingTimeoutError)
502 assert _pin_idle_task_id("c") in _timers(provider)
503 # aiosendspin already left pairing in band, so the provider must not force it.
504 assert api.end_pairing_calls == 0
505
506
507async def test_dynamic_pin_attempt_carries_length_and_languages(
508 monkeypatch: pytest.MonkeyPatch,
509) -> None:
510 """A dynamic-PIN session knows its negotiated length and hints the operator's languages."""
511 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN, min_pin_length=8)])
512 provider, _refreshed = _make_provider(api, monkeypatch)
513 session = await provider.start_pin_pairing("c")
514 assert session.pin_length == 8 # the device's floor wins over the server's default
515 assert api.attempts[0].languages == ("nl-NL", "nl")
516 assert api.attempts[0].on_pair_pending is not None
517 await _submit_and_settle(provider, "12345678")
518
519
520async def test_pin_length_follows_the_hello_advertisement(
521 monkeypatch: pytest.MonkeyPatch,
522) -> None:
523 """
524 The predicted length mirrors the server's negotiation, which reads the hello floor.
525
526 A live config that lowered the floor still leaves the device deriving a hello-length PIN,
527 so the operator prompt must not follow the config here.
528 """
529 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN, min_pin_length=8)])
530 provider, _refreshed = _make_provider(api, monkeypatch)
531 provider._pairing_config_snapshots["c"] = (
532 cast("SendspinConnection", api.connection),
533 ManagementResultData(dynamic_pin=PairingMethodConfig(enabled=True, min_pin_length=4)),
534 )
535
536 session = await provider.start_pin_pairing("c")
537 assert session.pin_length == 8
538 await _submit_and_settle(provider, "12345678")
539
540
541async def test_static_pin_attempt_carries_no_length_or_languages(
542 monkeypatch: pytest.MonkeyPatch,
543) -> None:
544 """The spoken-PIN hint and PIN length are dynamic-PIN only."""
545 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)])
546 provider, _refreshed = _make_provider(api, monkeypatch)
547 session = await provider.start_pin_pairing("c", static=True)
548 assert session.pin_length is None
549 assert api.attempts[0].languages == ()
550 await _submit_and_settle(provider, "12345678")
551
552
553async def test_gesture_signal_tracks_the_window_wait(monkeypatch: pytest.MonkeyPatch) -> None:
554 """pair-pending moves the session from the first-message wait to the gesture wait."""
555 gesture = asyncio.Event()
556 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], gesture=gesture)
557 provider, _refreshed = _make_provider(api, monkeypatch)
558 monkeypatch.setattr(provider_module, "PIN_REQUEST_FEEDBACK_TIMEOUT", 0)
559 session = await provider.start_pin_pairing("c", static=True)
560 await asyncio.sleep(0)
561 assert session.awaiting_gesture
562 assert not session.awaiting_first_message
563
564 gesture.set()
565 await _submit_and_settle(provider, "12345678")
566 assert session.finished
567
568
569async def test_unpaired_device_gets_no_pairing_window(monkeypatch: pytest.MonkeyPatch) -> None:
570 """Management needs a paired connection, so a first-time pairing still needs the gesture."""
571 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], await_pin=False)
572 provider, _refreshed = _make_provider(api, monkeypatch)
573 session = await provider.start_pin_pairing("c", static=True)
574 assert api.connection.window_calls == 0
575 assert not session.opened_management
576 assert provider.get_management_session("c") is None
577
578
579async def test_disconnected_device_is_refused_before_management(
580 monkeypatch: pytest.MonkeyPatch,
581) -> None:
582 """A device that only left its hello behind is refused instead of reaching management."""
583 api = _FakeServerApi(
584 [_desc(PairMethod.STATIC_PIN)], await_pin=False, management_capable=True, connected=False
585 )
586 provider, _refreshed = _make_provider(api, monkeypatch)
587 with pytest.raises(SecurityActionError) as excinfo:
588 await provider.start_pin_pairing("c", static=True)
589 assert excinfo.value.alert_key == "pairing_error_not_connected"
590 assert api.calls == []
591
592
593async def test_paired_device_opens_the_window_before_the_attempt(
594 monkeypatch: pytest.MonkeyPatch,
595) -> None:
596 """A paired device's window is requested over management before pairing starts."""
597 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], await_pin=False, management_capable=True)
598 provider, _refreshed = _make_provider(api, monkeypatch)
599 session = await provider.start_pin_pairing("c", static=True)
600 # The pairing activate takes management off the connection, so the order matters.
601 assert api.calls == ["window", "pair"]
602 assert session.opened_management
603 assert session.task is not None
604 await session.task
605 provider.clear_pin_session("c")
606 # The session opened here is ours to close, so the device does not stay in management.
607 assert provider.get_management_session("c") is None
608 assert not api.connection.management_active
609
610
611async def test_cancel_closes_a_management_session_we_opened(
612 monkeypatch: pytest.MonkeyPatch,
613) -> None:
614 """Cancelling the pairing session also gives back the management session it opened."""
615 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], management_capable=True)
616 provider, _refreshed = _make_provider(api, monkeypatch)
617 session = await provider.start_pin_pairing("c", static=True)
618 assert session.opened_management
619 await provider.cancel_pin_pairing("c")
620 assert provider.get_management_session("c") is None
621
622
623async def test_cancelled_window_request_closes_the_management_session(
624 monkeypatch: pytest.MonkeyPatch,
625) -> None:
626 """Abandoning the flow mid-request still gives back the management session it opened."""
627 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], await_pin=False, management_capable=True)
628 api.connection.window_error = asyncio.CancelledError()
629 provider, _refreshed = _make_provider(api, monkeypatch)
630 with pytest.raises(asyncio.CancelledError):
631 await provider.start_pin_pairing("c", static=True)
632 assert provider.get_management_session("c") is None
633 assert not api.connection.management_active
634
635
636async def test_existing_management_session_is_reused_and_kept(
637 monkeypatch: pytest.MonkeyPatch,
638) -> None:
639 """A session the operator already opened is used for the window and left running."""
640 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], await_pin=False, management_capable=True)
641 provider, _refreshed = _make_provider(api, monkeypatch)
642 provider.enter_management("c")
643 session = await provider.start_pin_pairing("c", static=True)
644 assert api.connection.window_calls == 1
645 assert not session.opened_management
646 assert session.task is not None
647 await session.task
648 provider.clear_pin_session("c")
649 assert provider.get_management_session("c") is not None
650
651
652async def test_rejected_window_falls_back_to_the_gesture(
653 monkeypatch: pytest.MonkeyPatch,
654) -> None:
655 """A refused window request drops the management session and leaves the gesture wait."""
656 api = _FakeServerApi([_desc(PairMethod.STATIC_PIN)], await_pin=False, management_capable=True)
657 api.connection.window_result = ManagementResult.INVALID
658 provider, _refreshed = _make_provider(api, monkeypatch)
659 session = await provider.start_pin_pairing("c", static=True)
660 assert api.connection.window_calls == 1
661 assert not session.opened_management
662 assert provider.get_management_session("c") is None
663
664
665async def test_local_user_cancel_records_no_error(monkeypatch: pytest.MonkeyPatch) -> None:
666 """Our own end_pairing cancel is not surfaced as an error."""
667 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)], await_pin=False)
668 api.outcomes.append(LocalPairingAbortError(PairAbortReason.USER_CANCELLED))
669 provider, refreshed = _make_provider(api, monkeypatch)
670 session = await provider.start_pin_pairing("c")
671 assert session.task is not None
672 await session.task
673 assert session.error is None
674 assert not session.retryable
675 assert session.finished
676 assert refreshed == []
677
678
679async def test_remote_user_cancel_is_retryable(monkeypatch: pytest.MonkeyPatch) -> None:
680 """A cancel initiated on the device is retryable from the operator's side."""
681 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)], await_pin=False)
682 api.outcomes.append(RemotePairingAbortError(PairAbortReason.USER_CANCELLED))
683 provider, _refreshed = _make_provider(api, monkeypatch)
684 session = await provider.start_pin_pairing("c")
685 assert session.task is not None
686 await session.task
687 assert session.can_retry
688 assert isinstance(session.error, RemotePairingAbortError)
689 assert _pin_idle_task_id("c") in _timers(provider)
690 provider._cancel_pin_idle_timeout("c")
691
692
693async def test_non_abort_failure_is_terminal(monkeypatch: pytest.MonkeyPatch) -> None:
694 """A non-abort failure is terminal and does not call end_pairing (server disconnected)."""
695 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)], await_pin=False)
696 api.outcomes.append(PairingError("boom"))
697 provider, refreshed = _make_provider(api, monkeypatch)
698 session = await provider.start_pin_pairing("c")
699 assert session.task is not None
700 await session.task
701 assert session.finished
702 assert not session.can_retry
703 assert isinstance(session.error, PairingError)
704 assert api.end_pairing_calls == 0
705 assert refreshed == []
706
707
708async def test_cancel_pin_pairing_ends_and_pops(monkeypatch: pytest.MonkeyPatch) -> None:
709 """Cancelling ends pairing, drops the session, and refreshes the player."""
710 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)])
711 provider, refreshed = _make_provider(api, monkeypatch)
712 session = await provider.start_pin_pairing("c")
713 assert session.awaiting_pin
714 await provider.cancel_pin_pairing("c")
715 assert provider.get_pin_session("c") is None
716 assert api.end_pairing_calls == 1
717 assert refreshed == ["c"]
718
719
720async def test_pair_with_token_failure_unparks(monkeypatch: pytest.MonkeyPatch) -> None:
721 """A failed token pairing unparks the connection and re-raises."""
722 api = _FakeServerApi([], await_pin=False)
723 api.outcomes.append(RemotePairingAbortError(PairAbortReason.PIN_MISMATCH))
724 provider, refreshed = _make_provider(api, monkeypatch)
725 monkeypatch.setattr(
726 provider_module,
727 "decode_token",
728 lambda _value: SimpleNamespace(client_id="c", pairing_psk=b"\x00" * 32),
729 )
730 with pytest.raises(RemotePairingAbortError):
731 await provider.pair_with_token("c", "tok")
732 assert api.end_pairing_calls == 1
733 assert refreshed == []
734
735
736async def test_pair_with_token_rejected_maps_to_pairing_error(
737 monkeypatch: pytest.MonkeyPatch,
738) -> None:
739 """A token the client does not accept surfaces as a friendly PairingError."""
740 api = _FakeServerApi([], await_pin=False)
741 api.outcomes.append(HandshakeAbortedError("expected Noise message 2 (TEXT), got CLOSE"))
742 provider, refreshed = _make_provider(api, monkeypatch)
743 monkeypatch.setattr(
744 provider_module,
745 "decode_token",
746 lambda _value: SimpleNamespace(client_id="c", pairing_psk=b"\x00" * 32),
747 )
748 with pytest.raises(PairingError, match="the token was rejected by the device"):
749 await provider.pair_with_token("c", "tok")
750 # The server already disconnected the client on a non-abort failure; nothing to unpark.
751 assert api.end_pairing_calls == 0
752 assert refreshed == []
753
754
755async def test_pair_with_token_malformed_token(monkeypatch: pytest.MonkeyPatch) -> None:
756 """A token that fails to decode surfaces as an invalid-token alert without pairing."""
757 api = _FakeServerApi([], await_pin=False)
758 provider, refreshed = _make_provider(api, monkeypatch)
759 with pytest.raises(SecurityActionError) as excinfo:
760 await provider.pair_with_token("c", "not-a-token")
761 assert excinfo.value.alert_key == "pairing_error_token_invalid"
762 assert api.initiate_calls == 0
763 assert refreshed == []
764
765
766async def test_pair_with_token_rejected_while_pin_attempt_runs(
767 monkeypatch: pytest.MonkeyPatch,
768) -> None:
769 """A token submitted while a PIN attempt is running is rejected without a second attempt."""
770 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)])
771 provider, refreshed = _make_provider(api, monkeypatch)
772 session = await provider.start_pin_pairing("c")
773 assert session.attempt_running
774 with pytest.raises(SecurityActionError) as excinfo:
775 await provider.pair_with_token("c", "tok")
776 assert excinfo.value.alert_key == "pairing_error_concurrent"
777 assert api.initiate_calls == 1
778 assert refreshed == []
779 await provider.cancel_pin_pairing("c")
780
781
782async def test_pair_with_token_success_refreshes(monkeypatch: pytest.MonkeyPatch) -> None:
783 """A successful token pairing refreshes the player without unparking."""
784 api = _FakeServerApi([], await_pin=False)
785 provider, refreshed = _make_provider(api, monkeypatch)
786 monkeypatch.setattr(
787 provider_module,
788 "decode_token",
789 lambda _value: SimpleNamespace(client_id="c", pairing_psk=b"\x00" * 32),
790 )
791 await provider.pair_with_token("c", "tok")
792 assert api.end_pairing_calls == 0
793 assert refreshed == ["c"]
794
795
796async def test_idle_timeout_restores_connection(monkeypatch: pytest.MonkeyPatch) -> None:
797 """An abandoned retryable session is terminated and the connection restored."""
798 api = _FakeServerApi([_desc(PairMethod.DYNAMIC_PIN)], await_pin=False)
799 provider, refreshed = _make_provider(api, monkeypatch)
800 monkeypatch.setattr(provider_module, "PIN_RETRY_IDLE_TIMEOUT", 0)
801 session = PinPairingSession(
802 client_id="c",
803 method=PairMethod.DYNAMIC_PIN,
804 pin_future=asyncio.get_running_loop().create_future(),
805 retryable=True,
806 )
807 provider._pin_sessions["c"] = session
808 provider._arm_pin_idle_timeout(session)
809 assert _pin_idle_task_id("c") in _timers(provider)
810 # The zero-delay timer fires on a later loop iteration, then spawns the idle task.
811 for _ in range(20):
812 if api.end_pairing_calls:
813 break
814 await asyncio.sleep(0)
815 assert api.end_pairing_calls == 1
816 assert isinstance(session.error, TimeoutError)
817 assert not session.retryable
818 assert refreshed == ["c"]
819 session.pin_future.cancel()
820