Different Werkzeug test clients are affecting each other. Why?

134 Views Asked by At

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

  1. Is this a bug?
  2. Am I using something wrong?
  3. What am I missing here?
  4. How can I achieve my objective to test that @login_required is doing what it's supposed to do?
2

There are 2 best solutions below

2
jwag On

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 :-)

0
Andy Doucette On

After a breakthrough thanks entirely to @kuba-lilz (Thanks so much Kuba!), I have found out that the issue is with the run_tests(app) line. That needs to be one indentation to the left so it's no longer within the app.app_context(). Makes complete sense in retrospect. Thanks again to @kuba-lilz! :D

Finding the issue with my original application (not the example application) was more tricky, but I eventually found out that flask_script has a file commands.py that runs the entire command within an app.test_request_context(). Removing that context fixed the issue on my main app.