"""Tests for server-side session management and QR code login. Covers: * Session creation, validation, revocation, and cleanup * "Log off everywhere" (revoke all sessions) * QR login challenge creation, validation, claiming, and status polling * Session management API endpoints (list, revoke, revoke-all) * QR auth API endpoints (challenge, status, claim) * Device info parsing from User-Agent strings """ from __future__ import annotations import secrets from datetime import datetime, timedelta, timezone from unittest.mock import patch import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from app.database import Base from app.models import ApiToken, QRLoginChallenge, UserSession # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def db_session(): """Provide an in-memory SQLite session with all tables created.""" engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) TestSession = sessionmaker(bind=engine) session = TestSession() yield session session.close() Base.metadata.drop_all(engine) @pytest.fixture() def sample_user_id(): return "user@example.com" # --------------------------------------------------------------------------- # Model Tests # --------------------------------------------------------------------------- @pytest.mark.unit class TestUserSessionModel: """Tests for the UserSession ORM model.""" def test_create_user_session(self, db_session: Session, sample_user_id: str): """Test creating a UserSession record.""" now = datetime.now(timezone.utc) session = UserSession( session_token=secrets.token_urlsafe(64), user_id=sample_user_id, ip_address="192.168.1.1", user_agent="Mozilla/5.0", device_info="Chrome on macOS", expires_at=now + timedelta(days=30), ) db_session.add(session) db_session.commit() assert session.id is not None assert session.user_id == sample_user_id assert session.is_revoked is False assert session.device_info == "Chrome on macOS" def test_session_default_values(self, db_session: Session, sample_user_id: str): """Test that default values are set correctly.""" session = UserSession( session_token="test_token_123", user_id=sample_user_id, expires_at=datetime.now(timezone.utc) + timedelta(days=30), ) db_session.add(session) db_session.commit() assert session.is_revoked is False assert session.revoked_at is None @pytest.mark.unit class TestQRLoginChallengeModel: """Tests for the QRLoginChallenge ORM model.""" def test_create_challenge(self, db_session: Session, sample_user_id: str): """Test creating a QRLoginChallenge record.""" challenge = QRLoginChallenge( challenge_token=secrets.token_urlsafe(64), user_id=sample_user_id, created_by_ip="10.0.0.1", expires_at=datetime.now(timezone.utc) + timedelta(seconds=120), ) db_session.add(challenge) db_session.commit() assert challenge.id is not None assert challenge.is_claimed is False assert challenge.is_cancelled is False def test_challenge_default_values(self, db_session: Session, sample_user_id: str): """Test that QRLoginChallenge defaults are correct.""" challenge = QRLoginChallenge( challenge_token="challenge_test_123", user_id=sample_user_id, expires_at=datetime.now(timezone.utc) + timedelta(seconds=120), ) db_session.add(challenge) db_session.commit() assert challenge.is_claimed is False assert challenge.is_cancelled is False assert challenge.claimed_at is None assert challenge.device_name is None # --------------------------------------------------------------------------- # Session Manager Tests # --------------------------------------------------------------------------- @pytest.mark.unit class TestSessionManager: """Tests for app/utils/session_manager.py functions.""" @patch("app.utils.session_manager.settings") def test_get_session_lifetime_days_default(self, mock_settings): """Test default session lifetime.""" from app.utils.session_manager import get_session_lifetime_days mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 assert get_session_lifetime_days() == 30 @patch("app.utils.session_manager.settings") def test_get_session_lifetime_days_custom(self, mock_settings): """Test custom session lifetime overrides default.""" from app.utils.session_manager import get_session_lifetime_days mock_settings.session_lifetime_custom_days = 90 mock_settings.session_lifetime_days = 30 assert get_session_lifetime_days() == 90 @patch("app.utils.session_manager.settings") def test_get_session_lifetime_days_minimum(self, mock_settings): """Test session lifetime has a minimum of 1 day.""" from app.utils.session_manager import get_session_lifetime_days mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 0 assert get_session_lifetime_days() == 1 @patch("app.utils.session_manager.settings") def test_get_session_max_age_seconds(self, mock_settings): """Test session max age in seconds.""" from app.utils.session_manager import get_session_max_age_seconds mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 assert get_session_max_age_seconds() == 30 * 86400 @patch("app.utils.session_manager.settings") def test_create_session(self, mock_settings, db_session: Session, sample_user_id: str): """Test creating a server-side session.""" from app.utils.session_manager import create_session mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 mock_settings.qr_login_challenge_ttl_seconds = 120 user_session = create_session( db_session, user_id=sample_user_id, ip_address="10.0.0.1", user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0", ) assert user_session.id is not None assert user_session.user_id == sample_user_id assert user_session.ip_address == "10.0.0.1" assert user_session.session_token is not None assert len(user_session.session_token) > 32 assert user_session.is_revoked is False assert user_session.device_info is not None @patch("app.utils.session_manager.settings") def test_validate_session_valid(self, mock_settings, db_session: Session, sample_user_id: str): """Test validating a valid session.""" from app.utils.session_manager import create_session, validate_session mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 user_session = create_session(db_session, user_id=sample_user_id) result = validate_session(db_session, user_session.session_token) assert result is not None assert result.id == user_session.id @patch("app.utils.session_manager.settings") def test_validate_session_revoked(self, mock_settings, db_session: Session, sample_user_id: str): """Test that revoked sessions are rejected.""" from app.utils.session_manager import create_session, validate_session mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 user_session = create_session(db_session, user_id=sample_user_id) user_session.is_revoked = True db_session.commit() result = validate_session(db_session, user_session.session_token) assert result is None @patch("app.utils.session_manager.settings") def test_validate_session_expired(self, mock_settings, db_session: Session, sample_user_id: str): """Test that expired sessions are rejected.""" from app.utils.session_manager import create_session, validate_session mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 user_session = create_session(db_session, user_id=sample_user_id) user_session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) db_session.commit() result = validate_session(db_session, user_session.session_token) assert result is None def test_validate_session_empty_token(self, db_session: Session): """Test that empty token returns None.""" from app.utils.session_manager import validate_session assert validate_session(db_session, "") is None assert validate_session(db_session, None) is None def test_validate_session_nonexistent_token(self, db_session: Session): """Test that nonexistent token returns None.""" from app.utils.session_manager import validate_session assert validate_session(db_session, "nonexistent_token_xyz") is None @patch("app.utils.session_manager.settings") def test_revoke_session(self, mock_settings, db_session: Session, sample_user_id: str): """Test revoking a single session.""" from app.utils.session_manager import create_session, revoke_session, validate_session mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 user_session = create_session(db_session, user_id=sample_user_id) assert revoke_session(db_session, user_session.id, sample_user_id) is True # Session should now be invalid assert validate_session(db_session, user_session.session_token) is None @patch("app.utils.session_manager.settings") def test_revoke_session_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str): """Test that a user cannot revoke another user's session.""" from app.utils.session_manager import create_session, revoke_session mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 user_session = create_session(db_session, user_id=sample_user_id) assert revoke_session(db_session, user_session.id, "other_user@example.com") is False @patch("app.utils.session_manager.settings") def test_revoke_all_sessions(self, mock_settings, db_session: Session, sample_user_id: str): """Test revoking all sessions for a user.""" from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 s1 = create_session(db_session, user_id=sample_user_id) s2 = create_session(db_session, user_id=sample_user_id) s3 = create_session(db_session, user_id=sample_user_id) count = revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=False) assert count == 3 # All sessions should be revoked active = list_user_sessions(db_session, sample_user_id) assert len(active) == 0 @patch("app.utils.session_manager.settings") def test_revoke_all_except_current(self, mock_settings, db_session: Session, sample_user_id: str): """Test revoking all sessions except the current one.""" from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 s1 = create_session(db_session, user_id=sample_user_id) s2 = create_session(db_session, user_id=sample_user_id) s3 = create_session(db_session, user_id=sample_user_id) count = revoke_all_sessions( db_session, sample_user_id, except_session_id=s1.id, revoke_api_tokens=False, ) assert count == 2 active = list_user_sessions(db_session, sample_user_id) assert len(active) == 1 assert active[0].id == s1.id @patch("app.utils.session_manager.settings") def test_revoke_all_includes_api_tokens(self, mock_settings, db_session: Session, sample_user_id: str): """Test that revoke-all also revokes API tokens.""" from app.utils.session_manager import create_session, revoke_all_sessions mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 create_session(db_session, user_id=sample_user_id) # Create an API token token = ApiToken( owner_id=sample_user_id, name="Test Token", token_hash="abc123hash", token_prefix="de_abc12345", ) db_session.add(token) db_session.commit() revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=True) db_session.refresh(token) assert token.is_active is False @patch("app.utils.session_manager.settings") def test_list_user_sessions(self, mock_settings, db_session: Session, sample_user_id: str): """Test listing active sessions for a user.""" from app.utils.session_manager import create_session, list_user_sessions mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 create_session(db_session, user_id=sample_user_id) create_session(db_session, user_id=sample_user_id) create_session(db_session, user_id="other@example.com") sessions = list_user_sessions(db_session, sample_user_id) assert len(sessions) == 2 @patch("app.utils.session_manager.settings") def test_cleanup_expired_sessions(self, mock_settings, db_session: Session, sample_user_id: str): """Test cleaning up expired sessions.""" from app.utils.session_manager import cleanup_expired_sessions, create_session mock_settings.session_lifetime_custom_days = None mock_settings.session_lifetime_days = 30 # Create a session that expired 10 days ago session = create_session(db_session, user_id=sample_user_id) session.expires_at = datetime.now(timezone.utc) - timedelta(days=10) db_session.commit() count = cleanup_expired_sessions(db_session) assert count == 1 # --------------------------------------------------------------------------- # QR Login Tests # --------------------------------------------------------------------------- @pytest.mark.unit class TestQRLogin: """Tests for QR login challenge/claim flow.""" @patch("app.utils.session_manager.settings") def test_create_qr_challenge(self, mock_settings, db_session: Session, sample_user_id: str): """Test creating a QR login challenge.""" from app.utils.session_manager import create_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id, ip_address="10.0.0.1") assert challenge.id is not None assert challenge.user_id == sample_user_id assert challenge.challenge_token is not None assert len(challenge.challenge_token) > 32 assert challenge.is_claimed is False assert challenge.created_by_ip == "10.0.0.1" # SQLite returns naive datetimes; normalise before comparison expires = challenge.expires_at if expires.tzinfo is None: expires = expires.replace(tzinfo=timezone.utc) assert expires > datetime.now(timezone.utc) @patch("app.utils.session_manager.settings") def test_create_qr_challenge_ttl_seconds(self, mock_settings, db_session: Session, sample_user_id: str): """Test that ttl_seconds can be derived from created_at and expires_at. The API endpoint computes ttl_seconds = (expires_at - created_at) to allow the client to run a countdown timer without comparing absolute timestamps (avoiding clock-skew issues). """ from app.utils.session_manager import create_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds())) assert ttl_seconds == 120 @patch("app.utils.session_manager.settings") def test_create_qr_challenge_custom_ttl(self, mock_settings, db_session: Session, sample_user_id: str): """Test that a custom TTL is correctly reflected in the challenge timestamps.""" from app.utils.session_manager import create_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 300 challenge = create_qr_challenge(db_session, sample_user_id) ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds())) assert ttl_seconds == 300 @patch("app.utils.session_manager.settings") def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str): """Test validating a valid QR challenge.""" from app.utils.session_manager import create_qr_challenge, validate_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) result = validate_qr_challenge(db_session, challenge.challenge_token) assert result is not None assert result.id == challenge.id @patch("app.utils.session_manager.settings") def test_validate_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str): """Test that expired challenges are rejected.""" from app.utils.session_manager import create_qr_challenge, validate_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1) db_session.commit() result = validate_qr_challenge(db_session, challenge.challenge_token) assert result is None @patch("app.utils.session_manager.settings") def test_validate_qr_challenge_claimed(self, mock_settings, db_session: Session, sample_user_id: str): """Test that claimed challenges are rejected (replay protection).""" from app.utils.session_manager import create_qr_challenge, validate_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) challenge.is_claimed = True db_session.commit() result = validate_qr_challenge(db_session, challenge.challenge_token) assert result is None @patch("app.utils.session_manager.settings") def test_validate_qr_challenge_cancelled(self, mock_settings, db_session: Session, sample_user_id: str): """Test that cancelled challenges are rejected.""" from app.utils.session_manager import create_qr_challenge, validate_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) challenge.is_cancelled = True db_session.commit() result = validate_qr_challenge(db_session, challenge.challenge_token) assert result is None def test_validate_qr_challenge_empty(self, db_session: Session): """Test that empty challenge token returns None.""" from app.utils.session_manager import validate_qr_challenge assert validate_qr_challenge(db_session, "") is None assert validate_qr_challenge(db_session, None) is None @patch("app.utils.session_manager.settings") def test_claim_qr_challenge_success(self, mock_settings, db_session: Session, sample_user_id: str): """Test successfully claiming a QR challenge.""" from app.utils.session_manager import claim_qr_challenge, create_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) result = claim_qr_challenge( db_session, challenge.challenge_token, device_name="Christian's iPhone 15 Pro", ip_address="192.168.1.100", ) assert result is not None assert result["token"].startswith("de_") assert result["token_id"] is not None assert result["owner_id"] == sample_user_id assert "QR" in result["name"] # Challenge should now be claimed db_session.refresh(challenge) assert challenge.is_claimed is True assert challenge.claimed_by_ip == "192.168.1.100" assert challenge.device_name == "Christian's iPhone 15 Pro" @patch("app.utils.session_manager.settings") def test_claim_qr_challenge_replay_protection(self, mock_settings, db_session: Session, sample_user_id: str): """Test that a claimed challenge cannot be claimed again.""" from app.utils.session_manager import claim_qr_challenge, create_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) # First claim succeeds result1 = claim_qr_challenge(db_session, challenge.challenge_token) assert result1 is not None # Second claim fails (replay protection) result2 = claim_qr_challenge(db_session, challenge.challenge_token) assert result2 is None @patch("app.utils.session_manager.settings") def test_claim_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str): """Test that expired challenges cannot be claimed.""" from app.utils.session_manager import claim_qr_challenge, create_qr_challenge mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1) db_session.commit() result = claim_qr_challenge(db_session, challenge.challenge_token) assert result is None def test_claim_qr_challenge_invalid_token(self, db_session: Session): """Test claiming with an invalid token.""" from app.utils.session_manager import claim_qr_challenge result = claim_qr_challenge(db_session, "nonexistent_token_xyz") assert result is None @patch("app.utils.session_manager.settings") def test_get_challenge_status_pending(self, mock_settings, db_session: Session, sample_user_id: str): """Test getting status of a pending challenge.""" from app.utils.session_manager import create_qr_challenge, get_challenge_status mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) status = get_challenge_status(db_session, challenge.id, sample_user_id) assert status is not None assert status["status"] == "pending" @patch("app.utils.session_manager.settings") def test_get_challenge_status_claimed(self, mock_settings, db_session: Session, sample_user_id: str): """Test getting status of a claimed challenge.""" from app.utils.session_manager import claim_qr_challenge, create_qr_challenge, get_challenge_status mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) claim_qr_challenge(db_session, challenge.challenge_token, device_name="Test Device") status = get_challenge_status(db_session, challenge.id, sample_user_id) assert status is not None assert status["status"] == "claimed" assert status["device_name"] == "Test Device" @patch("app.utils.session_manager.settings") def test_get_challenge_status_expired(self, mock_settings, db_session: Session, sample_user_id: str): """Test getting status of an expired challenge.""" from app.utils.session_manager import create_qr_challenge, get_challenge_status mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1) db_session.commit() status = get_challenge_status(db_session, challenge.id, sample_user_id) assert status["status"] == "expired" @patch("app.utils.session_manager.settings") def test_get_challenge_status_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str): """Test that a user cannot see another user's challenge status.""" from app.utils.session_manager import create_qr_challenge, get_challenge_status mock_settings.qr_login_challenge_ttl_seconds = 120 challenge = create_qr_challenge(db_session, sample_user_id) status = get_challenge_status(db_session, challenge.id, "other@example.com") assert status is None # --------------------------------------------------------------------------- # Device Info Parsing Tests # --------------------------------------------------------------------------- @pytest.mark.unit class TestDeviceInfoParsing: """Tests for User-Agent parsing.""" def test_chrome_macos(self): from app.utils.session_manager import _parse_device_info ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" result = _parse_device_info(ua) assert "Chrome" in result assert "macOS" in result def test_safari_iphone(self): from app.utils.session_manager import _parse_device_info ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1" result = _parse_device_info(ua) assert "Safari" in result assert "iPhone" in result def test_firefox_windows(self): from app.utils.session_manager import _parse_device_info ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0" result = _parse_device_info(ua) assert "Firefox" in result assert "Windows" in result def test_edge_windows(self): from app.utils.session_manager import _parse_device_info ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0" result = _parse_device_info(ua) assert "Edge" in result assert "Windows" in result def test_android_chrome(self): from app.utils.session_manager import _parse_device_info ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.210 Mobile Safari/537.36" result = _parse_device_info(ua) assert "Chrome" in result assert "Android" in result def test_none_user_agent(self): from app.utils.session_manager import _parse_device_info assert _parse_device_info(None) is None def test_empty_user_agent(self): from app.utils.session_manager import _parse_device_info assert _parse_device_info("") is None # --------------------------------------------------------------------------- # Config Tests # --------------------------------------------------------------------------- @pytest.mark.unit class TestSessionConfig: """Tests for session-related configuration fields.""" def test_session_lifetime_days_field_exists(self): """Verify session_lifetime_days field is defined in Settings.""" from app.config import Settings # Check the field exists in the model assert "session_lifetime_days" in Settings.model_fields def test_session_lifetime_custom_days_field_exists(self): """Verify session_lifetime_custom_days field is defined in Settings.""" from app.config import Settings assert "session_lifetime_custom_days" in Settings.model_fields def test_qr_login_challenge_ttl_field_exists(self): """Verify qr_login_challenge_ttl_seconds field is defined in Settings.""" from app.config import Settings assert "qr_login_challenge_ttl_seconds" in Settings.model_fields