b975164fc2
- 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>
71 lines
2.3 KiB
Python
71 lines
2.3 KiB
Python
"""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")
|