soulsync/tests/test_profile_recovery.py
BoulderBadgeDad 613688a9ad Login recovery (DB + backend): security question to reset a forgotten password
Closes the forgot-login-password gap. A per-profile recovery question + answer lets
a locked-out user reset their own password.

- DB: additive recovery_question + recovery_answer_hash columns (idempotent
  migration). set/get-question/verify/has methods; answer is hashed (pbkdf2) and
  matched forgivingly (trim + lowercase + collapse whitespace). No recovery set →
  never verifies.
- Endpoints (allowlisted in the login gate so they work pre-auth):
  GET /api/auth/recovery-question?username= (generic 404 when absent),
  POST /api/auth/recovery-reset {username, answer, new_password} — brute-force
  limited; a correct answer sets the new password + authenticates the session.
  POST /api/profiles/<id>/set-recovery (admin or self) to configure it.

Tests: set/get/verify, forgiving match, hashed-not-plaintext, no-recovery-never-
verifies, full reset flow (wrong answer rejected + password intact; correct answer
resets), unknown-user 404. 25 tests pass. Next: the Settings + login-screen UI.
2026-06-10 22:24:54 -07:00

60 lines
2.2 KiB
Python

"""Login-password recovery via security question + answer — DB layer."""
from __future__ import annotations
import pytest
from database.music_database import MusicDatabase
@pytest.fixture
def db(tmp_path):
return MusicDatabase(str(tmp_path / "music.db"))
def test_migration_adds_recovery_columns(db):
with db._get_connection() as conn:
cols = [r[1] for r in conn.execute("PRAGMA table_info(profiles)").fetchall()]
assert 'recovery_question' in cols and 'recovery_answer_hash' in cols
def test_set_get_verify(db):
pid = db.create_profile(name='RecUser')
assert db.profile_has_recovery(pid) is False
assert db.get_profile_recovery_question(pid) is None
db.set_profile_recovery(pid, 'First pet?', 'Rex')
assert db.profile_has_recovery(pid) is True
assert db.get_profile_recovery_question(pid) == 'First pet?'
assert db.verify_profile_recovery_answer(pid, 'Rex') is True
assert db.verify_profile_recovery_answer(pid, 'Fido') is False
def test_answer_match_is_forgiving(db):
pid = db.create_profile(name='Forgiving')
db.set_profile_recovery(pid, 'City?', ' New York ')
assert db.verify_profile_recovery_answer(pid, 'new york') is True # case + spacing
assert db.verify_profile_recovery_answer(pid, 'NEW YORK') is True
def test_no_recovery_never_verifies(db):
pid = db.create_profile(name='NoRec')
assert db.verify_profile_recovery_answer(pid, '') is False
assert db.verify_profile_recovery_answer(pid, 'anything') is False
def test_clearing_recovery(db):
pid = db.create_profile(name='ClearRec')
db.set_profile_recovery(pid, 'Q?', 'A')
assert db.profile_has_recovery(pid) is True
db.set_profile_recovery(pid, '', '')
assert db.profile_has_recovery(pid) is False
assert db.get_profile_recovery_question(pid) is None
def test_answer_is_hashed_not_plaintext(db):
pid = db.create_profile(name='Hashed')
db.set_profile_recovery(pid, 'Q?', 'secretanswer')
with db._get_connection() as conn:
stored = conn.execute("SELECT recovery_answer_hash FROM profiles WHERE id = ?", (pid,)).fetchone()[0]
assert 'secretanswer' not in stored and stored.startswith('pbkdf2:')