Chapter 22 · The test framework
22.11 Sessions and logins in the test client: ClientMixin.login and force_login
The test client keeps its cookies in RequestFactory, and reads the session they name through ClientMixin. ClientMixin checks credentials with authenticate and ClientMixin does not; both write the user into a session with django and set its cookie, and ClientMixin flushes it and drops every cookie.
A test of a page behind a login needs what a browser has after signing in: a session holding the user, and the cookie naming it. These methods make both without the login form, calling the auth application’s functions on a request no handler sees and setting the cookie by hand. They are ClientMixin’s, so Client and AsyncClient both have them.
The cookies
RequestFactory gives every factory, and so every client, a SimpleCookie of its own (Cookies), and RequestFactory sends it with each request as "HTTP_COOKIE": every cookie as its name and value, whatever its path, domain, expiry or Secure flag say, unless a "Cookie" header given to the client or with the request replaces them all. The documentation says so of expiry, and offers a new client to lose every cookie. After each request Client.request merges in the response’s cookies, each replacing the client’s cookie of that name. AsyncRequestFactory sends them as a "cookie" header of the scope.
The session: ClientMixin.session
ClientMixin is a property that answers with a store of the engine SESSION_ENGINE names. When the client holds a cookie under SESSION_COOKIE_NAME, the answer is a store for that key. When it holds none, the property makes a store, saves it at once, which gives it a key, and sets that key as the client’s cookie. So a client with no session has an empty one after a read.
Each read makes a new store, which loads the data afresh at its first use (The session store: SessionBase), so a value set on the property’s answer directly is set on an object nothing keeps. The documentation’s advice is to keep the store in a variable, change it, and call SessionBase.save. ClientMixin is the same as a coroutine method.
Logging in from a test
The office’s new_ship is behind login_required. A test module beside test_ships.py (The test framework) logs nora in both ways, and puts a value in the session:
# harbour/office/tests/test_logins.py
import time
from django.contrib.auth.models import User
from django.contrib.sessions.models import Session
from django.test import TestCase
def rows():
return [(s.session_key[:8], sorted(s.get_decoded())) for s in Session.objects.all()]
class LoginTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.nora = User.objects.create_user("nora", password="anchor-chain-7")
def test_force_login(self):
start = time.perf_counter()
self.client.force_login(self.nora)
print("force_login: %.3f s" % (time.perf_counter() - start), rows())
def test_login(self):
print("new_ship:", self.client.get("/ships/new/").status_code)
start = time.perf_counter()
print("login:", self.client.login(username="nora", password="anchor-chain-7"),
"%.3f s" % (time.perf_counter() - start), rows())
print("new_ship:", self.client.get("/ships/new/").status_code,
sorted(self.client.cookies))
self.client.logout()
print("logout:", sorted(self.client.cookies), rows())
def test_session(self):
self.client.session["berth"] = "B4"
print("unsaved:", rows())
session = self.client.session
session["berth"] = "B4"
session.save()
request = self.client.get("/ships/").wsgi_request
print("saved:", rows(), request.session["berth"])
$ python manage.py test harbour.office.tests.test_logins -v 0
System check identified no issues (0 silenced).
force_login: 0.004 s [('dsxlhtzx', ['_auth_user_backend', '_auth_user_hash', '_auth_user_id'])]
new_ship: 302
login: True 0.624 s [('7jsf7d4o', ['_auth_user_backend', '_auth_user_hash', '_auth_user_id'])]
new_ship: 200 ['csrftoken', 'sessionid']
logout: [] []
unsaved: [('fs3noza0', [])]
saved: [('fs3noza0', ['berth'])] B4
----------------------------------------------------------------------
Ran 3 tests in 1.295s
OK
Each login left one session holding the user’s three entries; checking the password took over half a second, forcing the login a few milliseconds. In test_session the first read made an empty session and lost the value; the saved store reached the next request.
ClientMixin.login and force_login
ClientMixin passes its keyword arguments to authenticate, which tries the backends of AUTHENTICATION_BACKENDS with them, ModelBackend checking a password through the hashers (Authenticating: authenticate and the backends). If one answers with a user, the client logs the user in and returns True; if none does, False, after authenticate has sent user_login_failed with no request.
ClientMixin asks nothing. It sets the user’s backend attribute, as authenticate would, to the dotted path it is given or the one ClientMixin chooses, and logs the user in. The documentation offers it where how the user logged in does not matter, and as faster. It lets in an inactive user, whom ModelBackend refuses, but only into the session: ModelBackend refuses the user again at the next request, and in a run new_ship sent an inactive nora, forced in, to the login page (The user of a request: login, logout and AuthenticationMiddleware).
_get_backend takes the first entry of AUTHENTICATION_BACKENDS whose class overrides BaseBackend or BaseBackend, by which a later request finds the user. In a run, a backend answering only permissions, listed first, was passed over for ModelBackend.
The login itself: ClientMixin._login
ClientMixin makes a bare HttpRequest, gives it the client’s session as the property returns it (a new store if that one has no key, as when the session cookie’s value is empty), and calls django on it. That writes the three entries, after giving a session without a user a new key or flushing one that holds another user (The user of a request: login, logout and AuthenticationMiddleware). _login saves the session, and ClientMixin sets the client’s cookie under SESSION_COOKIE_NAME to its key, with the path "/", the domain of SESSION_COOKIE_DOMAIN, Secure if SESSION_COOKIE_SECURE, and no expiry.
No request goes through the handler: no login view, no AuthenticationForm, no CSRF check, no middleware. django runs whole, so user_logged_in is sent and its receiver records last_login; but what it does to the request is lost with the request. The CSRF secret that rotate_token replaces is the bare request’s: in a run, the client’s "csrftoken" cookie was the same after its login as before, where a login through LoginView replaced it.
ClientMixin.logout
ClientMixin gives a bare request the client’s session and the user django reads from it, and calls django, which sends user_logged_out with that user, or with None if no one is logged in, and flushes the session. Then it replaces the cookies with an empty SimpleCookie: every cookie goes, not the session’s alone.
The asynchronous methods
ClientMixin, ClientMixin and ClientMixin, added in 5.0, take the same steps through aauthenticate, django and django, with asession. But ClientMixin puts the user on the bare request as HttpRequest.user, and django asks HttpRequest, which a bare request lacks: in a run, a receiver of user_logged_out got nora from the client’s logout and None from its asynchronous one.