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:
BoulderBadgeDad 2026-06-10 00:40:26 -07:00
parent af24a08cc7
commit 4ef12d7ddf
2 changed files with 100 additions and 0 deletions

View file

@ -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

View file

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