Adversarial line-by-line review of the feature diff turned up: - /api/database/update/stop was NOT @admin_only while its sibling start_update was — a non-admin could abort a library scan. Gated. - /api/metadata-cache/evict was NOT gated while its clear siblings were. Gated. - validate_credential_payload now treats whitespace-only values as missing, so a blank-but-spacey secret can't be saved to fail confusingly later. Tests updated: both endpoints added to the admin-gating matrix; a whitespace-only validation case added. 42 credential/gating tests pass. Review also confirmed (no change needed): migration is idempotent + additive + O(1); encryption round-trips with a non-dict guard; no SQL injection; stale selections fall back to None safely; no secret ever returned to the browser; the hybrid-drag index math is correct in both directions; the new resolver is fully DORMANT (zero runtime callers) so existing client behaviour is untouched; and @admin_only is a no-op for single-profile installs (default profile = admin).
82 lines
3 KiB
Python
82 lines
3 KiB
Python
"""Phase 3: server-side admin gating of shared/global-destructive endpoints.
|
|
|
|
The audit found these were callable by any profile (UI hid them, the API didn't).
|
|
For a real multi-user setup that's unsafe — a non-admin could restore/vacuum the
|
|
DB, wipe the shared library, clear the Plex library, or mint API keys. These
|
|
assert the @admin_only gate now blocks non-admins, that admin is NOT blocked
|
|
(zero change for single-profile installs, where everyone is the default admin),
|
|
and crucially that a PROFILE-SCOPED op (clearing your OWN wishlist) was NOT
|
|
over-gated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
_TMP = tempfile.mkdtemp(prefix='ss-admin-gate-')
|
|
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'gate.db')
|
|
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
|
|
|
|
web_server = pytest.importorskip('web_server')
|
|
|
|
|
|
# (method, path) for every endpoint that must be admin-only.
|
|
GATED = [
|
|
('GET', '/api/v1/api-keys-internal'),
|
|
('POST', '/api/v1/api-keys-internal/generate'),
|
|
('DELETE', '/api/v1/api-keys-internal/revoke/abc'),
|
|
('POST', '/api/plex/clear-library'),
|
|
('PUT', '/api/library/clear-match'),
|
|
('DELETE', '/api/library/track/123'),
|
|
('DELETE', '/api/library/album/123'),
|
|
('POST', '/api/library/tracks/delete-batch'),
|
|
('POST', '/api/database/update'),
|
|
('POST', '/api/database/update/stop'),
|
|
('POST', '/api/database/backup'),
|
|
('DELETE', '/api/database/backups/x.db'),
|
|
('POST', '/api/database/backups/x.db/restore'),
|
|
('POST', '/api/database/maintenance/vacuum'),
|
|
('DELETE', '/api/metadata-cache/clear'),
|
|
('DELETE', '/api/metadata-cache/clear-musicbrainz'),
|
|
('POST', '/api/metadata-cache/evict'),
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return web_server.app.test_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def nonadmin(client):
|
|
pid = web_server.get_database().create_profile(name=f'u_{os.urandom(3).hex()}')
|
|
with client.session_transaction() as sess:
|
|
sess['profile_id'] = pid
|
|
return pid
|
|
|
|
|
|
def _call(client, method, path):
|
|
return client.open(path, method=method, json={})
|
|
|
|
|
|
@pytest.mark.parametrize('method,path', GATED)
|
|
def test_nonadmin_blocked(client, nonadmin, method, path):
|
|
# @admin_only returns 403 BEFORE the view body runs, so this never triggers
|
|
# the underlying destructive operation — safe to assert across all of them.
|
|
assert _call(client, method, path).status_code == 403, f"{method} {path} should be 403 for non-admin"
|
|
|
|
|
|
def test_admin_not_blocked_by_the_gate(client):
|
|
# Default session = profile 1 (admin). Prove the gate lets admin through on a
|
|
# SAFE, read-only gated endpoint (listing API keys) — confirming the no-change
|
|
# guarantee for single-profile installs without triggering a destructive op.
|
|
assert client.get('/api/v1/api-keys-internal').status_code != 403
|
|
|
|
|
|
def test_profile_scoped_wishlist_clear_not_overgated(client, nonadmin):
|
|
# Clearing your OWN wishlist is profile-scoped data — a non-admin MUST still
|
|
# be allowed. This is the guard against a blanket sweep.
|
|
assert _call(client, 'POST', '/api/wishlist/clear').status_code != 403
|