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
|
'payload': {'base_url': 'http://p', 'token': 't'}}).status_code == 403
|
||||||
assert client.put('/api/credentials/1', json={'label': 'x'}).status_code == 403
|
assert client.put('/api/credentials/1', json={'label': 'x'}).status_code == 403
|
||||||
assert client.delete('/api/credentials/1').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
|
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 ---
|
# --- Watchlist API Endpoints ---
|
||||||
|
|
||||||
@app.route('/api/watchlist/count', methods=['GET'])
|
@app.route('/api/watchlist/count', methods=['GET'])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue