soulsync/tests/test_login_endpoints.py
BoulderBadgeDad 21dfbb39b0 Native login (increment 3/3): login screen, set-password, Settings toggle, logout
The UI that makes opt-in login usable. Off by default → your LAN setup is unchanged
(none of this appears unless security.require_login is on).

- Login screen overlay (reuses the launch-PIN styling): username + password →
  /api/auth/login → reload into the app. Shown when /api/profiles/current reports
  login_required (checked before profile selection).
- POST /api/profiles/<id>/set-password (admin, or self) to set/clear a login
  password, distinct from the PIN.
- Settings → Security: "Login password (admin account)" field + a "Require login"
  toggle (with the anti-lockout note). Wired into the existing settings load/save.
- Sign-out button in the profile bar, revealed only in login mode (login_mode flag
  on /api/profiles/current); soulsyncLogout() → /api/auth/logout → reload.

Tests: set-password sets/clears + verifies; /api/profiles/current signals
login_required. 20 login/password tests pass; 64 script-split integrity pass.

Remaining (small follow-up): a password field in the Manage Profiles edit form so
admins can set OTHER profiles' passwords from the UI (the endpoint already exists).
2026-06-10 22:10:33 -07:00

100 lines
3.9 KiB
Python

"""Username/password login endpoints + gate (opt-in login mode)."""
from __future__ import annotations
import os
import tempfile
import pytest
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-login-')
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'l.db')
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
web_server = pytest.importorskip('web_server')
@pytest.fixture
def client():
return web_server.app.test_client()
def _enable_login(monkeypatch):
real_get = web_server.config_manager.get
monkeypatch.setattr(web_server.config_manager, 'get',
lambda k, d=None: True if k == 'security.require_login' else real_get(k, d))
web_server._login_limiter.record_success('127.0.0.1') # clean slate
_GATED = '/api/profiles/me/connections' # a normal, non-allowlisted endpoint
def test_login_gate_blocks_then_authenticated_access(client, monkeypatch):
db = web_server.get_database()
pid = db.create_profile(name='LoginUser')
db.set_profile_password(pid, 'secretpw')
_enable_login(monkeypatch)
assert client.get(_GATED).status_code == 401 # not logged in → blocked
r = client.post('/api/auth/login', json={'username': 'LoginUser', 'password': 'secretpw'})
assert r.status_code == 200 and r.get_json()['success'] is True
assert client.get(_GATED).status_code == 200 # authenticated → in
assert client.post('/api/auth/logout').get_json()['success'] is True
assert client.get(_GATED).status_code == 401 # logged out → blocked again
def test_login_is_case_insensitive_on_username(client, monkeypatch):
db = web_server.get_database()
db.set_profile_password(db.create_profile(name='CaseUser'), 'pw')
_enable_login(monkeypatch)
assert client.post('/api/auth/login', json={'username': 'caseuser', 'password': 'pw'}).status_code == 200
def test_wrong_password_401_generic(client, monkeypatch):
db = web_server.get_database()
db.set_profile_password(db.create_profile(name='WrongPwUser'), 'right')
_enable_login(monkeypatch)
r = client.post('/api/auth/login', json={'username': 'WrongPwUser', 'password': 'nope'})
assert r.status_code == 401
assert 'username or password' in r.get_json()['error'].lower() # generic — no name-leak
def test_passwordless_profile_cannot_login(client, monkeypatch):
db = web_server.get_database()
db.create_profile(name='NoPwUser') # no password set
_enable_login(monkeypatch)
assert client.post('/api/auth/login', json={'username': 'NoPwUser', 'password': 'x'}).status_code == 401
def test_unknown_user_401(client, monkeypatch):
_enable_login(monkeypatch)
assert client.post('/api/auth/login', json={'username': 'ghost', 'password': 'x'}).status_code == 401
def test_cannot_enable_login_without_admin_password(client):
# admin (1) has no password → enabling login mode is refused (anti-lockout)
web_server.get_database().set_profile_password(1, '')
r = client.post('/api/settings', json={'security': {'require_login': True}})
assert r.status_code == 400
assert 'password' in r.get_json().get('error', '').lower()
def test_set_password_endpoint(client):
db = web_server.get_database()
pid = db.create_profile(name='SetPwTest')
# admin (default session) can set any profile's login password
r = client.post(f'/api/profiles/{pid}/set-password', json={'password': 'newpw123'})
body = r.get_json()
assert body['success'] is True and body['has_password'] is True
assert db.verify_profile_password(pid, 'newpw123') is True
# clearing it
assert client.post(f'/api/profiles/{pid}/set-password', json={'password': ''}).get_json()['has_password'] is False
def test_profiles_current_signals_login_required(client, monkeypatch):
_enable_login(monkeypatch)
body = client.get('/api/profiles/current').get_json()
assert body.get('login_required') is True # frontend uses this to show the sign-in screen