#!/usr/bin/env python3 """ Flask test-client tests for auth routes (api/auth_routes.py). The auth_routes Blueprint is expected to be registered on the Flask app at /api/auth/... The module-level `auth_manager` in app is patched to an in-process AuthManager backed by a tmp_path so tests run without Docker. Route contract tested here: POST /api/auth/login POST /api/auth/logout GET /api/auth/me POST /api/auth/change-password POST /api/auth/admin/reset-password GET /api/auth/users """ import os import sys import json from pathlib import Path from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) from app import app from auth_manager import AuthManager # ── helpers ─────────────────────────────────────────────────────────────────── def _make_auth_manager(tmp_path): data_dir = str(tmp_path / 'data') config_dir = str(tmp_path / 'config') os.makedirs(data_dir, exist_ok=True) os.makedirs(config_dir, exist_ok=True) mgr = AuthManager(data_dir=data_dir, config_dir=config_dir) mgr.create_user('admin', 'AdminPass123!', 'admin') mgr.create_user('alice', 'AlicePass123!', 'peer') return mgr def _login(client, username, password): return client.post( '/api/auth/login', data=json.dumps({'username': username, 'password': password}), content_type='application/json', ) # ── fixtures ────────────────────────────────────────────────────────────────── @pytest.fixture def auth_mgr(tmp_path): return _make_auth_manager(tmp_path) @pytest.fixture def app_client(auth_mgr): """Raw test client — not logged in. auth_manager is patched to auth_mgr.""" app.config['TESTING'] = True app.config['SECRET_KEY'] = 'test-secret' with patch('app.auth_manager', auth_mgr): # also patch inside auth_routes module if it imports auth_manager separately try: import auth_routes with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True): with app.test_client() as client: yield client except (ImportError, AttributeError): with app.test_client() as client: yield client @pytest.fixture def admin_client(auth_mgr): """Test client already authenticated as admin.""" app.config['TESTING'] = True app.config['SECRET_KEY'] = 'test-secret' with patch('app.auth_manager', auth_mgr): try: import auth_routes with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True): with app.test_client() as client: r = _login(client, 'admin', 'AdminPass123!') assert r.status_code == 200, ( f'admin login failed with {r.status_code}: {r.data}' ) yield client except (ImportError, AttributeError): with app.test_client() as client: r = _login(client, 'admin', 'AdminPass123!') assert r.status_code == 200, ( f'admin login failed with {r.status_code}: {r.data}' ) yield client @pytest.fixture def peer_client(auth_mgr): """Test client already authenticated as alice (peer).""" app.config['TESTING'] = True app.config['SECRET_KEY'] = 'test-secret' with patch('app.auth_manager', auth_mgr): try: import auth_routes with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True): with app.test_client() as client: r = _login(client, 'alice', 'AlicePass123!') assert r.status_code == 200, ( f'alice login failed with {r.status_code}: {r.data}' ) yield client except (ImportError, AttributeError): with app.test_client() as client: r = _login(client, 'alice', 'AlicePass123!') assert r.status_code == 200, ( f'alice login failed with {r.status_code}: {r.data}' ) yield client @pytest.fixture def anon_client(auth_mgr): """Test client with no session (anonymous).""" app.config['TESTING'] = True app.config['SECRET_KEY'] = 'test-secret' with patch('app.auth_manager', auth_mgr): try: import auth_routes with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True): with app.test_client() as client: yield client except (ImportError, AttributeError): with app.test_client() as client: yield client # ── login ───────────────────────────────────────────────────────────────────── def test_login_success(app_client): r = _login(app_client, 'admin', 'AdminPass123!') assert r.status_code == 200 data = json.loads(r.data) assert 'username' in data assert 'role' in data assert data['username'] == 'admin' def test_login_success_sets_session_cookie(app_client): r = _login(app_client, 'admin', 'AdminPass123!') assert r.status_code == 200 assert 'pic_sess_' in (r.headers.get('Set-Cookie', '') or '') def test_login_wrong_password(app_client): r = _login(app_client, 'admin', 'WrongPassword!') assert r.status_code == 401 def test_login_unknown_user(app_client): r = _login(app_client, 'nobody', 'SomePassword1!') assert r.status_code == 401 def test_login_missing_username(app_client): r = app_client.post( '/api/auth/login', data=json.dumps({'password': 'AdminPass123!'}), content_type='application/json', ) assert r.status_code in (400, 401) def test_login_missing_password(app_client): r = app_client.post( '/api/auth/login', data=json.dumps({'username': 'admin'}), content_type='application/json', ) assert r.status_code in (400, 401) def test_login_empty_body(app_client): r = app_client.post('/api/auth/login', content_type='application/json') assert r.status_code in (400, 401) def test_login_locked_account(app_client, auth_mgr): """After enough failed attempts alice's account locks; subsequent login → 423.""" from auth_manager import LOCKOUT_THRESHOLD for _ in range(LOCKOUT_THRESHOLD): auth_mgr.verify_password('alice', 'wrong') r = _login(app_client, 'alice', 'AlicePass123!') assert r.status_code == 423 # ── logout ──────────────────────────────────────────────────────────────────── def test_logout_returns_200(admin_client): r = admin_client.post('/api/auth/logout') assert r.status_code == 200 def test_logout_then_me_returns_401(admin_client): admin_client.post('/api/auth/logout') r = admin_client.get('/api/auth/me') assert r.status_code == 401 # ── /api/auth/me ────────────────────────────────────────────────────────────── def test_me_authenticated_returns_200(admin_client): r = admin_client.get('/api/auth/me') assert r.status_code == 200 def test_me_authenticated_returns_username(admin_client): r = admin_client.get('/api/auth/me') data = json.loads(r.data) assert data.get('username') == 'admin' def test_me_unauthenticated_returns_401(anon_client): r = anon_client.get('/api/auth/me') assert r.status_code == 401 # ── change-password ─────────────────────────────────────────────────────────── def test_change_password_success(peer_client): r = peer_client.post( '/api/auth/change-password', data=json.dumps({'old_password': 'AlicePass123!', 'new_password': 'AliceNew99!'}), content_type='application/json', ) assert r.status_code == 200 def test_change_password_new_password_works(peer_client, auth_mgr): peer_client.post( '/api/auth/change-password', data=json.dumps({'old_password': 'AlicePass123!', 'new_password': 'AliceNew99!'}), content_type='application/json', ) result = auth_mgr.verify_password('alice', 'AliceNew99!') assert result is not None def test_change_password_wrong_old_returns_400(peer_client): r = peer_client.post( '/api/auth/change-password', data=json.dumps({'old_password': 'WrongOld!', 'new_password': 'AliceNew99!'}), content_type='application/json', ) assert r.status_code == 400 def test_change_password_unauthenticated_returns_401(anon_client): r = anon_client.post( '/api/auth/change-password', data=json.dumps({'old_password': 'AlicePass123!', 'new_password': 'AliceNew99!'}), content_type='application/json', ) assert r.status_code == 401 # ── admin reset-password ────────────────────────────────────────────────────── def test_admin_reset_password_success(admin_client): r = admin_client.post( '/api/auth/admin/reset-password', data=json.dumps({'username': 'alice', 'new_password': 'AdminSet99!'}), content_type='application/json', ) assert r.status_code == 200 def test_admin_reset_password_peer_forbidden(peer_client): r = peer_client.post( '/api/auth/admin/reset-password', data=json.dumps({'username': 'admin', 'new_password': 'HackedPass1!'}), content_type='application/json', ) assert r.status_code == 403 def test_admin_reset_password_unknown_user(admin_client): r = admin_client.post( '/api/auth/admin/reset-password', data=json.dumps({'username': 'nobody', 'new_password': 'SomePass1!'}), content_type='application/json', ) assert r.status_code in (400, 404) def test_admin_reset_password_unauthenticated(anon_client): r = anon_client.post( '/api/auth/admin/reset-password', data=json.dumps({'username': 'alice', 'new_password': 'SomePass1!'}), content_type='application/json', ) assert r.status_code == 401 # ── /api/auth/users ─────────────────────────────────────────────────────────── def test_list_users_admin_returns_200(admin_client): r = admin_client.get('/api/auth/users') assert r.status_code == 200 def test_list_users_contains_admin_and_alice(admin_client): r = admin_client.get('/api/auth/users') users = json.loads(r.data) assert isinstance(users, list) names = [u['username'] for u in users] assert 'admin' in names assert 'alice' in names def test_list_users_no_hashes_in_response(admin_client): r = admin_client.get('/api/auth/users') users = json.loads(r.data) for u in users: assert 'password_hash' not in u def test_list_users_peer_forbidden(peer_client): r = peer_client.get('/api/auth/users') assert r.status_code == 403 def test_list_users_unauthenticated(anon_client): r = anon_client.get('/api/auth/users') assert r.status_code == 401