Fix account swaping
Deploy HEOS panel / deploy (push) Successful in 26s

This commit is contained in:
2026-09-18 02:05:48 +02:00
parent 053be20b9c
commit 5ca504c680
10 changed files with 885 additions and 21 deletions
+141
View File
@@ -0,0 +1,141 @@
"""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()