diff --git a/config/settings.py b/config/settings.py index f1482081..28d1908a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -876,6 +876,20 @@ class ConfigManager: config[keys[-1]] = value self._save_config() + def resolve_secret(self, key: str, posted: Any) -> str: + """Resolve a secret value coming back from the settings UI. + + The UI renders a saved-but-untouched secret as the REDACTED_SENTINEL (shown + masked); empty or that sentinel means "use the stored value", while a real + string is a genuine new secret. A connection-test endpoint should test the + EFFECTIVE secret, not the mask — otherwise testing a saved-but-untouched + token sends the sentinel and the source rejects it (#870).""" + if isinstance(posted, str): + posted = posted.strip() + if not posted or posted == self.REDACTED_SENTINEL: + return self.get(key, '') or '' + return posted + def get_spotify_config(self) -> Dict[str, str]: return self.get('spotify', {}) diff --git a/tests/test_resolve_secret.py b/tests/test_resolve_secret.py new file mode 100644 index 00000000..3ff53a36 --- /dev/null +++ b/tests/test_resolve_secret.py @@ -0,0 +1,43 @@ +"""ConfigManager.resolve_secret — test the EFFECTIVE secret, not the mask (#870). + +The settings UI renders a saved-but-untouched secret as the redaction sentinel +(shown as dots). The Deezer ARL connection test was sending that sentinel as the +token, so Deezer rejected it ("Invalid ARL token — USER_ID=0") and it looked like +the ARL kept resetting — even though the saved value was fine. resolve_secret maps +an empty/sentinel posted value back to the stored token. +""" + +from __future__ import annotations + +from config.settings import ConfigManager + +S = ConfigManager.REDACTED_SENTINEL + + +def _cm(config_data): + cm = object.__new__(ConfigManager) # bypass __init__/DB — get() reads config_data + cm.config_data = config_data + return cm + + +def test_sentinel_resolves_to_stored(): + cm = _cm({'deezer_download': {'arl': 'real_arl_123'}}) + assert cm.resolve_secret('deezer_download.arl', S) == 'real_arl_123' + + +def test_empty_or_none_resolves_to_stored(): + cm = _cm({'deezer_download': {'arl': 'real_arl_123'}}) + assert cm.resolve_secret('deezer_download.arl', '') == 'real_arl_123' + assert cm.resolve_secret('deezer_download.arl', ' ') == 'real_arl_123' + assert cm.resolve_secret('deezer_download.arl', None) == 'real_arl_123' + + +def test_real_value_passes_through_trimmed(): + cm = _cm({'deezer_download': {'arl': 'old'}}) + assert cm.resolve_secret('deezer_download.arl', 'new_token') == 'new_token' + assert cm.resolve_secret('deezer_download.arl', ' spaced ') == 'spaced' + + +def test_sentinel_with_nothing_stored_returns_empty(): + cm = _cm({}) + assert cm.resolve_secret('deezer_download.arl', S) == '' diff --git a/web_server.py b/web_server.py index 0196fe9b..fd53c71a 100644 --- a/web_server.py +++ b/web_server.py @@ -21578,7 +21578,10 @@ def deezer_download_test(): """Test Deezer ARL token authentication.""" try: data = request.get_json() or {} - arl = data.get('arl', '') + # An empty/redaction-sentinel value means "test the SAVED token" — the + # settings field round-trips a mask for a saved-but-untouched secret, so + # testing it must use the stored ARL, not the mask (#870). + arl = config_manager.resolve_secret('deezer_download.arl', data.get('arl')) if not arl: return jsonify({'success': False, 'error': 'No ARL token provided'}) diff --git a/webui/static/settings.js b/webui/static/settings.js index af4bb3f7..1921839b 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -4305,13 +4305,11 @@ async function testDeezerDownloadConnection() { statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { - // Save the ARL first so the backend can use it - const arl = document.getElementById('deezer-download-arl')?.value || ''; - if (!arl) { - statusEl.textContent = 'No ARL token provided'; - statusEl.style.color = '#ff9800'; - return; - } + let arl = document.getElementById('deezer-download-arl')?.value || ''; + // An untouched field holds the redaction sentinel (a token IS saved) — + // send empty so the backend tests the SAVED token instead of the mask, + // which the source would reject (#870). + if (arl === REDACTED_SECRET_SENTINEL) arl = ''; const resp = await fetch('/api/deezer-download/test', { method: 'POST', headers: { 'Content-Type': 'application/json' },