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': 'john@doe.com',
'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': 'john@doe.com',
'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': 'john@doe.com',
'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'] = 'john@doe.com'
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
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:
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())
$ 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 ;)