spotify export: clickable authorize link (popup-block safe) + endpoint pre-check tests

Double-checking the on-demand auth flow: the needs_auth handler called window.open() AFTER an
await, which breaks the user-gesture chain so browsers popup-block it — the user would see "approve
in the new tab" with no tab. Replaced with a clickable authorize link (a direct click is never
blocked).

Adds two endpoint tests via the Flask test client: Spotify export returns needs_auth + the
/auth/spotify/export url (and short-circuits before the DB) when the token lacks write scope, and
does NOT short-circuit when write scope is present. 10 service-export tests green, 64 script-integrity
green, ruff clean.
This commit is contained in:
BoulderBadgeDad 2026-06-29 08:35:25 -07:00
parent f3672c7ab4
commit efefdd64ff
2 changed files with 28 additions and 4 deletions

View file

@ -128,3 +128,27 @@ def test_spotify_backfill_search_disables_cross_service_fallback(monkeypatch):
assert fn is not None
fn('Kendrick Lamar', 'Not Like Us') # drives the search
assert seen['allow_fallback'] is False
def test_spotify_export_endpoint_demands_auth_when_no_write_scope(monkeypatch):
"""The export endpoint must return needs_auth (not start a doomed job) when the Spotify
token lacks write scope and it must short-circuit BEFORE touching the DB."""
import types
monkeypatch.setattr(ws, 'spotify_client',
types.SimpleNamespace(has_write_scope=lambda: False))
resp = ws.app.test_client().post('/api/playlists/5/export/service/spotify')
data = resp.get_json()
assert data['needs_auth'] is True
assert data['auth_url'] == '/auth/spotify/export'
assert data['success'] is False
def test_spotify_export_endpoint_proceeds_when_write_scope_present(monkeypatch):
"""With write scope, the spotify path must NOT short-circuit on needs_auth (it goes on to
start a job here it just must not be a needs_auth response)."""
import types
monkeypatch.setattr(ws, 'spotify_client',
types.SimpleNamespace(has_write_scope=lambda: True))
resp = ws.app.test_client().post('/api/playlists/999999/export/service/spotify')
data = resp.get_json()
assert not data.get('needs_auth')

View file

@ -730,11 +730,11 @@ async function _startPlaylistExport(playlistId, mode, name, backfill) {
body: isService ? JSON.stringify({ backfill: !!backfill }) : JSON.stringify({ mode }),
});
const data = await resp.json();
// Spotify export needs a one-time write-permission grant. Open the consent in a new tab
// and tell the user to try again once they've authorized.
// Spotify export needs a one-time write-permission grant. Surface a clickable link (a
// direct user click avoids popup-blocking; window.open after this await would be blocked)
// and tell the user to retry once they've authorized.
if (data.needs_auth && data.auth_url) {
window.open(data.auth_url, '_blank');
_setExportStatus(playlistId, `<span style="color:#f59e0b;">Spotify needs permission to create playlists — approve it in the new tab, then click Export again.</span>`, 15000);
_setExportStatus(playlistId, `<span style="color:#f59e0b;">Spotify needs permission to create playlists — <a href="${data.auth_url}" target="_blank" rel="noopener" style="color:#38bdf8;text-decoration:underline;">authorize</a>, then click Export again.</span>`, 20000);
return;
}
if (!data.success || !data.job_id) {