Search code examples
pythonpython-3.xunit-testingsessioncherrypy

Strange behaviour of cherrypy.session while testing between requests


I'm facing a strange issue while testing a CherryPy app when testing.

Basically, session data is lost between requests while testing, as when running the server and testing manually this does not happen.

The app itself is quite simple, but some resource are protected using the mechanism of hook to be triggered before the request is processed.

Let's see the main file:

import cherrypy
import hashlib
import json
import sys
from bson import json_util

from cr.db.store import global_settings as settings
from cr.db.store import connect

SESSION_KEY = 'user'
main = None


def protect(*args, **kwargs):
    """
    Just a hook for checking protected resources
    :param args:
    :param kwargs:
    :return: 401 if unauthenticated access found (based on session id)
    """
    # Check if provided endpoint requires authentication
    condition = cherrypy.request.config.get('auth.require', None)
    if condition is not None:
        try:
            # Try to get the current session
            cherrypy.session[SESSION_KEY]
            # cherrypy.session.regenerate()
            cherrypy.request.login = cherrypy.session[SESSION_KEY]
        except KeyError:
            raise cherrypy.HTTPError(401, u'Not authorized to access this resource. Please login.')

# Specify the hook
cherrypy.tools.crunch = cherrypy.Tool('before_handler', protect)


class Root(object):

    def __init__(self, db_settings):
        self.db = connect(db_settings)

    @cherrypy.expose
    @cherrypy.config(**{'auth.require': True, 'tools.crunch.on': False})
    def index(self):
        # If authenticated, return to users view
        if SESSION_KEY in cherrypy.session:
            raise cherrypy.HTTPRedirect(u'/users', status=301)
        else:
            return 'Welcome to this site.  Please <a href="/login">login</a>.'


    @cherrypy.tools.allow(methods=['GET', 'POST'])
    @cherrypy.expose
    @cherrypy.config(**{'auth.require': True})
    @cherrypy.tools.json_in()
    def users(self, *args, **kwargs):
        if cherrypy.request.method == 'GET':
            return json.dumps({'users': [u for u in self.db.users.find()]}, default=json_util.default)
        elif cherrypy.request.method == 'POST':
            # Get post form data and create a new user
            input_json = cherrypy.request.json
            new_id = self.db.users.insert_one(input_json)
            new_user = self.db.users.find_one(new_id.inserted_id)
            cherrypy.response.status = 201
            return json.dumps(new_user, default=json_util.default)

    @cherrypy.tools.allow(methods=['GET', 'POST'])
    @cherrypy.expose
    @cherrypy.config(**{'tools.crunch.on': False})
    def login(self, *args, **kwargs):

        if cherrypy.request.method == 'GET':
            # Check if user is logged in already
            if SESSION_KEY in cherrypy.session:
                return """<html>
                  <head></head>
                  <body>
                    <form method="post" action="logout">
                      <label>Click button to logout</label>
                      <button type="submit">Logout</button>
                    </form>
                  </body>
                 </html>"""

            return """<html>
          <head></head>
          <body>
            <form method="post" action="login">
              <input type="text" value="Enter email" name="username" />
              <input type="password" value="Enter password" name="password" />
              <button type="submit">Login</button>
            </form>
          </body>
        </html>"""

        elif cherrypy.request.method == 'POST':
            # Get post form data and create a new user
            if 'password' and 'username' in kwargs:
                user = kwargs['username']
                password = kwargs['password']
                if self.user_verify(user, password):
                    cherrypy.session.regenerate()
                    cherrypy.session[SESSION_KEY] = cherrypy.request.login = user
                    # Redirect to users
                    raise cherrypy.HTTPRedirect(u'/users', status=301)
                else:
                    raise cherrypy.HTTPError(u'401 Unauthorized')
            else:
                raise cherrypy.HTTPError(u'401 Please provide username and password')


    @cherrypy.tools.allow(methods=['GET'])
    @cherrypy.expose
    def logout(self):
        if SESSION_KEY in cherrypy.session:
            cherrypy.session.regenerate()
            return 'Logged out, we will miss you dearly!.'
        else:
            raise cherrypy.HTTPRedirect(u'/', status=301)

    def user_verify(self, username, password):
        """
        Simply checks if a user with provided email and pass exists in db
        :param username: User email
        :param password:  User pass
        :return: True if user found
        """
        users = self.db.users
        user = users.find_one({"email": username})
        if user:
            password = hashlib.sha1(password.encode()).hexdigest()
            return password == user['hash']
        return False

if __name__ == '__main__':
    config_root = {'/': {
        'tools.crunch.on': True,
        'tools.sessions.on': True,
        'tools.sessions.name': 'crunch', }
    }
    # This simply specifies the URL for the Mongo db
    settings.update(json.load(open(sys.argv[1])))
    main = Root(settings)
    cherrypy.quickstart(main, '/', config=config_root)

cr.db is a very simple wrapper over pymongo that exposes the db functionality, nothing special.

As you can see the users view is protected, basically if the SESSION['user'] key is not set, we ask to login.

If I fire up the server and try to access /users directly I'm redirected to /login. Once loged in, visiting /users again works fine, since

cherrypy.session[SESSION_KEY]

Does not throw an KeyError since it was properly set in /login. Everything cool.

Now this is my test file, based on official docs about testing, located at same level as file above.

import urllib
from unittest.mock import patch
import cherrypy
from cherrypy.test import helper
from cherrypy.lib.sessions import RamSession
from .server import Root
from cr.db.store import global_settings as settings
from cr.db.loader import load_data

DB_URL = 'mongodb://localhost:27017/test_db'
SERVER = 'http://127.0.0.1'


class SimpleCPTest(helper.CPWebCase):

    def setup_server():
        cherrypy.config.update({'environment': "test_suite",
                                'tools.sessions.on': True,
                                'tools.sessions.name': 'crunch',
                                'tools.crunch.on': True,
                                })
        db = {
            "url": DB_URL
        }
        settings.update(db)
        main = Root(settings)
        # Simply loads some dummy users into test db
        load_data(settings, True)
        cherrypy.tree.mount(main, '/')
    setup_server = staticmethod(setup_server)

    # HELPER METHODS
    def get_redirect_path(self, data):
        """
        Tries to extract the path from the cookie data obtained in a response
        :param data: The cookie data from the response
        :return: The path if possible, None otherwise
        """
        path = None
        location = None
        # Get the Location from response, if possible
        for tuples in data:
            if tuples[0] == 'Location':
               location = tuples[1]
               break
        if location:
            if SERVER in location:
                index = location.find(SERVER)
                # Path plus equal
                index = index + len(SERVER) + 6
                # Get the actual path
                path = location[index:]
        return path

    def test_login_shown_if_not_logged_in(self):
        response = self.getPage('/')
        self.assertStatus('200 OK') 
        self.assertIn('Welcome to Crunch.  Please <a href="/login">login</a>.', response[2].decode())

    def test_login_redirect_to_users(self):
        # Try to authenticate with a wrong password
        data = {
            'username': '[email protected]',
            'password': 'admin',
        }
        query_string = urllib.parse.urlencode(data)
        self.getPage("/login", method='POST', body=query_string)
        # Login should show 401
        self.assertStatus('401 Unauthorized')
        # Try to authenticate with a correct password
        data = {
            'username': '[email protected]',
            'password': '123456',
        }
        query_string = urllib.parse.urlencode(data)
        # Login should work and be redirected to users
        self.getPage('/login', method='POST', body=query_string)
        self.assertStatus('301 Moved Permanently')

    def test_login_no_credentials_throws_401(self):
        # Login should show 401
        response = self.getPage('/login', method='POST')
        self.assertStatus('401 Please provide username and password')

    def test_login_shows_login_logout_forms(self):
        # Unauthenticated GET should show login form
        response = self.getPage('/login', method='GET')
        self.assertStatus('200 OK')
        self.assertIn('<form method="post" action="login">', response[2].decode())
        # Try to authenticate
        data = {
            'username': '[email protected]',
            'password': '123456',
        }
        query_string = urllib.parse.urlencode(data)
        # Login should work and be redirected to users
        response = self.getPage('/login', method='POST', body=query_string)
        self.assertStatus('301 Moved Permanently')
        # FIXME: Had to mock the session, not sure why between requests while testing the session loses
        # values, this would require more investigation, since when firing up the real server works fine
        # For now let's just mock it
        sess_mock = RamSession()
        sess_mock['user'] = '[email protected]'
        with patch('cherrypy.session', sess_mock, create=True):
            # Make a GET again
            response = self.getPage('/login', method='GET')
            self.assertStatus('200 OK')
            self.assertIn('<form method="post" action="logout">', response[2].decode())

As you can see in last method, after login, we should have cherrpy.session[SESSION_KEY] set, but for some reason the session does not have the key. That's the reason I actually had to mock it...this works, but is hacking something that should actually work...

To me it looks like when testing the session is not being kept between requests. Before digging into CherrPy internals I wanted to ask this in case someone stumbled upon something similar in the past.

Notice I'm using Python 3.4 here.

Thanks


Solution

  • getPage() accepts headers argument and produces self.cookies iterable. But it does not pass it over to the next request automatically, so it doesn't get the same session cookies.

    I've crafted a simple example of how to persist session with the next request:

    >>> test_cp.py <<<

    import cherrypy
    from cherrypy.test import helper
    
    class SimpleCPTest(helper.CPWebCase):
    
        @staticmethod
        def setup_server():
            class Root:
                @cherrypy.expose
                def login(self):
                    if cherrypy.request.method == 'POST':
                        cherrypy.session['test_key'] = 'test_value'
                        return 'Hello'
                    elif cherrypy.request.method in ['GET', 'HEAD']:
                        try:
                            return cherrypy.session['test_key']
                        except KeyError:
                            return 'Oops'
    
            cherrypy.config.update({'environment': "test_suite",
                                    'tools.sessions.on': True,
                                    'tools.sessions.name': 'crunch',
                                    })
            main = Root()
            # Simply loads some dummy users into test db
            cherrypy.tree.mount(main, '')
    
        def test_session_sharing(self):
            # Unauthenticated GET
            response = self.getPage('/login', method='GET')
            self.assertIn('Oops', response[2].decode())
    
            # Authenticate
            response = self.getPage('/login', method='POST')
            self.assertIn('Hello', response[2].decode())
    
            # Make a GET again <<== INCLUDE headers=self.cookies below:
            response = self.getPage('/login', headers=self.cookies, method='GET')
            self.assertIn('test_value', response[2].decode())
    

    Running it

    $ pytest
    Test session starts (platform: linux, Python 3.6.1, pytest 3.0.7, pytest-sugar 0.8.0)
    rootdir: ~/src/test, inifile:
    plugins: sugar-0.8.0, backports.unittest-mock-1.3
    
     test_cp.py ✓✓                                                                                                                                                           100% ██████████
    
    Results (0.41s):
           2 passed
    

    P.S. Of course, ideally I'd inherit testcase class and add additional method to encapsulate this ;)