From 09b97c5f6395e73f625a16785a324fcd11ef5571 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 13 Jun 2026 15:37:29 -0700 Subject: [PATCH] =?UTF-8?q?#870:=20Deezer=20ARL=20'resets=20itself'=20?= =?UTF-8?q?=E2=80=94=20test=20the=20SAVED=20token,=20not=20the=20redaction?= =?UTF-8?q?=20mask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Deezer ARL field round-trips a redaction sentinel for a saved-but-untouched secret (shown as dots). The save path already guards against the sentinel overwriting the real token (ConfigManager.set), so the ARL was never actually lost — but the connection TEST read the field value and sent the sentinel as the token, so Deezer returned USER_ID=0 ('Invalid ARL token') after navigating away and back. That false failure made it look like the ARL kept resetting. Fix: - ConfigManager.resolve_secret(key, posted): empty/sentinel posted value -> the stored value; a real string -> a genuine new secret. Reusable for any secret connection-test (single source of truth). - /api/deezer-download/test now resolves the effective ARL via resolve_secret, so an untouched field tests the stored token. - testDeezerDownloadConnection() strips the sentinel before sending (untouched -> empty -> backend uses the saved token). Seam/regression tests for resolve_secret (sentinel/empty/none -> stored, real -> passthrough, nothing stored -> empty). JS integrity 64 green. --- config/settings.py | 14 ++++++++++++ tests/test_resolve_secret.py | 43 ++++++++++++++++++++++++++++++++++++ web_server.py | 5 ++++- webui/static/settings.js | 12 +++++----- 4 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 tests/test_resolve_secret.py 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' },