Profiles Phase 2 (backend): per-profile credential selection endpoints
Lets any profile pick which admin-created credential set is active for it,
without creating/seeing secrets:
- GET /api/profiles/me/services per-service options (id+label only) +
this profile's selected_id (stale-safe)
- POST /api/profiles/me/services/select {service, credential_id|null}
Not admin-gated by design — it only writes a per-profile pointer and exposes no
secrets. Validates the chosen set exists AND belongs to that service (can't
select a tidal set under spotify), and rejects unsupported services. null clears
back to the global/admin default.
Tests: a non-admin reads options + selects + clears (no secret in the response),
and selection rejects wrong-service / nonexistent / unsupported. 10 endpoint
tests total.
This commit is contained in:
parent
af24a08cc7
commit
4ef12d7ddf
2 changed files with 100 additions and 0 deletions
|
|
@ -106,3 +106,46 @@ def test_nonadmin_blocked_from_all_credential_writes(client, nonadmin_profile):
|
|||
'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
|
||||
|
|
|
|||
|
|
@ -25536,6 +25536,63 @@ def delete_service_credential_endpoint(credential_id):
|
|||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/profiles/me/services', methods=['GET'])
|
||||
def get_my_service_selections():
|
||||
"""For the current profile: the available credential sets per service (id +
|
||||
label, never secrets) and which one this profile has selected. Drives the
|
||||
quick-switch modal's pills. Any profile may read this — it exposes no
|
||||
secrets, only the admin-created set names. Stale-safe: a selection whose set
|
||||
was deleted reports as None (fall back to the global/admin default)."""
|
||||
try:
|
||||
from core.credentials.store import SERVICE_CREDENTIAL_SCHEMA
|
||||
db = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
out = {}
|
||||
for service in SERVICE_CREDENTIAL_SCHEMA:
|
||||
options = db.list_service_credentials(service)
|
||||
selected = db.get_profile_service_credential_id(profile_id, service)
|
||||
if selected not in {o['id'] for o in options}:
|
||||
selected = None
|
||||
out[service] = {
|
||||
'options': [{'id': o['id'], 'label': o['label']} for o in options],
|
||||
'selected_id': selected,
|
||||
}
|
||||
return jsonify({'success': True, 'services': out})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/profiles/me/services/select', methods=['POST'])
|
||||
def select_my_service_credential():
|
||||
"""Set which admin-created credential set is active for the current profile
|
||||
on a service. ``credential_id=null`` clears it (fall back to the global/admin
|
||||
default). The caller can only pick an EXISTING set for that service — never
|
||||
create one — so a non-admin can switch their account but not configure new
|
||||
credentials. Not admin-gated by design: it only writes a per-profile pointer
|
||||
and exposes no secrets."""
|
||||
try:
|
||||
from core.credentials.store import is_supported_service
|
||||
data = request.json or {}
|
||||
service = (data.get('service') or '').strip()
|
||||
credential_id = data.get('credential_id')
|
||||
|
||||
if not is_supported_service(service):
|
||||
return jsonify({'success': False, 'error': f'Unsupported service: {service}'}), 400
|
||||
|
||||
db = get_database()
|
||||
if credential_id is not None:
|
||||
cred = db.get_service_credential(credential_id)
|
||||
if not cred or cred['service'] != service:
|
||||
return jsonify({'success': False, 'error': 'No such credential set for this service'}), 400
|
||||
|
||||
ok = db.set_profile_service_credential(get_current_profile_id(), service, credential_id)
|
||||
if ok:
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False, 'error': 'Failed to save selection'}), 500
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# --- Watchlist API Endpoints ---
|
||||
|
||||
@app.route('/api/watchlist/count', methods=['GET'])
|
||||
|
|
|
|||
Loading…
Reference in a new issue