Replaces the basic credential-pill quick-switch with a Manage-Workers-styled modal (topbar + left rail + panel, entrance animation, brand-logo cards). - Sidebar Service Status: whole panel opens the modal; clicking the Metadata / Media Server / Download rows deep-links straight to that tab. Removed the "switch ▸" hover text. - Three tabs: Metadata (source logo cards, unavailable ones dimmed), Server (Plex/Jellyfin/Navidrome/SoulSync logos), Download (Single⇄Hybrid segmented toggle; Hybrid shows a draggable priority list). Logos reuse SOURCE_LABELS + HYBRID_SOURCES; active card gets an accent ring + check. - Admin writes the GLOBAL active source/server/download (reuses the same setters + client reloads as the Settings save, so changes take effect immediately). Non-admins see it read-only (editable=false) — the per-profile override is the next layer. Backend: GET /api/profiles/me/active-sources (any profile; reports editable), POST /api/profiles/active-sources (@admin_only; validates against the allowed metadata/server/download lists, applies + reloads). New service-switch.js (registered + in the integrity registry); old modal removed from credential-sets.js (admin Connected Accounts manager stays). Tests: 14 endpoint tests — read shape, admin sets metadata/hybrid+order (reflected), bad-value 400s, non-admin read-only + 403 on write. 64 integrity tests pass; real-app smoke confirms render + deep-links + the full set/reflect cycle.
183 lines
8.4 KiB
Python
183 lines
8.4 KiB
Python
"""Phase 1: service-credential-set admin endpoints (real app, real HTTP).
|
|
|
|
These import the actual web_server app and drive the endpoints through a Flask
|
|
test client — the only way to verify the @admin_only gating and the request
|
|
validation wrappers for real. Secrets must never come back in any response.
|
|
|
|
Heavy (imports web_server once), so isolated in its own module. The default
|
|
session is profile 1 (admin); a non-admin session is simulated to prove the
|
|
gate blocks writes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
# Redirect the DB before importing web_server so it never touches a real library.
|
|
_TMP = tempfile.mkdtemp(prefix='ss-cred-ep-')
|
|
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'creds_ep.db')
|
|
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
|
|
|
|
web_server = pytest.importorskip('web_server')
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return web_server.app.test_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def nonadmin_profile():
|
|
"""Create a real non-admin profile and yield its id."""
|
|
db = web_server.get_database()
|
|
pid = db.create_profile(name=f'tester_{os.urandom(3).hex()}', avatar_color='#fff')
|
|
yield pid
|
|
|
|
|
|
# ── admin happy paths ────────────────────────────────────────────────────────
|
|
|
|
def test_admin_create_list_update_delete_roundtrip(client):
|
|
r = client.post('/api/credentials', json={
|
|
'service': 'plex', 'label': 'Living Room',
|
|
'payload': {'base_url': 'http://plex:32400', 'token': 'sekret'}})
|
|
assert r.status_code == 200 and r.get_json()['success']
|
|
cid = r.get_json()['id']
|
|
|
|
# list shows it, and NEVER leaks the payload/secret
|
|
body = client.get('/api/credentials').get_json()
|
|
assert any(c['label'] == 'Living Room' for c in body['services']['plex'])
|
|
assert 'sekret' not in str(body) and 'payload' not in str(body)
|
|
|
|
# update label
|
|
assert client.put(f'/api/credentials/{cid}', json={'label': 'Den'}).get_json()['success']
|
|
body = client.get('/api/credentials').get_json()
|
|
assert any(c['label'] == 'Den' for c in body['services']['plex'])
|
|
|
|
# delete
|
|
assert client.delete(f'/api/credentials/{cid}').get_json()['success']
|
|
body = client.get('/api/credentials').get_json()
|
|
assert not any(c['id'] == cid for c in body['services']['plex'])
|
|
|
|
|
|
# ── validation ───────────────────────────────────────────────────────────────
|
|
|
|
def test_create_rejects_missing_fields(client):
|
|
r = client.post('/api/credentials', json={
|
|
'service': 'plex', 'label': 'X', 'payload': {'base_url': 'http://p'}})
|
|
assert r.status_code == 400 and 'token' in r.get_json()['error']
|
|
|
|
|
|
def test_create_rejects_unsupported_service(client):
|
|
r = client.post('/api/credentials', json={'service': 'itunes', 'label': 'X', 'payload': {}})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_create_rejects_blank_label(client):
|
|
r = client.post('/api/credentials', json={
|
|
'service': 'deezer', 'label': ' ', 'payload': {'arl': 'x'}})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_duplicate_label_conflict(client):
|
|
p = {'service': 'qobuz', 'label': 'Dup', 'payload': {'user_auth_token': 't'}}
|
|
assert client.post('/api/credentials', json=p).status_code == 200
|
|
assert client.post('/api/credentials', json=p).status_code == 409
|
|
|
|
|
|
def test_update_missing_set_404(client):
|
|
assert client.put('/api/credentials/999999', json={'label': 'x'}).status_code == 404
|
|
|
|
|
|
def test_delete_missing_set_404(client):
|
|
assert client.delete('/api/credentials/999999').status_code == 404
|
|
|
|
|
|
# ── the security gate: non-admin cannot manage credential sets ───────────────
|
|
|
|
def test_nonadmin_blocked_from_all_credential_writes(client, nonadmin_profile):
|
|
with client.session_transaction() as sess:
|
|
sess['profile_id'] = nonadmin_profile
|
|
assert client.get('/api/credentials').status_code == 403
|
|
assert client.post('/api/credentials', json={
|
|
'service': 'plex', 'label': 'Sneaky',
|
|
'payload': {'base_url': 'http://p', 'token': 't'}}).status_code == 403
|
|
assert client.put('/api/credentials/1', json={'label': 'x'}).status_code == 403
|
|
assert client.delete('/api/credentials/1').status_code == 403
|
|
|
|
|
|
# ── Phase 2: per-profile selection (any profile selects among existing sets) ──
|
|
|
|
def test_profile_selects_among_existing_sets(client, nonadmin_profile):
|
|
# Admin creates two Spotify sets.
|
|
a = client.post('/api/credentials', json={'service': 'spotify', 'label': 'Acct A',
|
|
'payload': {'client_id': 'a', 'client_secret': 's'}}).get_json()['id']
|
|
b = client.post('/api/credentials', json={'service': 'spotify', 'label': 'Acct B',
|
|
'payload': {'client_id': 'b', 'client_secret': 's'}}).get_json()['id']
|
|
|
|
# Switch to a non-admin session — it can still READ options + SELECT.
|
|
with client.session_transaction() as sess:
|
|
sess['profile_id'] = nonadmin_profile
|
|
|
|
svc = client.get('/api/profiles/me/services').get_json()['services']['spotify']
|
|
assert {o['id'] for o in svc['options']} == {a, b}
|
|
assert svc['selected_id'] is None
|
|
assert 'secret' not in str(svc) and 's' not in [o.get('client_secret') for o in svc['options'] if 'client_secret' in o]
|
|
|
|
assert client.post('/api/profiles/me/services/select',
|
|
json={'service': 'spotify', 'credential_id': b}).get_json()['success']
|
|
svc = client.get('/api/profiles/me/services').get_json()['services']['spotify']
|
|
assert svc['selected_id'] == b
|
|
|
|
# Clear → back to None
|
|
assert client.post('/api/profiles/me/services/select',
|
|
json={'service': 'spotify', 'credential_id': None}).get_json()['success']
|
|
assert client.get('/api/profiles/me/services').get_json()['services']['spotify']['selected_id'] is None
|
|
|
|
|
|
def test_select_rejects_wrong_service_or_missing_set(client):
|
|
sp = client.post('/api/credentials', json={'service': 'spotify', 'label': 'X',
|
|
'payload': {'client_id': 'a', 'client_secret': 's'}}).get_json()['id']
|
|
# Selecting a spotify set under 'tidal' must be rejected.
|
|
assert client.post('/api/profiles/me/services/select',
|
|
json={'service': 'tidal', 'credential_id': sp}).status_code == 400
|
|
# Nonexistent id rejected.
|
|
assert client.post('/api/profiles/me/services/select',
|
|
json={'service': 'spotify', 'credential_id': 999999}).status_code == 400
|
|
# Unsupported service rejected.
|
|
assert client.post('/api/profiles/me/services/select',
|
|
json={'service': 'itunes', 'credential_id': None}).status_code == 400
|
|
|
|
|
|
# ── Quick-switch: active source/server/download (admin=global, non-admin read-only) ──
|
|
|
|
def test_active_sources_read_shape(client):
|
|
a = client.get('/api/profiles/me/active-sources').get_json()
|
|
assert a['success'] and a['editable'] is True # default session = admin
|
|
assert a['metadata']['active'] and len(a['metadata']['options']) == 6
|
|
assert len(a['server']['options']) == 4
|
|
assert 'mode' in a['download'] and isinstance(a['download']['hybrid_order'], list)
|
|
|
|
|
|
def test_admin_sets_global_active_sources(client):
|
|
assert client.post('/api/profiles/active-sources', json={'metadata_source': 'itunes'}).get_json()['success']
|
|
assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'itunes'
|
|
# hybrid + order round-trips
|
|
client.post('/api/profiles/active-sources', json={'download_mode': 'hybrid', 'hybrid_order': ['hifi', 'soulseek']})
|
|
dl = client.get('/api/profiles/me/active-sources').get_json()['download']
|
|
assert dl['mode'] == 'hybrid' and dl['hybrid_order'] == ['hifi', 'soulseek']
|
|
|
|
|
|
def test_active_sources_rejects_bad_values(client):
|
|
assert client.post('/api/profiles/active-sources', json={'metadata_source': 'nope'}).status_code == 400
|
|
assert client.post('/api/profiles/active-sources', json={'media_server': 'nope'}).status_code == 400
|
|
assert client.post('/api/profiles/active-sources', json={'download_mode': 'nope'}).status_code == 400
|
|
|
|
|
|
def test_active_sources_nonadmin_readonly_and_blocked(client, nonadmin_profile):
|
|
with client.session_transaction() as sess:
|
|
sess['profile_id'] = nonadmin_profile
|
|
assert client.get('/api/profiles/me/active-sources').get_json()['editable'] is False
|
|
assert client.post('/api/profiles/active-sources', json={'metadata_source': 'deezer'}).status_code == 403
|