Files
pic/tests/test_auth_routes.py
roof ac0c16c97b Fix session cookie name collision when running multiple PIC instances on localhost
Flask's default cookie name ('session') is shared across all ports on the same
hostname. When two PIC instances are accessed via localhost:portA and localhost:portB,
logging into one overwrites the other's session cookie, causing repeated logouts.

Derive a unique 8-hex suffix from each instance's persistent SECRET_KEY and set
SESSION_COOKIE_NAME = 'pic_sess_<suffix>'. This ensures each cell uses a distinct
cookie name, so sessions are fully isolated regardless of hostname.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:15:42 -04:00

339 lines
12 KiB
Python

#!/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