"""The LAN half of Spotify Connect, against a stand-in for one speaker's /zc endpoint. What the real ones do with these calls was measured rather than assumed -- spotify_zc's module docstring says what and why. python3 -m unittest discover -s tests -t . """ import base64 import json import sys import threading import unittest from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from spotify_zc import KEY_BYTES, Speaker, ZeroconfError # noqa: E402 class FakeSpeaker(threading.Thread): """A speaker's /zc endpoint, as much of one as this needs -- including its habit of answering 101 to a sign-in it has not tried yet.""" def __init__(self, token_type="accesstoken"): super().__init__(daemon=True) self.token_type = token_type self.refuse = False self.logins = [] self.resets = 0 fake = self class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def _send(self, payload): body = json.dumps(payload).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if parse_qs(urlparse(self.path).query).get("action") == ["getInfo"]: self._send(fake.getinfo()) else: self._send({"status": 301, "statusString": "ERROR-UNKNOWN"}) def do_POST(self): length = int(self.headers.get("Content-Length", 0)) form = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode()).items()} if form.get("action") == "addUser": fake.logins.append(form) if fake.refuse: self._send({"status": 203, "statusString": "ERROR-INVALID-ARGUMENTS"}) else: self._send({"status": 101, "statusString": "OK", "spotifyError": 0}) elif form.get("action") == "resetUsers": fake.resets += 1 self._send({"status": 101, "statusString": "OK"}) else: self._send({"status": 301, "statusString": "ERROR-UNKNOWN"}) self.server = HTTPServer(("127.0.0.1", 0), Handler) self.port = self.server.server_port self.start() def getinfo(self): return { "status": 101, "statusString": "OK", "remoteName": "Living Room", "deviceID": "8f190c21", "version": "2.10.0", "tokenType": self.token_type, } def run(self): self.server.serve_forever(poll_interval=0.05) def stop(self): self.server.shutdown() self.server.server_close() class SpeakerTest(unittest.TestCase): def setUp(self): self.fake = FakeSpeaker() self.addCleanup(self.fake.stop) self.speaker = Speaker("127.0.0.1", self.fake.port, "/zc") def test_info_reads_the_speaker_back(self): self.assertEqual(self.speaker.info()["remoteName"], "Living Room") def test_add_user_hands_over_the_token(self): self.speaker.add_user("fifou", "BQD-token") (login,) = self.fake.logins self.assertEqual(login["userName"], "fifou") self.assertEqual(login["tokenType"], "accesstoken") # Plain, because that is the only shape these speakers act on: a # sealed blob is taken and then never signed in with. self.assertEqual(login["blob"], "BQD-token") def test_add_user_sends_the_client_key_the_device_demands(self): # Required -- a POST without one is a 400 -- and then never read, # so all that matters is that something of the size is there. self.speaker.add_user("fifou", "BQD-token") self.assertEqual(len(base64.b64decode(self.fake.logins[0]["clientKey"])), KEY_BYTES) def test_every_sign_in_brings_its_own_client_key(self): self.speaker.add_user("fifou", "BQD-token") self.speaker.add_user("fifou", "BQD-token") first, second = (login["clientKey"] for login in self.fake.logins) self.assertNotEqual(first, second) def test_a_refused_sign_in_is_reported(self): self.fake.refuse = True with self.assertRaises(ZeroconfError) as caught: self.speaker.add_user("fifou", "BQD-token") self.assertIn("refused the login", str(caught.exception)) def test_reset_users_signs_it_out(self): self.speaker.reset_users() self.assertEqual(self.fake.resets, 1) def test_an_older_speaker_says_so_rather_than_failing_obscurely(self): # A device on the original blob protocol wants credentials a Web API # token cannot produce, so there is no point sending it one. self.fake.token_type = "authBlob" with self.assertRaises(ZeroconfError) as caught: self.speaker.add_user("fifou", "BQD-token") self.assertIn("older blob protocol", str(caught.exception)) self.assertEqual(self.fake.logins, []) def test_a_speaker_that_is_not_there_is_an_error_not_a_hang(self): self.fake.stop() with self.assertRaises(ZeroconfError): self.speaker.info() if __name__ == "__main__": unittest.main()