#870: Deezer ARL 'resets itself' — test the SAVED token, not the redaction mask

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.
This commit is contained in:
BoulderBadgeDad 2026-06-13 15:37:29 -07:00
parent 177a4d8d05
commit 09b97c5f63
4 changed files with 66 additions and 8 deletions

View file

@ -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', {})

View file

@ -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) == ''

View file

@ -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'})

View file

@ -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' },