Opt-in username/password login — profiles become real accounts. This is the data layer: a per-profile login password, kept SEPARATE from the quick-switch PIN (different security purpose; a 4-digit PIN must not become the password guarding a public instance). - Additive migration: profiles.password_hash column (idempotent, metadata-flagged). - set_profile_password / verify_profile_password / profile_has_password / get_profile_by_name (the login username = profile name, unique + case-insensitive). - Security default: a profile with NO password is NOT loginable (verify returns False) — unlike the PIN where "no PIN = always valid". You can't authenticate to an account with no credential. Tests: migration adds the column; set/verify; no-password-never-loginable; clearing; name lookup; and password is fully independent of the PIN. 6 tests pass. Next: the login endpoint + require_login gate (increment 2).
67 lines
2.6 KiB
Python
67 lines
2.6 KiB
Python
"""Per-profile LOGIN password (opt-in username/password mode) — DB layer.
|
|
|
|
Separate from the quick-switch PIN: a profile with no password set is NOT
|
|
loginable (you can't authenticate to an account with no credential), unlike the
|
|
PIN where 'no PIN = always valid'.
|
|
"""
|
|
|
|
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_password_hash_column(db):
|
|
with db._get_connection() as conn:
|
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(profiles)").fetchall()]
|
|
assert 'password_hash' in cols
|
|
|
|
|
|
def test_set_and_verify_password(db):
|
|
pid = db.create_profile(name='Brock')
|
|
assert db.profile_has_password(pid) is False # none yet
|
|
assert db.verify_profile_password(pid, 'hunter2') is False # no password → not loginable
|
|
|
|
db.set_profile_password(pid, 'hunter2')
|
|
assert db.profile_has_password(pid) is True
|
|
assert db.verify_profile_password(pid, 'hunter2') is True
|
|
assert db.verify_profile_password(pid, 'wrong') is False
|
|
|
|
|
|
def test_no_password_is_never_loginable(db):
|
|
# Unlike the PIN (no PIN = always valid), a passwordless account can't log in.
|
|
pid = db.create_profile(name='NoPass')
|
|
assert db.verify_profile_password(pid, '') is False
|
|
assert db.verify_profile_password(pid, 'anything') is False
|
|
|
|
|
|
def test_clearing_password(db):
|
|
pid = db.create_profile(name='Temp')
|
|
db.set_profile_password(pid, 'pw')
|
|
assert db.profile_has_password(pid) is True
|
|
db.set_profile_password(pid, '') # clear
|
|
assert db.profile_has_password(pid) is False
|
|
assert db.verify_profile_password(pid, 'pw') is False
|
|
|
|
|
|
def test_get_profile_by_name_case_insensitive(db):
|
|
pid = db.create_profile(name='Daughter')
|
|
assert db.get_profile_by_name('daughter')['id'] == pid
|
|
assert db.get_profile_by_name('DAUGHTER')['id'] == pid
|
|
assert db.get_profile_by_name('nobody') is None
|
|
|
|
|
|
def test_password_is_independent_of_pin(db):
|
|
# Setting a password must not touch the PIN and vice-versa (separate creds).
|
|
from werkzeug.security import generate_password_hash
|
|
pid = db.create_profile(name='Both', pin_hash=generate_password_hash('1234', method='pbkdf2:sha256'))
|
|
db.set_profile_password(pid, 'longpassword')
|
|
assert db.verify_profile_pin(pid, '1234') is True # PIN still works
|
|
assert db.verify_profile_password(pid, 'longpassword') is True # password works
|
|
assert db.verify_profile_password(pid, '1234') is False # PIN is NOT the password
|