I recently updated my project to Django 2 and channels 2. Right now I am trying to rewrite my tests for chat app.
I am facing a problem with tests that depend on django db mark from pytest-django. I tried to create objects in fixtures, setup methods, in test function itself, using async_to_sync on WebsocketCommunicator. However, none of those worked.
If I create a user in a fixture and save it correctly gets an id. However, in my consumer Django does not see that User in the database. And treat it like an anonymous user.
I have a temporary token which I use to authenticate a user on websocket.connect.
#pytest.fixture
def room():
room = generate_room()
room.save()
return room
#pytest.fixture
def room_with_user(room, normal_user):
room.users.add(normal_user)
yield room
room.users.remove(normal_user)
#pytest.fixture
def normal_user():
user = generate_user()
user.save()
return user
#pytest.mark.django_db
class TestConnect:
#pytest.mark.asyncio
async def test_get_connected_client(self, path, room_with_user, temp_token):
assert get_path(room_with_user.id) == path
communicator = QSWebsocketCommunicator(application, path, query_string=get_query_string(temp_token))
connected, subprotocol = await communicator.connect()
assert connected
await communicator.disconnect()
Consumer:
class ChatConsumer(JsonWebsocketConsumer):
def connect(self):
# Called on connection. Either call
self.user = self.scope['user']
self.room_id = self.scope['url_route']['kwargs']['room_id']
group = f'room_{self.room_id}'
users = list(User.objects.all()) # no users here
self.group_name = group
if not (self.user is not None and self.user.is_authenticated):
return self.close({'Error': 'Not authenticated user'})
try:
self.room = Room.objects.get(id=self.room_id, users__id=self.user.id)
except ObjectDoesNotExist:
return self.close({'Error': 'Room does not exists'})
# Send success response
self.accept()
# Save user as active
self.room.active_users.add(self.user)
My authentication Middleware
class OAuthTokenAuthMiddleware:
"""
Custom middleware that takes Authorization header and read OAuth token from it.
"""
def __init__(self, inner):
# Store the ASGI application we were passed
self.inner = inner
def __call__(self, scope):
temp_token = self.get_token(scope)
scope['user'] = self.validate_token(temp_token)
return self.inner(scope)
#staticmethod
def get_token(scope) -> str:
return url_parse.parse_qs(scope['query_string'])[b'token'][0].decode("utf-8")
#staticmethod
def validate_token(token):
try:
token = TemporaryToken.objects.select_related('user').get(token=token)
if token.is_active():
token.delete()
return token.user
else:
return AnonymousUser()
except ObjectDoesNotExist:
return AnonymousUser()
And custom WebsocketCommunicator which accepts query_string in order to include my one time token
class QSWebsocketCommunicator(WebsocketCommunicator):
def __init__(self, application, path, headers=None, subprotocols=None,
query_string: Optional[Union[str, bytes]]=None):
if isinstance(query_string, str):
query_string = str.encode(query_string)
self.scope = {
"type": "websocket",
"path": path,
"headers": headers or [],
"subprotocols": subprotocols or [],
"query_string": query_string or ''
}
ApplicationCommunicator.__init__(self, application, self.scope)
My question is how can I create User, Room, etc. objects in tests/fixtures so that I can access them in Django consumer.
Or do you have another idea how can I overcome this?
It's pretty much impossible to reproduce your issue using the code you've provided. Read about How to create a Minimal, Complete, and Verifiable example. However, I suppose that you should use real transactions in your test as the plain pytest.mark.django_db will skip the transactions and not store any data in the database per se. A working example:
# routing.py
from django import http
from django.conf.urls import url
from django.contrib.auth.models import User
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.generic.websocket import JsonWebsocketConsumer
class ChatConsumer(JsonWebsocketConsumer):
def connect(self):
self.user = self.scope['user']
print('user in scope, set by middleware:', self.user)
users = list(User.objects.all()) # no users here
print('all users in chat consumer:', users)
if not (self.user is not None and self.user.is_authenticated):
return self.close({'Error': 'Not authenticated user'})
# Send success response
self.accept()
class OAuthTokenAuthMiddleware:
def __init__(self, inner):
# Store the ASGI application we were passed
self.inner = inner
def __call__(self, scope):
token = self.get_token(scope)
print('token in middleware:', token)
scope['user'] = User.objects.get(username=token)
return self.inner(scope)
#staticmethod
def get_token(scope) -> str:
d = http.QueryDict(scope['query_string'])
return d['token']
APP = ProtocolTypeRouter({
'websocket': OAuthTokenAuthMiddleware(URLRouter([url(r'^websocket/$', ChatConsumer)])),
})
Sample fixture that creates a user with username spam:
#pytest.fixture(scope='function', autouse=True)
def create_user():
with transaction.atomic():
User.objects.all().delete()
user = User.objects.create_user(
'spam', 'spam#example.com', password='eggs',
first_name='foo', last_name='bar'
)
return user
Now, I mark the test as transactional one, meaning that each query is actually committed. Now the test user is stored into database and the queries made in middleware/consumer can actually return something meaningful:
#pytest.mark.django_db(transaction=True)
#pytest.mark.asyncio
async def test_get_connected_client():
app = OAuthTokenAuthMiddleware(URLRouter([url(r'^websocket/$', ChatConsumer)]))
communicator = QSWebsocketCommunicator(app, '/websocket/', query_string='token=spam')
connected, subprotocol = await communicator.connect()
assert connected
await communicator.disconnect()
Running test test yields the desired result:
$ pytest -vs
================================== test session starts =================================
platform darwin -- Python 3.6.3, pytest-3.4.0, py-1.5.2, pluggy-0.6.0 -- /Users/hoefling/.virtualenvs/stackoverflow/bin/python
cachedir: .pytest_cache
Django settings: spam.settings (from environment variable)
rootdir: /Users/hoefling/projects/private/stackoverflow/so-49136564/spam, inifile: pytest.ini
plugins: celery-4.1.0, forked-0.2, django-3.1.2, cov-2.5.1, asyncio-0.8.0, xdist-1.22.0, mock-1.6.3, hypothesis-3.44.4
collected 1 item
tests/test_middleware.py::test_get_connected_client Creating test database for alias 'default'...
token in middleware: spam
user in scope: spam
all users in chat consumer: [<User: spam>]
PASSEDDestroying test database for alias 'default'...
=============================== 1 passed in 0.38 seconds ================================
Btw you don't need to hack around the WebsocketCommunicator anymore since it is now able to deal with query strings, see this issue closed.
Related
I am using a custom JWT Authentication Middleware for verifying of JWT.
import jwt
from urllib.parse import parse_qs
from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.conf import settings
from rest_framework_simplejwt.tokens import AccessToken
#database_sync_to_async
def get_user(user_id):
User = get_user_model()
try:
user = User.objects.get(id=user_id)
return user
except User.DoesNotExist:
return AnonymousUser()
class TokenAuthMiddleware:
"""
Custom middleware (insecure) that takes user IDs from the query string.
"""
def __init__(self, inner):
# Store the ASGI application we were passed
self.inner = inner
async def __call__(self, scope, receive, send):
# Look up user from query string (you should also do things like
# checking if it is a valid user ID, or if scope["user"] is already
# populated).
token = parse_qs(scope["query_string"].decode())["token"][0]
AccessToken(token).verify()
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
scope["user"] = await get_user(int(payload["user_id"]))
return await self.inner(dict(scope), receive, send)
I am getting the error TypeError: object.__init__() takes exactly one argument (the instance to initialize)
I followed the official docs
Can anyone guide me where is exactly the issue?
For anyone facing the same issue, the problem is not actually in the Middleware. For my case, the issue was in asgi.py. I had forgot to call the .as_asgi() method on the consumer.
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
# Just HTTP for now. (We can add other protocols later.)
"websocket": TokenAuthMiddlewareStack(
URLRouter([
path("chat/", ChatConsumer.as_asgi()),
])
)
}
)
Note the as_asgi() method on ChatConsumer. I missed that which eventually led me to think that the issue was in my custom middleware.
I had a django 2 app and i used django channels for socket connection.
i just update django to version 3. and now daphne show this error when i try to make a socket connection. i had not any problem with django 2.
[Failure instance: Traceback: <class 'django.core.exceptions.SynchronousOnlyOperation'>: You cannot call this from an async context - use a thread or sync_to_async.
/home/ubuntu/pl_env/lib/python3.6/site-packages/autobahn/websocket/protocol.py:2844:processHandshake
/home/ubuntu/pl_env/lib/python3.6/site-packages/txaio/tx.py:429:as_future
/home/ubuntu/pl_env/lib/python3.6/site-packages/twisted/internet/defer.py:151:maybeDeferred
/home/ubuntu/pl_env/lib/python3.6/site-packages/daphne/ws_protocol.py:83:onConnect
--- <exception caught here> ---
/home/ubuntu/pl_env/lib/python3.6/site-packages/twisted/internet/defer.py:151:maybeDeferred
/home/ubuntu/pl_env/lib/python3.6/site-packages/daphne/server.py:201:create_application
/home/ubuntu/pl_env/lib/python3.6/site-packages/channels/routing.py:54:__call__
/home/ubuntu/pl_env/lib/python3.6/site-packages/channels/security/websocket.py:37:__call__
/home/ubuntu/petroline_django/orders/token_auth.py:25:__call__
/home/ubuntu/pl_env/lib/python3.6/site-packages/django/db/models/manager.py:82:manager_method
/home/ubuntu/pl_env/lib/python3.6/site-packages/django/db/models/query.py:411:get
/home/ubuntu/pl_env/lib/python3.6/site-packages/django/db/models/query.py:258:__len__
/home/ubuntu/pl_env/lib/python3.6/site-packages/django/db/models/query.py:1261:_fetch_all
/home/ubuntu/pl_env/lib/python3.6/site-packages/django/db/models/query.py:57:__iter__
/home/ubuntu/pl_env/lib/python3.6/site-packages/django/db/models/sql/compiler.py:1142:execute_sql
/home/ubuntu/pl_env/lib/python3.6/site-packages/django/utils/asyncio.py:24:inner
it says the problem is in token_auth.py, line 25. this line is token = Token.objects.get(key=token_key)
this is my token_auth.py that handles token authentication.
from channels.auth import AuthMiddlewareStack
from django.contrib.auth.models import AnonymousUser
from django.db import close_old_connections
from rest_framework.authtoken.models import Token
class TokenAuthMiddleware:
"""
Token authorization middleware for Django Channels 2
see:
https://channels.readthedocs.io/en/latest/topics/authentication.html#custom-authentication
"""
def __init__(self, inner):
self.inner = inner
def __call__(self, scope):
headers = dict(scope['headers'])
if b'authorization' in headers:
try:
token_name, token_key = headers[b'authorization'].decode().split()
if token_name == 'Token':
# Close old database connections to prevent usage of timed out connections
close_old_connections()
token = Token.objects.get(key=token_key)
scope['user'] = token.user
except Token.DoesNotExist:
scope['user'] = AnonymousUser()
return self.inner(scope)
TokenAuthMiddlewareStack = lambda inner: TokenAuthMiddleware(AuthMiddlewareStack(inner))
Thanks to #ivissani answers, i fixed my TokenAuthMiddleware with some of SessionMiddleware codes.
I have opened an issue for django channels about updating docs.
#database_sync_to_async
def get_user(token_key):
try:
return Token.objects.get(key=token_key).user
except Token.DoesNotExist:
return AnonymousUser()
class TokenAuthMiddleware:
"""
Token authorization middleware for Django Channels 2
see:
https://channels.readthedocs.io/en/latest/topics/authentication.html#custom-authentication
"""
def __init__(self, inner):
self.inner = inner
def __call__(self, scope):
return TokenAuthMiddlewareInstance(scope, self)
class TokenAuthMiddlewareInstance:
def __init__(self, scope, middleware):
self.middleware = middleware
self.scope = dict(scope)
self.inner = self.middleware.inner
async def __call__(self, receive, send):
headers = dict(self.scope['headers'])
if b'authorization' in headers:
token_name, token_key = headers[b'authorization'].decode().split()
if token_name == 'Token':
self.scope['user'] = await get_user(token_key)
inner = self.inner(self.scope)
return await inner(receive, send)
TokenAuthMiddlewareStack = lambda inner: TokenAuthMiddleware(AuthMiddlewareStack(inner))
Fixed by using #database_sync_to_async decorator:
(see https://github.com/MathieuB1/KOREK-backend/commit/ff6a4b542cda583a1d5abbf200a5d57ef328cae0#diff-95e545fb374a9ed7e8af8c31087a3f29)
import jwt, re
import traceback
from channels.auth import AuthMiddlewareStack
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser
from django.conf import LazySettings
from jwt import InvalidSignatureError, ExpiredSignatureError, DecodeError
from django.contrib.auth.models import User
from django.contrib.sessions.models import Session
settings = LazySettings()
from django.db import close_old_connections
#database_sync_to_async
def close_connections():
close_old_connections()
#database_sync_to_async
def get_user(user_jwt):
try:
return User.objects.get(id=user_jwt)
except User.DoesNotExist:
return AnonymousUser()
class TokenAuthMiddleware:
"""
Token authorization middleware for Django Channels 2
"""
def __init__(self, inner):
self.inner = inner
def __call__(self, scope):
# Close old database connections to prevent usage of timed out connections
close_connections()
# Login with JWT
try:
if scope['subprotocols'][0] != 'None':
token = scope['subprotocols'][0]
try:
user_jwt = jwt.decode(
token,
settings.SECRET_KEY,
)
scope['user'] = get_user(user_jwt['user_id'])
return self.inner(scope)
except (InvalidSignatureError, KeyError, ExpiredSignatureError, DecodeError):
traceback.print_exc()
pass
except Exception as e:
traceback.print_exc()
else:
raise
Refer to this part of the documentation. There it is explained that Django 3 will raise such exception if you try to use the ORM from within an async context (which seems to be the case).
As Django Channels documentation explains solution would be to use sync_to_async as follows:
from channels.db import database_sync_to_async
class TokenAuthMiddleware:
# more code here
async def __call__(self, scope):
# and some more code here
token = await database_sync_to_async(Token.objects.get(key=token_key))()
Although please bear in mind that I haven't used this in my life, so it may fail.
Note that in the Django channels documentation it says that you need to write your query in a separate method. So if this fails try doing that.
I have many django forms in which I pass the request as kwarg.
I've just started dive into testing, and it seems that testing forms which require the request as argument makes the testing harder. As I have to somehow create a request and I cant test my forms without it.
So is it best to avoid passing the request to the form at all? Or another workaround?
The reason I do that on first place is that sometimes I need request.user, or request.session and do some cleaning/setting based on that info in the form.
UPDATE:
This is an example form:
class OrderForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
self.user = self.request.user
def clean(self):
# Here I have some cross session-field validation
if self.request.session['has_response'] and self.cleaned_data('status') == 'NEW':
raise ValidationError()
def save(self, commit=False):
self.instance.user = self.user
return super(OrderForm, self).save(commit=True)
class Meta:
model = Order
fields = ('address', 'city', 'status', ) # more fields
The view code is simple:
form = OrderForm(request.POST, request=request)
The Order model also have a clean() method with some validation logic.
The session is populated at most during the user login.
The point it I need the session/user there.
But most important, the question - is it a bad design to pass the request and session to the form, considering options for testing this form? I find it more logical when the form take care for saving the object, including the request.user. But maybe I should try to split that between the form and view?
Passing request to the form is okay if you need it in the clean() method. You can use a request/session/user in a test like this:
from django.test import TestCase, Client
from django.test.client import RequestFactory
from django.contrib.auth.models import AnonymousUser, User
from .views import my_view
from .forms import MyForm
from django.contrib.sessions.middleware import SessionMiddleware
# If Python >= 3.4
from unittest.mock import patch, MagicMock
# Else
from mock import patch, MagicMock
class SimpleTest(TestCase):
def setUp(self):
# Create a RequestFactory accessible by the entire class.
self.factory = RequestFactory()
# Create a new user object accessible by the entire class.
self.user = User.objects.create_user(username='username',
email='email', password='password')
def test_my_view(self):
# Create an instance of a GET request.
request = self.factory.get('/my-url/')
# Middleware is not supported so simulate a
# logged-in user by setting request.user.
request.user = self.user
# Or add anonymous user to request.
request.user = AnonymousUser()
# Test view() at '/my-url/'
response = my_view(request)
self.assertEqual(response.status_code, 200)
#patch('app.models.ModelName.save', MagicMock(name="save"))
def test_my_form_view_with_factory(self):
# Set up form data.
form_data = {'something': 'something'}
# Create an instance of a POST request.
request = self.factory.post('/my-form-url/', form_data)
# Simulate logged-in user
request.user = self.user
# Setup session.
middleware = SessionMiddleware()
middleware.process_request(request)
request.session.save()
# Or you should just be able to do
request.session['somekey'] = 'test'
request.session.save()
# Get response from form view, and test passing
# request/data to form.
form = MyForm(request=request, data=form_data)
response = my_form_view(request)
self.assertTrue(form.is_valid())
self.assertEqual(response.status_code, 200)
# If model form you can do
self.assertTrue(ModelName.save.called)
#patch('app.models.ModelName.save', MagicMock(name="save"))
def test_my_form_view_with_client(self):
# Use Client instead of RequestFactory.
self.client = Client()
# Login with Client.
self.client.login(username='username', password='password')
# Set up form data.
form_data = {'something': 'something'}
# Get/set session.
session = self.client.session
session['somekey'] = 'test'
session.save()
# Get response with Client.
response = self.client.post('/my-form-url/', form_data)
self.assertEqual(response.status_code, 200)
# If model form you can do
self.assertTrue(ModelName.save.called)
Should give a general idea of what you can do, not specifically tested.
I'm trying to figure out why my Flask test is not working correctly. I'm testing a view function '/register' that successfully redirects to a dashboard when I run the site on localhost.
My test for this behavior fails and instead feeds back a 200 response.
Traceback (most recent call last):
File "/Users/casey/python/storm/project/tests/test_views.py", line 30, in test_register_user
self.assertEqual(resp.status_code, 302)
AssertionError: 200 != 302
My test and view code are below:
# tests/helpers.py
from unittest import TestCase
from views import app
class PhotogTestCase(TestCase):
def setUp(self):
self.client = app.test_client()
self.client.testing = True
app.config['WTF_CSRF_ENABLED'] = False
def tearDown(self):
pass
# tests/test_views.py
from helpers import PhotogTestCase
class TestRegister(PhotogTestCase):
"""Test our registration view."""
def test_register_user(self):
# Ensure page loads with correct text
resp = self.client.get('/register')
assert 'Register for an Account' in resp.data
# Ensure that valid fields result in success.
resp = self.client.post('/register', {
'email': 'c#gmail.com',
'password': 'woot1LoveCookies!',
'password_again': 'woot1LoveCookies!'
}, follow_redirects=True)
self.assertEqual(resp.status_code, 302)
# views.py
import uuid
from forms import RegistrationForm
from flask import Flask, redirect, render_template, \
request, url_for, flash, current_app, abort
from flask.ext.stormpath import StormpathManager, login_required, \
groups_required, user, User
from stormpath.error import Error as StormpathError
from flask.ext.login import login_user
#app.route('/register', methods=['GET', 'POST'])
def register():
"""
Register a new user with Stormpath.
"""
form = RegistrationForm()
# If we received a POST request with valid information, we'll continue
# processing.
if form.validate_on_submit():
data = {}
# Attempt to create the user's account on Stormpath.
try:
# email and password
data['email'] = request.form['email']
data['password'] = request.form['password']
# given_name and surname are required fields
data['given_name'] = 'Anonymous'
data['surname'] = 'Anonymous'
# create a tenant ID
tenant_id = str(uuid.uuid4())
data['custom_data'] = {
'tenant_id': tenant_id,
'site_admin': True
}
# Create the user account on Stormpath. If this fails, an
# exception will be raised.
account = User.create(**data)
# create a new stormpath group
directory = stormpath_manager.application.default_account_store_mapping.account_store
tenant_group = directory.groups.create({
'name': tenant_id,
'description': data['email']
})
# assign new user to the newly created group
account.add_group(tenant_group)
account.add_group('site_admin')
# If we're able to successfully create the user's account,
# we'll log the user in (creating a secure session using
# Flask-Login), then redirect the user to the
# STORMPATH_REDIRECT_URL setting.
login_user(account, remember=True)
# redirect to dashboard
redirect_url = app.config['STORMPATH_REDIRECT_URL']
return redirect(redirect_url)
except StormpathError as err:
flash(err.message.get('message'))
return render_template(
'account/register.html',
form=form,
)
When making the request, you set follow_redirects=True, so naturally you see the final page rather than the intermediate redirect. Set follow_redirects=False instead.
The following test code does not pass even though manually submitting the form on my web interface actually does work.
import os
from flask.ext.testing import TestCase
from flask import url_for
from config import _basedir
from app import app, db
from app.users.models import User
class TestUser(TestCase):
def create_app(self):
"""
Required method. Always implement this so that app is returned with context.
"""
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(_basedir, 'test.db')
app.config['WTF_CSRF_ENABLED'] = False # This must be disabled for post to succeed during tests
self.client = app.test_client()
ctx = app.app_context()
ctx.push()
return app
def setUp(self):
db.create_all()
#pass
#app.teardown_appcontext
def tearDown(self):
db.session.remove()
db.drop_all()
#pass
def test_admin_home(self):
# url is the admin home page
url = url_for('admin.index')
resp = self.client.get(url)
self.assertTrue(resp.status_code == 200)
def test_admin_registration(self):
url = url_for('admin.register_view')
data = {'username': 'admin', 'email': 'admin#example.com', 'password': 'admin'}
resp = self.client.post(url, data)
self.assertTrue(resp.status_code == 200)
u = User.query.filter_by(username=u'admin').first()
self.assertTrue(u.username == 'admin') # <----- This fails. Why?
After the test client has post to the register_view url and returns a 200 OK response, I fail to retrieve the 'admin' user from the test database. Why is this so?
Here's the view code (this is a flask admin view)
from flask import request
from flask.ext.admin import expose, AdminIndexView, helpers
from app.auth.forms import LoginForm, RegistrationForm
from app.users.models import User
from app import db
class MyAdminIndexView(AdminIndexView):
#expose('/', methods=('GET', 'POST'))
def index(self):
# handle user login
form = LoginForm(request.form)
self._template_args['form'] = form
return super(MyAdminIndexView, self).index()
#expose('/register/', methods=('GET', 'POST'))
def register_view(self):
# handle user registration
form = RegistrationForm(request.form)
if helpers.validate_form_on_submit(form):
user = User()
form.populate_obj(user)
db.session.add(user)
db.session.commit()
self._template_args['form'] = form
return super(MyAdminIndexView, self).index()
Dumbest mistake ever.
The offending line in my test code is
resp = self.client.post(url, data)
It should be
resp = self.client.post(url, data=data)
I managed to track it down by painstakingly walking through the logic and inserting ipdb.set_trace() step by step until I found the bad POST request made by my client.