"""Phase 0: named, switchable service-credential sets. Foundation for letting an admin save multiple named credential sets per auth service ("pills") that each profile can switch between. These cover the PURE selection/validation logic and the encrypted DB storage + per-profile selection + resolver — with real temp databases (not mocks) so encryption round-trips and the stale-selection fallback are genuinely exercised. This layer is dormant (nothing reads it at runtime yet), so it can't regress existing behaviour — the tests pin the contract the later wiring will rely on. """ from __future__ import annotations import pytest from core.credentials.store import ( SUPPORTED_SERVICES, is_supported_service, validate_credential_payload, pick_active_credential, ) from database.music_database import MusicDatabase # ── pure: validation ──────────────────────────────────────────────────────── def test_supported_services_cover_the_auth_sources(): for s in ('spotify', 'tidal', 'deezer', 'qobuz', 'plex', 'jellyfin', 'navidrome'): assert is_supported_service(s) assert not is_supported_service('itunes') # no auth → not a credential service assert not is_supported_service('musicbrainz') def test_validate_payload_ok_and_missing(): ok, missing = validate_credential_payload('spotify', {'client_id': 'a', 'client_secret': 'b'}) assert ok and missing == [] ok, missing = validate_credential_payload('spotify', {'client_id': 'a'}) assert not ok and missing == ['client_secret'] def test_validate_payload_unknown_service_and_non_dict(): assert validate_credential_payload('nope', {'x': 1}) == (False, []) ok, missing = validate_credential_payload('plex', None) assert not ok and set(missing) == {'base_url', 'token'} def test_validate_treats_empty_string_as_missing(): ok, missing = validate_credential_payload('navidrome', {'base_url': 'http://x', 'username': '', 'password': 'p'}) assert not ok and missing == ['username'] def test_validate_treats_whitespace_only_as_missing(): # A blank-but-spacey secret must be rejected, not saved to fail later. ok, missing = validate_credential_payload('navidrome', {'base_url': 'http://x', 'username': ' ', 'password': 'p'}) assert not ok and missing == ['username'] # ── pure: active-set selection (stale-safe) ────────────────────────────────── def test_pick_active_credential_match_and_misses(): creds = [{'id': 1, 'label': 'A'}, {'id': 2, 'label': 'B'}] assert pick_active_credential(creds, 2)['label'] == 'B' assert pick_active_credential(creds, None) is None # no selection assert pick_active_credential(creds, 99) is None # stale id (set deleted) assert pick_active_credential([], 1) is None assert pick_active_credential(None, 1) is None # ── DB: storage + selection + resolver (real temp DB, real encryption) ─────── @pytest.fixture def db(tmp_path): return MusicDatabase(database_path=str(tmp_path / 'creds.db')) def test_create_get_roundtrip_encrypts_payload(db, tmp_path): cid = db.create_service_credential('spotify', "Brock's Spotify", {'client_id': 'abc', 'client_secret': 'sek'}) assert cid got = db.get_service_credential(cid) assert got['service'] == 'spotify' and got['label'] == "Brock's Spotify" assert got['payload'] == {'client_id': 'abc', 'client_secret': 'sek'} # The on-disk payload must be ciphertext, never the plaintext secret. import sqlite3 raw = sqlite3.connect(str(tmp_path / 'creds.db')).execute( "SELECT payload FROM service_credentials WHERE id = ?", (cid,)).fetchone()[0] assert 'sek' not in raw and raw.startswith('gAAAAA') def test_duplicate_label_per_service_rejected(db): assert db.create_service_credential('spotify', 'Main', {'client_id': 'a', 'client_secret': 'b'}) assert db.create_service_credential('spotify', 'Main', {'client_id': 'c', 'client_secret': 'd'}) is None # same label is fine under a DIFFERENT service assert db.create_service_credential('tidal', 'Main', {'access_token': 't', 'refresh_token': 'r'}) def test_list_never_exposes_payload(db): db.create_service_credential('spotify', 'One', {'client_id': 'a', 'client_secret': 'b'}) db.create_service_credential('deezer', 'Two', {'arl': 'xyz'}) rows = db.list_service_credentials() assert {r['label'] for r in rows} == {'One', 'Two'} assert all('payload' not in r for r in rows) assert [r['label'] for r in db.list_service_credentials('deezer')] == ['Two'] def test_update_label_and_payload(db): cid = db.create_service_credential('qobuz', 'Q', {'user_auth_token': 'tok'}) assert db.update_service_credential(cid, label='Q renamed') assert db.update_service_credential(cid, payload={'user_auth_token': 'newtok'}) got = db.get_service_credential(cid) assert got['label'] == 'Q renamed' and got['payload']['user_auth_token'] == 'newtok' def test_profile_selection_resolves_and_falls_back(db): cid = db.create_service_credential('spotify', 'Shared', {'client_id': 'a', 'client_secret': 'b'}) # No selection → None (caller uses global default) assert db.resolve_profile_service_credential(7, 'spotify') is None db.set_profile_service_credential(7, 'spotify', cid) assert db.resolve_profile_service_credential(7, 'spotify') == {'client_id': 'a', 'client_secret': 'b'} # Clearing the selection falls back again db.set_profile_service_credential(7, 'spotify', None) assert db.resolve_profile_service_credential(7, 'spotify') is None def test_delete_clears_selections_and_resolves_to_fallback(db): cid = db.create_service_credential('spotify', 'Temp', {'client_id': 'a', 'client_secret': 'b'}) db.set_profile_service_credential(2, 'spotify', cid) db.set_profile_service_credential(9, 'spotify', cid) assert db.delete_service_credential(cid) # Both profiles' dangling selections resolve to None, not an error. assert db.resolve_profile_service_credential(2, 'spotify') is None assert db.resolve_profile_service_credential(9, 'spotify') is None assert db.get_service_credential(cid) is None def test_selection_is_per_profile_and_per_service(db): sp = db.create_service_credential('spotify', 'SP', {'client_id': 'a', 'client_secret': 'b'}) td = db.create_service_credential('tidal', 'TD', {'access_token': 't', 'refresh_token': 'r'}) db.set_profile_service_credential(1, 'spotify', sp) db.set_profile_service_credential(1, 'tidal', td) assert db.resolve_profile_service_credential(1, 'spotify')['client_id'] == 'a' assert db.resolve_profile_service_credential(1, 'tidal')['access_token'] == 't' # A different profile shares the pool but has its own (empty) selection. assert db.resolve_profile_service_credential(2, 'spotify') is None