My application requires login to google for later use of google apis. I have flask-dance, flask-security, flask-sqlalchemy working to the point where I can do the log in and log out in my development system.
What I've been struggling with is testing of the login using pytest. I am trying force the login with the call to flask_security.login_user, but test_login fails as if nobody is logged in. I suspect this is a problem because of context setting, but I have tried a lot of different things and haven't found the magic elixir.
Unfortunately, while I have a lot of experience in software development in general and python in particular, I don't have the pytest / flask-dance / flask-security background needed to solve this.
in settings.py
class Testing():
# default database
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
TESTING = True
WTF_CSRF_ENABLED = False
# need to set SERVER_NAME to something, else get a RuntimeError about not able to create URL adapter
SERVER_NAME = 'dev.localhost'
# need a default secret key - in production replace by config file
SECRET_KEY = "<test secret key>"
# fake credentials
GOOGLE_OAUTH_CLIENT_ID = 'fake-client-id'
GOOGLE_OAUTH_CLIENT_SECRET = 'fake-client-secret'
# need to allow logins in flask-security. see https://github.com/mattupstate/flask-security/issues/259
LOGIN_DISABLED = False
in conftest.py
import pytest
from racesupportcontracts import create_app
from racesupportcontracts.dbmodel import db
from racesupportcontracts.settings import Testing
#pytest.fixture
def app():
app = create_app(Testing)
yield app
#pytest.fixture
def dbapp(app):
db.drop_all()
db.create_all()
yield app
in test_basic.py
def login_test_user(email):
from racesupportcontracts.dbmodel import db, User
from flask_security import login_user
user = User.query.filter_by(email=email).one()
login_user(user)
db.session.commit()
def test_login(dbapp):
app = dbapp
from racesupportcontracts.dbmodel import db, init_db
from racesupportcontracts import user_datastore
from flask import url_for
# init_db should create at least superadmin, admin roles
init_db(defineowner=False)
useremail = 'testuser#example.com'
with app.test_client() as client:
create_user(useremail, 'superadmin')
login_test_user(useremail)
resp = client.get('/', follow_redirects=True)
assert resp.status_code == 200
assert url_for('admin.logout') in resp.data
When you call login_user(), that modifies the flask.session object. However, when using the test client, you can only modify flask.session inside of a session transaction. It should work if you do this:
with app.test_client() as client:
with client.session_transaction() as sess:
sess["user_id"] = 1 # if you want user 1 to be logged in for this test
resp = client.get('/', follow_redirects=True)
# make whatever assertions you want
If you install the latest version of Flask-Login from GitHub, you can also use the FlaskLoginClient class to make this more readable:
# in conftest.py
from flask_login import FlaskLoginClient
#pytest.fixture
def app():
app = create_app(Testing)
app.test_client_class = FlaskLoginClient
yield app
# in test_basic.py
def test_login(app):
user = User.query.filter_by(email='testuser#example.com').one()
with app.test_client(user=user) as client:
resp = client.get('/', follow_redirects=True)
# make whatever assertions you want
Unfortunately, the author of Flask-Login refuses to publish an update of the package to PyPI, so you can't use the version of Flask-Login that is on PyPI, you have to install from GitHub. (I have no idea why he refuses to publish an update.)
Related
I am trying to use pytest to unit test my Flask app. I have the following test case for an endpoint that requires information from flask_login's current_user:
def test_approval_logic():
with app.test_client() as test_client:
app_url_put = '/requests/process/2222'
with app.app_context():
user = User.query.filter_by(uid='xxxxxxx').first()
with app.test_request_context():
login_user(user)
user.authenticated = True
db.session.add(user)
data = dict(
state='EXAMPLE_STATE_NAME',
action='approve'
)
resp = test_client.put(app_url_put, data=data)
assert resp.status_code == 200
Inside the test_request_context, I am able to set current_user correctly. However, this test fails because in the requests view where the PUT is handled, there is no logged in user and 500 error results. The error message is, AttributeError: 'AnonymousUserMixin' object has no attribute 'email'. Can someone explain why current_user goes away and how I can set it correctly?
My guess is that no session cookie is passed in your PUT request.
Here is an example of how I log a user during my tests (I personally user unittest instead of pytest, so I reduced the code to the strict minimum, but let me know if you want a complete example with unittest)
from whereyourappisdefined import application
from models import User
from flask_login import login_user
# Specific route to log an user during tests
#application.route('/auto_login/<user_id>')
def auto_login(user_id):
user = User.query.filter(User.id == user_id).first()
login_user(user)
return "ok"
def yourtest():
application.config['TESTING'] = True # see my side note
test_client = application.test_client()
response = test_client.get(f"/auto_login/1")
app_url_put = '/requests/process/2222'
data = dict(
state='EXAMPLE_STATE_NAME',
action='approve'
)
r = test_client.put(app_url_put, data=data)
In the documentation we can read:
https://werkzeug.palletsprojects.com/en/2.0.x/test/#werkzeug.test.Client
The use_cookies parameter indicates whether cookies should be stored
and sent for subsequent requests. This is True by default but passing
False will disable this behavior.
So during the first request GET /auto_login/1 the application will receive a session cookie and keep it for further HTTP requests.
Side note:
Enable TESTING in your application (https://flask.palletsprojects.com/en/2.0.x/testing/)
During setup, the TESTING config flag is activated. What this does is
disable error catching during request handling so that you get better
error reports when performing test requests against the application.
Using test client to dispatch a request
The current session is not bound to test_client, so the request uses a new session.
Set the session cookie on the client, so Flask can load the same session for the request:
from flask import session
def set_session_cookie(client):
val = app.session_interface.get_signing_serializer(app).dumps(dict(session))
client.set_cookie('localhost', app.session_cookie_name, val)
Usage:
# with app.test_client() as test_client: # Change these
# with app.app_context(): #
# with app.test_request_context(): #
with app.test_request_context(), app.test_client() as test_client: # to this
login_user(user)
user.authenticated = True
db.session.add(user)
data = dict(
state='EXAMPLE_STATE_NAME',
action='approve'
)
set_session_cookie(test_client) # Add this
resp = test_client.put(app_url_put, data=data)
About the compatibility of with app.test_request_context()
i. with app.test_client()
with app.test_client() preserves the context of requests (Flask doc: Keeping the Context Around), so you would get this error when exiting an inner with app.test_request_context():
AssertionError: Popped wrong request context. (<RequestContext 'http://localhost/requests/process/2222' [PUT] of app> instead of <RequestContext 'http://localhost/' [GET] of app>)
Instead, enter app.test_request_context() before app.test_client() as shown above.
ii. with app.app_context()
with app.test_request_context() already pushes an app context, so with app.app_context() is unnecessary.
Using test request context without dispatching a request
From https://flask.palletsprojects.com/en/2.0.x/api/#flask.Flask.test_request_context:
This is mostly useful during testing, where you may want to run a function that uses request data without dispatching a full request.
Usage:
data = dict(
state='EXAMPLE_STATE_NAME',
action='approve'
)
with app.test_request_context(data=data): # Pass data here
login_user(user)
user.authenticated = True
db.session.add(user)
requests_process(2222) # Call function for '/requests/process/2222' directly
Here's how I do it on my sites:
user = User.query.filter_by(user_id='xxxxxxx').one_or_none()
if user:
user.authenticated = True
db.session.add(user)
db.session.commit()
login_user(user)
else:
# here I redirect to an unauthorized page, as the user wasn't found
I don't know the order is the issue or just the absence of db.session.commit(), but I think you need to have done both in order for your put request to work.
Note, also, that I am using a one_or_none() because there shouldn't be a possibility of multiple users with the same user_id, just a True or False depending on whether a user was found or not.
Simply saying, I am developing an app using Flask. For this app I am trying to implement a Single Sign-On, so a user never needs to enter credentials, e.g. username and password.
The authentication and authorization in this case will go through Kerberos together with LDAPS. The Kerberos part is not done yet, however, Kerberos is intended to get a "username" via a middleware currently logged into a system (Windows), when requesting an app's link. Afterwards this variable i.e. "username" will be proceeded with LDAPS, to check whether a user belongs to Active Directory or not. If yes - provide access and permission to a web site, if no - forbid.
However, since my user wont type anything, I do not understand whether I need to use either the Flask Form (flask-wtf) or the Flask Login (flask-login e.g. UserMixin) as well as how shall I provide an access to my user?
I was able to set up the FlaskLDAP3Login in 'config.py' and than run the '__init__.py'
from flask import Flask
from config import Config
from flask_login import LoginManager
from flask_ldap3_login import LDAP3LoginManager
app = Flask(__name__)
app.config.from_object(Config)
login_manager = LoginManager(app) # Setup a Flask-Login Manager
ldap_manager = LDAP3LoginManager(app) # Setup a LDAP3 Login Manager
from app import routes
than I got the following exception:
Exception: Missing user_loader or request_loader. Refer to
http://flask-login.readthedocs.io/#how-it-works for more info.
than I found this answer, but using this decorator #login_manager.user_loader is probably not enough, is not it?
My assumption is to create a decorator, similar to this one that will allow/forbid an access to a user:
import getpass
from flask import Flask, request, make_response
from functools import wraps
def auth_required(f):
#wraps(f)
def decorated(*args, **kwargs):
current_user = getpass.getuser() # current_user will be later acquired via Kerberos
auth = ldap_manager.get_user_info_for_username(current_user)
if auth:
return f(*args, **kwargs)
return make_response('Could not verify your login!', 401, {'WWW-Authenticate': 'Basic realm="You are not our user!"'})
return decorated
Also, I cannot find a similar or even related thread e.g.
github/flask-ldap3-login/flask_ldap3_login/forms.py
Authenticate with Flask-LDAP3-Login based on group membership
Flask Authentication With LDAP
Integrate LDAP Authentication with Flask
The Flask Mega-Tutorial Part V: User Logins
NOTE: This issue only appears to happen when I am running Flask with WSGI on Apache. When I run it via flask run --host=0.0.0.0 I get no issues whatsoever.
I realize that there are many other questions with a similar issue, and I have tried applying the different recommendations, in particular the following:
setting:
login_user(form.user, remember=True, force=True) # Tell flask-login to log them in.
session.permanent = True
app.permanent_session_lifetime = timedelta(seconds=3600)
session.modified = True
As well as: app.config["REMEMBER_COOKIE_DURATION"] = timedelta(seconds=3600).
I am running Flask on Apache 2.4 with Python 3.6.
Here's the code for my app:
import sys
import os
import os.path
import ssl
import json
from datetime import datetime, timedelta
from flask import Flask, make_response, url_for, render_template, jsonify, redirect, request, session
from flask_ldap3_login import LDAP3LoginManager
from flask_login import LoginManager, login_user, logout_user, UserMixin, current_user
from flask_security import login_required
from flask_session import Session
from flask_ldap3_login.forms import LDAPLoginForm
from ldap3 import Tls
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret'
app.config['DEBUG'] = True
# Hostname of your LDAP Server
app.config['LDAP_HOST'] = 'dc.website.com'
# Port number of your LDAP server
app.config['LDAP_PORT'] = 636
# Specify the server connection should use SSL
app.config['LDAP_USE_SSL'] = True
# Base DN of your directory
app.config['LDAP_BASE_DN'] = 'CN=Users,DC=website,dc=com'
# Users DN to be prepended to the Base DN
app.config['LDAP_USER_DN'] = ''
# Groups DN to be prepended to the Base DN
app.config['LDAP_GROUP_DN'] = ''
# The RDN attribute for your user schema on LDAP
app.config['LDAP_USER_RDN_ATTR'] = 'cn'
# The Attribute you want users to authenticate to LDAP with.
app.config['LDAP_USER_LOGIN_ATTR'] = 'sAMAccountName'
# The Username to bind to LDAP with
app.config['LDAP_BIND_USER_DN'] = 'CN=LDAP Read-only,CN=Users,DC=website,dc=com'
# The Password to bind to LDAP with
app.config['LDAP_BIND_USER_PASSWORD'] = 'password'
app.config["REMEMBER_COOKIE_DURATION"] = timedelta(seconds=3600)
login_manager = LoginManager(app) # Setup a Flask-Login Manager
ldap_manager = LDAP3LoginManager(app) # Setup a LDAP3 Login Manager.
# Initialize a `Tls` context, and add the server manually. See
# http://ldap3.readthedocs.io/ssltls.html for more information.
tls_ctx = Tls(
validate=ssl.CERT_REQUIRED,
version='ssl.PROTOCOL_TLSv1.3',
ca_certs_file='/usr/local/share/ca-certificates/cert.crt',
valid_names=[
'dc.website.com',
]
)
ldap_manager.add_server(
app.config.get('LDAP_HOST'),
app.config.get('LDAP_PORT'),
app.config.get('LDAP_USE_SSL'),
tls_ctx=tls_ctx
)
# Create a dictionary to store the users in when they authenticate
# This example stores users in memory.
users = {}
# Declare an Object Model for the user, and make it comply with the
# flask-login UserMixin mixin.
class User(UserMixin):
def __init__(self, dn, username, data):
self.dn = dn
self.username = username
self.data = data
def __repr__(self):
return self.dn
def get_id(self):
return self.dn
# Declare a User Loader for Flask-Login.
# Simply returns the User if it exists in our 'database', otherwise
# returns None.
#login_manager.user_loader
def load_user(id):
if id in users:
return users[id]
return None
# Declare The User Saver for Flask-Ldap3-Login
# This method is called whenever a LDAPLoginForm() successfully validates.
# Here you have to save the user, and return it so it can be used in the
# login controller.
#ldap_manager.save_user
def save_user(dn, username, data, memberships):
user = User(dn, username, data)
users[dn] = user
return user
# Declare some routes for usage to show the authentication process.
#app.route('/')
def home():
# Redirect users who are not logged in.
if not current_user or current_user.is_anonymous:
return redirect(url_for('login'))
return render_template('index.html')
#app.route('/login', methods=['GET', 'POST'])
def login():
# Instantiate a LDAPLoginForm which has a validator to check if the user
# exists in LDAP.
form = LDAPLoginForm()
if form.validate_on_submit():
# Successfully logged in, We can now access the saved user object
# via form.user.
login_user(form.user, remember=True, force=True) # Tell flask-login to log them in.
session.permanent = True
app.permanent_session_lifetime = timedelta(seconds=3600)
session.modified = True
return redirect('/') # Send them home
return render_template('login.html', form=form, current_user=current_user)
#app.route('/logout')
def logout():
logout_user()
return redirect(url_for('login'))
if __name__ == '__main__':
app.run()
And here's the WSGI file:
#!/usr/bin/python3.6
import logging
import sys
logging.basicConfig(stream=sys.stderr)
sys.path.insert(0, '/opt/flaskapp/')
from flaskapp import app as application
application.secret_key = 'secret'
Sometimes the user gets logged out after a few seconds, other times the session can last an hour. When I check the browser's memory (in Firefox, Chrome and Edge) the session cookies are still there.
Am I doing something wrong? I've also tried checking whether or not the users array becomes empty. It does. Even if I make some kind of a try check before the line users = {}.
The issue was with the WSGI file, as it is Apache that handles the sessions.
The line app.permanent_session_lifetime = timedelta(seconds=3600) from the application code should actually be in the WSGI file, so that it should look like this:
#!/usr/bin/python3.6
import logging
import sys
from datetime import datetime, timedelta
logging.basicConfig(stream=sys.stderr)
sys.path.insert(0, '/opt/flaskapp/')
from flaskapp import app as application
application.secret_key = 'secret'
application.permanent_session_lifetime = timedelta(seconds=3600)
I'm trying to write unit tests for a Flask app using pytest. I have an app factory:
def create_app():
from flask import Flask
app = Flask(__name__)
app.config.from_object('config')
import os
app.secret_key = os.urandom(24)
from models import db
db.init_app(app)
return app
And a test class:
class TestViews(object):
#classmethod
def setup_class(cls):
cls.app = create_app()
cls.app.testing = True
cls.client = cls.app.test_client()
#classmethod
def teardown_class(cls):
cls.app_context.pop()
def test_create_user(self):
"""
Tests the creation of a new user.
"""
view = TestViews.client.get(url_for('create_users')).status_code == 200
but when I run my tests I get the following error:
RuntimeError: Attempted to generate a URL without the application context being pushed. This has to be executed when application context is available.
Googling this tells me (I think) that using the test client should create an automatic application context. What am I missing?
Making requests with the test client does indeed push an app context (indirectly). However, you're confusing the fact that url_for is visually inside the test request call with the idea that it is actually called inside. The url_for call is evaluated first, the result is passed to client.get.
url_for is typically for generating URLs within the app, unit tests are external. Typically, you just write exactly the URL you're trying to test in the request instead of generating it.
self.client.get('/users/create')
If you really want to use url_for here, you must do it in an app context. Note that when you're in an app context but not a request context, you must set the SERVER_NAME config and also pass _external=False. But again, you should probably just write out the URL you're trying to test.
app.config['SERVER_NAME'] = 'localhost'
with self.app.app_context():
url = url_for(..., _external=False)
self.client.get(url, ...)
You can call url_for() in test request context that created with app.test_request_context() method. There are three methods to achieve this.
With setup and teardown
Since you have created the setup and teardown method, just like what I normally do with unittest, you can just push a test request context in setup method then pop it in teardown method:
class TestViews(object):
#classmethod
def setup_class(cls):
cls.app = create_app()
cls.app.testing = True
cls.client = cls.app.test_client()
cls.context = cls.app.test_request_context() # create the context object
cls.context.push() # push the context
#classmethod
def teardown_class(cls):
cls.context.pop() # pop the context
def test_create_user(self):
"""
Tests the creation of a new user.
"""
view = TestViews.client.get(url_for('create_users')).status_code == 200
With pytest-flask
Besides, you can also just use pytest-flask. With pytest-flask, you can access to context bound objects (url_for, request, session) without context managers:
def test_app(client):
assert client.get(url_for('myview')).status_code == 200
With autouse fixture
If you don't want to install the plugin, you can just use the following fixtures to do similar things (stolen from the source of pytest-flask):
#pytest.fixture
def app():
app = create_app('testing')
return app
#pytest.fixture(autouse=True)
def _push_request_context(request, app):
ctx = app.test_request_context() # create context
ctx.push() # push
def teardown():
ctx.pop() # pop
request.addfinalizer(teardown) # set teardown
I don't know how to correctly use testing app version while unittesting (with pytest) flask cli command (with click) decorated with with_app_context decorator. This decorator replaces pytest fixture app with the "normal", development application. I use app factory pattern.
My command in a simplified version looks like this (mind #with_appcontext):
#click.command()
#click.option('--username', prompt='Username')
#click.option('--email', prompt='User E-Mail')
#click.option('--password', prompt='Password', confirmation_prompt=True, hide_input=True)
#with_appcontext # from flask.cli import with_appcontext
def createsuperuser(username, email, password):
user = User(
username=username,
email=email,
password=password,
active=True,
is_admin=True,
)
user.save()
Without #with_appcontext unittests work just fine (they get the app injected by pytest), but the command itself does not, as it needs an app context.
My extracted pytest code:
# pytest fixtures
#pytest.yield_fixture(scope='function')
def app():
"""An application for the tests."""
_app = create_app(TestConfig)
ctx = _app.test_request_context()
ctx.push()
yield _app
ctx.pop()
#pytest.yield_fixture(scope='function')
def db(app):
"""A database for the tests."""
_db.app = app
with app.app_context():
_db.create_all()
yield _db
# Explicitly close DB connection
_db.session.close()
_db.drop_all()
#pytest.mark.usefixtures('db')
class TestCreateSuperUser:
# db fixture uses app fixture, works the same if app was injected here as well
def test_user_is_created(self, cli_runner, db):
result = cli_runner.invoke(
createsuperuser,
input='johnytheuser\nemail#email.com\nsecretpass\nsecretpass'
)
assert result.exit_code == 0
assert 'SUCCESS' in result.output
# etc.
All my tests using app and db fixtures work just fine apart from these decorated ones. I'm not sure how I should workaround this with_appcontext decorator that sets the app itself.
Thank you in advance for any hint.
Inspiration taken from https://github.com/pallets/flask/blob/master/tests/test_cli.py#L254.
from flask.cli import ScriptInfo
#pytest.fixture
def script_info(app):
return ScriptInfo(create_app=lambda info: app)
In your test:
def test_user_is_created(self, cli_runner, db, script_info):
result = cli_runner.invoke(
createsuperuser,
input='johnytheuser\nemail#email.com\nsecretpass\nsecretpass',
obj=obj,
)
The accepted answer helped me figure out a solution, but did not work out of the box. Here is what worked for me in case anyone else has a similar issue
#pytest.fixture(scope='session')
def script_info(app):
return ScriptInfo(create_app=lambda: app)
def test_user_is_created(self, cli_runner, db, script_info):
result = cli_runner.invoke(
createsuperuser,
input='johnytheuser\nemail#email.com\nsecretpass\nsecretpass',
obj=script_info,
)
I had the same problem testing one of my flask commands. Although your approach works, I think it is valuable to have a look at the flask documentation here:
https://flask.palletsprojects.com/en/1.1.x/testing/#testing-cli-commands
Flask has its own test runner for cli commands that probably has a fix to our problem builtin. So instead of patching the create_app function with a lambda you could also just use app.test_cli_runner() and it works out of the box.