Objective
I am using the werkzeug.test.Client to run tests on my flask application without having to run a server.
I would like to test that logged in users can access certain resources and not-logged in users cannot.
Procedure
I make a client called test_client with test_client=Client(app) and then log it in using the flask_security_too /login route.
I make another client called "mal_client" with mal_client=Client(app), and don't log it in, but when I use it to access a route protected with @login_required, it lets me when it shouldn't.
I can verify that there are no request headers other than Host set on mal's request (no cookies from test_client being sent), so I am not sure why the request is getting through.
Reproducing
I made a super-simple flask app that demonstrates the issue. Since it's difficult to pdb into a @login_required decorator, I instead made a route called is_logged_in that just returns current_user.is_authenticated (what @login_required is supposed to reference anyway). That shows that mal_client is somehow logged in even when it's not sending any session cookies.
Most of the demonstration app is adapted from this Flask-Security-Too Quick Start.
is_logged_in.py is my is_logged_in route, and logging_hooks.py is what I use to inspect the headers received and sent. Other than that, most of the main logic is in _user_test_app.py, which is the "main" file that is run with python3 ./user_test_app.py
user_test_app.py
#!/usr/bin/python3
from flask_security import current_user, auth_required, hash_password
from flask import render_template_string
from database import db_session, init_db
from is_logged_in import is_logged_in
from werkzeug.test import Client
from logging_hooks import *
from app import app
import os
def run_tests(app):
app.config['WTF_CSRF_ENABLED'] = False
test_client=login_test_client()
mal=assure_mal_not_logged_in()
def assure_mal_not_logged_in():
mal_client=Client(app)
response=mal_client.get('/is_logged_in')
assert response.status_code == 200
assert response.json==False
return mal_client
def login_test_client():
test_client=Client(app)
result=test_client.post('/login', json={
'next': '/',
'email': '[email protected]',
'password' : 'password',
'remember': 'y',
'submit': 'Login',
})
assert result.status_code == 200
response=test_client.get('/is_logged_in')
assert response.status_code == 200
assert response.json==True
return test_client
if __name__ == '__main__':
with app.app_context():
# Create a user to test with
init_db()
if not app.security.datastore.find_user(email="[email protected]"):
app.security.datastore.create_user(email="[email protected]", password=hash_password("password"))
db_session.commit()
run_tests(app)
#app.run(host='0.0.0.0', port=9000)
is_logged_in.py
from flask_security import current_user, login_required
from app import app
import flask
@app.route('/is_logged_in')
def is_logged_in():
is_logged_in=current_user.is_authenticated
return flask.jsonify(is_logged_in)
app.py
#!/usr/bin/python3
import os
from flask import Flask
from flask_security import Security, SQLAlchemySessionUserDatastore
from database import db_session
from models import User, Role
# Create app
app = Flask(__name__)
app.config['DEBUG'] = True
# Generate a nice key using secrets.token_urlsafe()
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw')
# Bcrypt is set as default SECURITY_PASSWORD_HASH, which requires a salt
# Generate a good salt using: secrets.SystemRandom().getrandbits(128)
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '146585145368132386173505678016728509634')
# Setup Flask-Security
user_datastore = SQLAlchemySessionUserDatastore(db_session, User, Role)
app.security = Security(app, user_datastore)
database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.ext.declarative import declarative_base
engine = create_engine('sqlite:////tmp/test.db')
db_session = scoped_session(sessionmaker(autocommit=False,
autoflush=False,
bind=engine))
Base = declarative_base()
Base.query = db_session.query_property()
def init_db():
# import all modules here that might define models so that
# they will be registered properly on the metadata. Otherwise
# you will have to import them first before calling init_db()
import models
Base.metadata.create_all(bind=engine)
models.py
from database import Base
from flask_security import UserMixin, RoleMixin
from sqlalchemy import create_engine
from sqlalchemy.orm import relationship, backref
from sqlalchemy import Boolean, DateTime, Column, Integer, \
String, ForeignKey, UnicodeText
class RolesUsers(Base):
__tablename__ = 'roles_users'
id = Column(Integer(), primary_key=True)
user_id = Column('user_id', Integer(), ForeignKey('user.id'))
role_id = Column('role_id', Integer(), ForeignKey('role.id'))
class Role(Base, RoleMixin):
__tablename__ = 'role'
id = Column(Integer(), primary_key=True)
name = Column(String(80), unique=True)
description = Column(String(255))
permissions = Column(UnicodeText)
class User(Base, UserMixin):
__tablename__ = 'user'
id = Column(Integer, primary_key=True)
email = Column(String(255), unique=True)
username = Column(String(255), unique=True, nullable=True)
password = Column(String(255), nullable=False)
last_login_at = Column(DateTime())
current_login_at = Column(DateTime())
last_login_ip = Column(String(100))
current_login_ip = Column(String(100))
login_count = Column(Integer)
active = Column(Boolean())
fs_uniquifier = Column(String(255), unique=True, nullable=False)
confirmed_at = Column(DateTime())
roles = relationship('Role', secondary='roles_users',
backref=backref('users', lazy='dynamic'))
logging_hooks.py
from app import app
from flask import request
@app.before_request
def log_request_info():
message=f'''
↓↓↓↓↓↓↓↓↓↓↓↓↓
↓↓ {request.method} request from {request.remote_addr} for {request.url}
Request Headers:
{request.headers}Request Body:
{request.get_data()}
======'''
app.logger.debug(message)
@app.after_request
def log_response_info(response):
message=f'''
======
Response Summary:
{str(response)}
Response Headers:
{str(response.headers)}'''
#Only log details for responses that aren't pass-through (static files are pass-through, for example):
try:
if not response.direct_passthrough:
message +=f'Response Body: \n{response.get_data().decode("utf-8")}'
except: pass
message+='\n↑↑\n↑↑↑↑↑↑↑↑↑↑↑↑↑↑'
app.logger.debug(message)
return response
Questions
- Is this a bug?
- Am I using something wrong?
- What am I missing here?
- How can I achieve my objective to test that @login_required is doing what it's supposed to do?
Simple answer - this is a behavior change with Flask 2.2 (and changes to Flask-Login to support it) - see discussion here: https://github.com/Flask-Middleware/flask-security/issues/669
I think you have a couple options - login with one client - test can access, logout, access again and show that can’t access. That’s what many Flask-Security unit tests do.
Create a real client and use requests to access your server - this is what the examples in the Flask-Security repo do.
Third option - trust that FS unit tests verify correct behavior :-)