""" Tests for terminal session management. Tests cover: - Session reuse for same user/host/mode - Session limit with rich error response - Idempotent close - Heartbeat updates last_seen_at - GC cleans up expired/idle sessions - Close beacon endpoint """ import pytest from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch from app.models.terminal_session import ( TerminalSession, SESSION_STATUS_ACTIVE, SESSION_STATUS_CLOSED, SESSION_STATUS_EXPIRED, CLOSE_REASON_USER, CLOSE_REASON_TTL, CLOSE_REASON_IDLE, CLOSE_REASON_CLIENT_LOST, ) from app.crud.terminal_session import TerminalSessionRepository from app.schemas.terminal import ( SessionLimitError, HeartbeatResponse, ActiveSessionInfo, ) # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def mock_session(): """Create a mock terminal session.""" now = datetime.now(timezone.utc) return TerminalSession( id="test-session-123", host_id="host-1", host_name="test-host", host_ip="192.168.1.100", user_id="user-1", username="testuser", token_hash="hash123", ttyd_port=7680, ttyd_pid=12345, mode="embedded", status=SESSION_STATUS_ACTIVE, created_at=now, last_seen_at=now, expires_at=now + timedelta(minutes=30), ) @pytest.fixture def mock_db_session(): """Create a mock database session.""" session = AsyncMock() session.execute = AsyncMock() session.flush = AsyncMock() session.commit = AsyncMock() return session # ============================================================================ # Session Reuse Tests # ============================================================================ class TestSessionReuse: """Tests for session reuse functionality.""" @pytest.mark.asyncio async def test_find_reusable_session_returns_matching_session(self, mock_db_session, mock_session): """Should find and return an existing active session for same user/host/mode.""" # Setup mock to return the session mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_session mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) result = await repo.find_reusable_session( user_id="user-1", host_id="host-1", mode="embedded", idle_timeout_seconds=120 ) assert result is not None assert result.id == mock_session.id assert result.host_id == "host-1" assert result.user_id == "user-1" @pytest.mark.asyncio async def test_find_reusable_session_returns_none_for_different_mode(self, mock_db_session): """Should not find session if mode is different.""" mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = None mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) result = await repo.find_reusable_session( user_id="user-1", host_id="host-1", mode="popout", # Different mode idle_timeout_seconds=120 ) assert result is None @pytest.mark.asyncio async def test_find_reusable_session_excludes_idle_sessions(self, mock_db_session): """Should not return sessions that are idle (last_seen too old).""" mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = None mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) # With a very short idle timeout, session should be considered idle result = await repo.find_reusable_session( user_id="user-1", host_id="host-1", mode="embedded", idle_timeout_seconds=1 # Very short timeout ) assert result is None # ============================================================================ # Session Limit Tests # ============================================================================ class TestSessionLimit: """Tests for session limit handling.""" def test_session_limit_error_schema(self): """SessionLimitError schema should contain all required fields.""" error = SessionLimitError( message="Maximum sessions reached", max_active=3, current_count=3, active_sessions=[ ActiveSessionInfo( session_id="sess-1", host_id="host-1", host_name="test-host", mode="embedded", age_seconds=300, last_seen_seconds=10, ) ], suggested_actions=["close_oldest", "close_session:sess-1"], can_reuse=False, ) assert error.error == "SESSION_LIMIT" assert error.max_active == 3 assert error.current_count == 3 assert len(error.active_sessions) == 1 assert "close_oldest" in error.suggested_actions def test_session_limit_error_with_reusable_session(self): """SessionLimitError should indicate when a session can be reused.""" error = SessionLimitError( message="Maximum sessions reached", max_active=3, current_count=3, active_sessions=[], suggested_actions=["reuse_existing", "close_oldest"], can_reuse=True, reusable_session_id="sess-reusable", ) assert error.can_reuse is True assert error.reusable_session_id == "sess-reusable" assert "reuse_existing" in error.suggested_actions # ============================================================================ # Idempotent Close Tests # ============================================================================ class TestIdempotentClose: """Tests for idempotent session close.""" @pytest.mark.asyncio async def test_close_already_closed_session(self, mock_db_session, mock_session): """Closing an already closed session should succeed (idempotent).""" mock_session.status = SESSION_STATUS_CLOSED mock_session.closed_at = datetime.now(timezone.utc) mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_session mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) # Close should not raise, even if already closed result = await repo.close_session(mock_session.id) # Status should remain closed assert result.status == SESSION_STATUS_CLOSED @pytest.mark.asyncio async def test_close_nonexistent_session(self, mock_db_session): """Closing a nonexistent session should return None.""" mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = None mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) result = await repo.close_session("nonexistent-session") assert result is None # ============================================================================ # Heartbeat Tests # ============================================================================ class TestHeartbeat: """Tests for heartbeat functionality.""" @pytest.mark.asyncio async def test_heartbeat_updates_last_seen(self, mock_db_session, mock_session): """Heartbeat should update last_seen_at timestamp.""" original_last_seen = mock_session.last_seen_at mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_session mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) result = await repo.update_last_seen(mock_session.id) assert result is not None assert result.last_seen_at >= original_last_seen @pytest.mark.asyncio async def test_heartbeat_on_closed_session_does_nothing(self, mock_db_session, mock_session): """Heartbeat on closed session should not update last_seen.""" mock_session.status = SESSION_STATUS_CLOSED original_last_seen = mock_session.last_seen_at mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_session mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) result = await repo.update_last_seen(mock_session.id) # last_seen should not be updated for closed sessions assert result.last_seen_at == original_last_seen def test_heartbeat_response_schema(self): """HeartbeatResponse schema should contain all required fields.""" response = HeartbeatResponse( session_id="test-session", status=SESSION_STATUS_ACTIVE, last_seen_at=datetime.now(timezone.utc), remaining_seconds=1500, healthy=True, ) assert response.session_id == "test-session" assert response.status == SESSION_STATUS_ACTIVE assert response.healthy is True assert response.remaining_seconds == 1500 # ============================================================================ # GC (Garbage Collection) Tests # ============================================================================ class TestGarbageCollection: """Tests for session garbage collection.""" @pytest.mark.asyncio async def test_list_expired_sessions(self, mock_db_session): """Should list sessions past their TTL.""" now = datetime.now(timezone.utc) expired_session = TerminalSession( id="expired-1", host_id="host-1", host_name="test-host", host_ip="192.168.1.100", user_id="user-1", token_hash="hash", ttyd_port=7680, mode="embedded", status=SESSION_STATUS_ACTIVE, created_at=now - timedelta(hours=1), last_seen_at=now - timedelta(minutes=5), expires_at=now - timedelta(minutes=10), # Expired ) mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [expired_session] mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) expired = await repo.list_expired() assert len(expired) == 1 assert expired[0].id == "expired-1" @pytest.mark.asyncio async def test_list_idle_sessions(self, mock_db_session): """Should list sessions without recent heartbeat.""" now = datetime.now(timezone.utc) idle_session = TerminalSession( id="idle-1", host_id="host-1", host_name="test-host", host_ip="192.168.1.100", user_id="user-1", token_hash="hash", ttyd_port=7680, mode="embedded", status=SESSION_STATUS_ACTIVE, created_at=now - timedelta(hours=1), last_seen_at=now - timedelta(minutes=5), # Idle for 5 minutes expires_at=now + timedelta(minutes=25), # Not expired ) mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [idle_session] mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) # With 120 second idle timeout, 5 minutes idle should be detected idle = await repo.list_idle(idle_timeout_seconds=120) assert len(idle) == 1 assert idle[0].id == "idle-1" @pytest.mark.asyncio async def test_list_stale_sessions_combines_expired_and_idle(self, mock_db_session): """Should list both expired and idle sessions.""" now = datetime.now(timezone.utc) mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [] mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) stale = await repo.list_stale_sessions( ttl_seconds=1800, idle_timeout_seconds=120 ) # Query should be executed mock_db_session.execute.assert_called() @pytest.mark.asyncio async def test_mark_expired_sets_correct_reason(self, mock_db_session, mock_session): """mark_expired should set reason_closed to TTL.""" mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_session mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) result = await repo.mark_expired(mock_session.id) assert result.status == SESSION_STATUS_EXPIRED assert result.reason_closed == CLOSE_REASON_TTL assert result.closed_at is not None @pytest.mark.asyncio async def test_mark_idle_sets_correct_reason(self, mock_db_session, mock_session): """mark_idle should set reason_closed to IDLE.""" mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_session mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) result = await repo.mark_idle(mock_session.id) assert result.status == SESSION_STATUS_EXPIRED assert result.reason_closed == CLOSE_REASON_IDLE assert result.closed_at is not None # ============================================================================ # Close Beacon Tests # ============================================================================ class TestCloseBeacon: """Tests for close beacon endpoint.""" @pytest.mark.asyncio async def test_mark_client_lost_sets_correct_reason(self, mock_db_session, mock_session): """mark_client_lost should set reason_closed to CLIENT_LOST.""" mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_session mock_db_session.execute.return_value = mock_result repo = TerminalSessionRepository(mock_db_session) result = await repo.mark_client_lost(mock_session.id) assert result.status == SESSION_STATUS_CLOSED assert result.reason_closed == CLOSE_REASON_CLIENT_LOST assert result.closed_at is not None # ============================================================================ # Terminal Service Tests # ============================================================================ class TestTerminalService: """Tests for terminal service functionality.""" def test_metrics_tracking(self): """Service should track metrics for observability.""" from app.services.terminal_service import TerminalService service = TerminalService() # Record some metrics service.record_session_created(reused=False) service.record_session_created(reused=True) service.record_session_limit_hit() metrics = service.get_metrics() assert metrics["sessions_created"] == 1 assert metrics["sessions_reused"] == 1 assert metrics["session_limit_hits"] == 1 assert "active_processes" in metrics assert "gc_running" in metrics def test_token_generation_and_verification(self): """Service should generate and verify tokens correctly.""" from app.services.terminal_service import TerminalService service = TerminalService() token, token_hash = service.generate_session_token() assert token is not None assert token_hash is not None assert len(token) > 32 assert len(token_hash) == 64 # SHA256 hex # Verify correct token assert service.verify_token(token, token_hash) is True # Verify incorrect token assert service.verify_token("wrong-token", token_hash) is False def test_session_id_generation(self): """Service should generate unique session IDs.""" from app.services.terminal_service import TerminalService service = TerminalService() id1 = service.generate_session_id() id2 = service.generate_session_id() assert id1 != id2 assert len(id1) == 64 # 32 bytes hex def test_to_utc_aware_handles_naive_datetime(self): """GC should not crash when SQLite returns naive datetimes.""" from app.services.terminal_service import TerminalService service = TerminalService() naive = datetime(2025, 1, 1, 12, 0, 0) aware = service._to_utc_aware(naive) assert aware is not None assert aware.tzinfo is not None assert aware.utcoffset() == timedelta(0) @pytest.mark.asyncio async def test_gc_cycle_expired_comparison_with_naive_expires_at(self, mock_db_session): """_run_gc_cycle should not raise when expires_at is naive.""" from app.services.terminal_service import TerminalService from app.crud import terminal_session as terminal_session_module service = TerminalService() now = datetime.now(timezone.utc) session = TerminalSession( id="naive-exp-1", host_id="host-1", host_name="test-host", host_ip="192.168.1.100", user_id="user-1", token_hash="hash", ttyd_port=7680, mode="embedded", status=SESSION_STATUS_ACTIVE, created_at=now - timedelta(minutes=10), last_seen_at=now - timedelta(minutes=1), expires_at=datetime.now() - timedelta(minutes=1), ) # Fake repo that returns a session with naive expires_at fake_repo = AsyncMock() fake_repo.list_stale_sessions.return_value = [session] fake_repo.mark_expired.return_value = session fake_repo.mark_idle.return_value = session # Fake async session factory class _Factory: async def __aenter__(self_inner): return mock_db_session async def __aexit__(self_inner, exc_type, exc, tb): return False service._db_session_factory = lambda: _Factory() with patch.object(terminal_session_module, "TerminalSessionRepository", return_value=fake_repo): service.terminate_session = AsyncMock() service.release_port = AsyncMock() await service._run_gc_cycle() # ============================================================================ # Model Tests # ============================================================================ class TestTerminalSessionModel: """Tests for TerminalSession model.""" def test_status_constants(self): """Status constants should be defined correctly.""" assert SESSION_STATUS_ACTIVE == "active" assert SESSION_STATUS_CLOSED == "closed" assert SESSION_STATUS_EXPIRED == "expired" def test_close_reason_constants(self): """Close reason constants should be defined correctly.""" assert CLOSE_REASON_USER == "user_close" assert CLOSE_REASON_TTL == "ttl" assert CLOSE_REASON_IDLE == "idle" assert CLOSE_REASON_CLIENT_LOST == "client_lost" def test_session_repr(self, mock_session): """Session repr should be informative.""" repr_str = repr(mock_session) assert "TerminalSession" in repr_str assert mock_session.host_name in repr_str assert mock_session.status in repr_str