feat(subsonic): response envelope, id scheme, and error mapping
- envelope: one serializer emitting the <subsonic-response> wrapper in XML (default) and JSON (f=json), carrying status/version/type/serverVersion - ids: stable, reversible type-prefixed ids (tr-/al-/ar-/pl-) ↔ UUIDs - errors: /rest requests render the Subsonic error envelope (always HTTP 200) with standard codes (10 missing param, 40 wrong creds, 50, 70 not found) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""Unit tests for the Subsonic response envelope (XML + JSON shapes)."""
|
||||
|
||||
import json
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from app.api.rest.envelope import (
|
||||
SUBSONIC_API_VERSION,
|
||||
subsonic_error,
|
||||
subsonic_response,
|
||||
)
|
||||
|
||||
|
||||
def _xml_root(body: bytes) -> ET.Element:
|
||||
return ET.fromstring(body)
|
||||
|
||||
|
||||
def _local(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] # strip namespace
|
||||
|
||||
|
||||
def test_ok_xml_shape() -> None:
|
||||
resp = subsonic_response({"license": {"valid": True}}, fmt="xml")
|
||||
assert resp.media_type.startswith("application/xml")
|
||||
root = _xml_root(resp.body)
|
||||
assert _local(root.tag) == "subsonic-response"
|
||||
assert root.attrib["status"] == "ok"
|
||||
assert root.attrib["version"] == SUBSONIC_API_VERSION
|
||||
assert root.attrib["type"] == "mcma"
|
||||
child = root[0]
|
||||
assert _local(child.tag) == "license"
|
||||
assert child.attrib["valid"] == "true"
|
||||
|
||||
|
||||
def test_ok_json_shape() -> None:
|
||||
resp = subsonic_response({"license": {"valid": True}}, fmt="json")
|
||||
assert resp.media_type.startswith("application/json")
|
||||
payload = json.loads(resp.body)["subsonic-response"]
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["version"] == SUBSONIC_API_VERSION
|
||||
assert payload["type"] == "mcma"
|
||||
assert payload["license"] == {"valid": True}
|
||||
|
||||
|
||||
def test_error_xml_shape() -> None:
|
||||
resp = subsonic_error(40, "Wrong username or password.", fmt="xml")
|
||||
root = _xml_root(resp.body)
|
||||
assert root.attrib["status"] == "failed"
|
||||
error = root[0]
|
||||
assert _local(error.tag) == "error"
|
||||
assert error.attrib["code"] == "40"
|
||||
assert error.attrib["message"] == "Wrong username or password."
|
||||
|
||||
|
||||
def test_error_json_shape() -> None:
|
||||
resp = subsonic_error(70, "Not found.", fmt="json")
|
||||
payload = json.loads(resp.body)["subsonic-response"]
|
||||
assert payload["status"] == "failed"
|
||||
assert payload["error"] == {"code": 70, "message": "Not found."}
|
||||
|
||||
|
||||
def test_default_format_is_xml() -> None:
|
||||
resp = subsonic_response(fmt=None)
|
||||
assert resp.media_type.startswith("application/xml")
|
||||
assert _xml_root(resp.body).attrib["status"] == "ok"
|
||||
|
||||
|
||||
def test_list_renders_repeated_elements() -> None:
|
||||
payload = {"genres": {"genre": [{"value": "Rock"}, {"value": "Jazz"}]}}
|
||||
root = _xml_root(subsonic_response(payload, fmt="xml").body)
|
||||
genres = root[0]
|
||||
values = [g.text for g in genres]
|
||||
assert values == ["Rock", "Jazz"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for Subsonic crypto + id helpers (no DB, no network)."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from app.api.rest import ids
|
||||
from app.api.rest.ids import IdKind
|
||||
from app.core.security import SubsonicPasswordCipher, generate_subsonic_password
|
||||
from app.domain.errors import AuthenticationError, NotFoundError
|
||||
|
||||
|
||||
def test_generate_subsonic_password_is_long_and_unique() -> None:
|
||||
a = generate_subsonic_password()
|
||||
b = generate_subsonic_password()
|
||||
assert a != b
|
||||
assert len(a) >= 20
|
||||
|
||||
|
||||
def test_cipher_roundtrip() -> None:
|
||||
cipher = SubsonicPasswordCipher("a-secret-key")
|
||||
plaintext = generate_subsonic_password()
|
||||
token = cipher.encrypt(plaintext)
|
||||
assert token != plaintext
|
||||
assert cipher.decrypt(token) == plaintext
|
||||
|
||||
|
||||
def test_cipher_token_then_md5_matches() -> None:
|
||||
"""The decrypted app-password must reproduce a client's t=md5(password+salt)."""
|
||||
cipher = SubsonicPasswordCipher("a-secret-key")
|
||||
password = generate_subsonic_password()
|
||||
enc = cipher.encrypt(password)
|
||||
|
||||
salt = "c19b2d"
|
||||
decrypted = cipher.decrypt(enc)
|
||||
expected = hashlib.md5((decrypted + salt).encode(), usedforsecurity=False).hexdigest()
|
||||
client_token = hashlib.md5((password + salt).encode(), usedforsecurity=False).hexdigest()
|
||||
assert expected == client_token
|
||||
|
||||
|
||||
def test_cipher_wrong_key_fails() -> None:
|
||||
token = SubsonicPasswordCipher("key-one").encrypt("hunter2")
|
||||
with pytest.raises(AuthenticationError):
|
||||
SubsonicPasswordCipher("key-two").decrypt(token)
|
||||
|
||||
|
||||
def test_id_encode_decode_roundtrip() -> None:
|
||||
value = uuid.uuid4()
|
||||
assert ids.decode_track(ids.encode_track(value)) == value
|
||||
assert ids.decode_album(ids.encode_album(value)) == value
|
||||
assert ids.decode_artist(ids.encode_artist(value)) == value
|
||||
assert ids.decode_playlist(ids.encode_playlist(value)) == value
|
||||
|
||||
|
||||
def test_id_parse_returns_kind() -> None:
|
||||
value = uuid.uuid4()
|
||||
kind, parsed = ids.parse(ids.encode_album(value))
|
||||
assert kind is IdKind.ALBUM
|
||||
assert parsed == value
|
||||
|
||||
|
||||
def test_id_wrong_prefix_rejected() -> None:
|
||||
track = ids.encode_track(uuid.uuid4())
|
||||
with pytest.raises(NotFoundError):
|
||||
ids.decode_album(track)
|
||||
|
||||
|
||||
def test_id_malformed_rejected() -> None:
|
||||
with pytest.raises(NotFoundError):
|
||||
ids.parse("not-a-real-id")
|
||||
Reference in New Issue
Block a user