Repository: valohai/django-allauth-2fa (language: python)

Issue:
Fix up Codecov
* Currently coverage reports are being generated somewhere within tox runs where the Codecov action can't find them.
  * Coverage should probably be merged from the various tox envs into a single file.
* The readme still has a coveralls badge; should be a codecov badge.
Fix up Codecov
* Currently coverage reports are being generated somewhere within tox runs where the Codecov action can't find them.
  * Coverage should probably be merged from the various tox envs into a single file.
* The readme still has a coveralls badge; should be a codecov badge.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7a4d3d5..f682864 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,7 +36,12 @@ jobs:
       - run: pip install -U pip wheel setuptools
       - run: pip install -U tox tox-gh-actions
       - run: tox
-      - uses: codecov/codecov-action@v1
+      - run: |
+          mkdir /tmp/coverage
+          find .tox -type f -name 'coverage*xml' -exec mv '{}' /tmp/coverage ';'
+      - uses: codecov/codecov-action@v2
+        with:
+          directory: /tmp/coverage
   Build:
     runs-on: ubuntu-20.04
     steps:
diff --git a/README.rst b/README.rst
index d9eebd6..1a0a090 100644
--- a/README.rst
+++ b/README.rst
@@ -70,11 +70,12 @@ django-allauth-2fa useful!
 Running tests
 '''''''''''''
 
-Tests can be run using the standard Django testing facility:
+Tests can be run using [pytest](https://docs.pytest.org/en/6.2.x/).
 
 .. code-block:: bash
 
-    python manage.py test
+    pip install -r requirements-test.txt
+    py.test
 
 Running the test project
 ''''''''''''''''''''''''
diff --git a/requirements-dev.txt b/requirements-dev.txt
index ad7ef5d..5599158 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,5 +1,5 @@
 # Dependencies required for installing the package are defined in setup.cfg.
-# Running unit tests should require no extra dependencies.
+# Dependencies for tests are in requirements-test.txt.
 
 -e .  # Install the current directory as an editable package.
 
diff --git a/tox.ini b/tox.ini
index 636136a..fdd9f4b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -38,11 +38,9 @@ python =
 
 [testenv]
 commands =
-    coverage run manage.py test
-    coverage report
-    coverage html
+    pytest --cov . --cov-report html --cov-report term-missing --cov-report xml:{envdir}/coverage-{envname}.xml
 deps =
-    coverage
+    -r requirements-test.txt
     django22: Django>=2.2,<3.0
     django30: Django>=3.0,<3.1
     django31: Django>=3.1,<3.2
@@ -63,11 +61,3 @@ deps =
     flake8
 commands =
     flake8 {toxinidir}
-
-[coverage:run]
-include =
-    allauth_2fa/*
-    tests/*
-
-[coverage:report]
-show_missing = True


Test patch:
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..2a8c655
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = tests.settings
diff --git a/requirements-test.txt b/requirements-test.txt
new file mode 100644
index 0000000..6a246a3
--- /dev/null
+++ b/requirements-test.txt
@@ -0,0 +1,2 @@
+pytest-cov
+pytest-django~=4.5.2
diff --git a/tests/settings.py b/tests/settings.py
index dccd591..2cb5097 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -68,6 +68,7 @@
     MIDDLEWARE += ("django.contrib.auth.middleware.SessionAuthenticationMiddleware",)
 
 AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",)
+PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",)
 
 # Enable two-factor auth.
 ACCOUNT_ADAPTER = "allauth_2fa.adapter.OTPAdapter"
diff --git a/tests/test_allauth_2fa.py b/tests/test_allauth_2fa.py
index 29785b2..efbcb61 100644
--- a/tests/test_allauth_2fa.py
+++ b/tests/test_allauth_2fa.py
@@ -1,401 +1,276 @@
-from urllib.parse import parse_qsl
-from urllib.parse import urlencode
-from urllib.parse import urlparse
-from urllib.parse import urlunparse
+from typing import Callable
+from typing import Optional
+from typing import Tuple
+from unittest.mock import Mock
 
+import pytest
 from allauth.account.signals import user_logged_in
 from django.conf import settings
 from django.contrib.auth import get_user_model
+from django.contrib.auth.models import AbstractUser
 from django.test import override_settings
-from django.test import TestCase
 from django.urls import reverse
+from django.urls import reverse_lazy
 from django_otp.oath import TOTP
+from django_otp.plugins.otp_totp.models import TOTPDevice
+from pytest_django.asserts import assertRedirects
 
 from allauth_2fa.middleware import BaseRequire2FAMiddleware
 
 
-def normalize_url(url):
-    """Sort the URL's query string parameters."""
-    url = str(url)  # Coerce reverse_lazy() URLs.
-    scheme, netloc, path, params, query, fragment = urlparse(url)
-    query_parts = sorted(parse_qsl(query))
-    return urlunparse((scheme, netloc, path, params, urlencode(query_parts), fragment))
+TWO_FACTOR_AUTH_URL = reverse_lazy("two-factor-authenticate")
+TWO_FACTOR_SETUP_URL = reverse_lazy("two-factor-setup")
+LOGIN_URL = reverse_lazy("account_login")
+JOHN_CREDENTIALS = {"login": "john", "password": "doe"}
 
 
-class Test2Factor(TestCase):
-    def setUp(self):
-        # Track the signals sent via allauth.
-        self.user_logged_in_count = 0
-        user_logged_in.connect(self._login_callback)
+@pytest.fixture(autouse=True)
+def enable_db_access_for_all_tests(db):
+    pass
 
-    def _login_callback(self, sender, **kwargs):
-        self.user_logged_in_count += 1
 
-    def test_standard_login(self):
-        """Test login behavior when 2FA is not configured."""
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
+@pytest.fixture()
+def john() -> "AbstractUser":
+    user = get_user_model().objects.create(username="john")
+    user.set_password("doe")
+    user.save()
+    return user
 
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
-        self.assertRedirects(
-            resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
-        )
 
-        # Ensure the signal is received as expected.
-        self.assertEqual(self.user_logged_in_count, 1)
+@pytest.fixture()
+def john_with_totp(john: AbstractUser) -> Tuple[AbstractUser, TOTPDevice]:
+    totp_model = john.totpdevice_set.create()
+    return (john, totp_model)
 
-    def test_2fa_login(self):
-        """Test login behavior when 2FA is configured."""
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-        totp_model = user.totpdevice_set.create()
 
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
-        self.assertRedirects(
-            resp, reverse("two-factor-authenticate"), fetch_redirect_response=False
-        )
+@pytest.fixture()
+def user_logged_in_count(request) -> Callable[[], int]:
+    login_callback = Mock()
+    user_logged_in.connect(login_callback)
+    request.addfinalizer(lambda: user_logged_in.disconnect(login_callback))
 
-        # Now ensure that logging in actually works.
-        totp = TOTP(
-            totp_model.bin_key, totp_model.step, totp_model.t0, totp_model.digits
-        )
-        resp = self.client.post(
-            reverse("two-factor-authenticate"), {"otp_token": totp.token()}
-        )
-        self.assertRedirects(
-            resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
-        )
+    def get_login_count() -> int:
+        return login_callback.call_count
 
-        # Ensure the signal is received as expected.
-        self.assertEqual(self.user_logged_in_count, 1)
+    return get_login_count
 
-    def test_invalid_2fa_login(self):
-        """Test login behavior when 2FA is configured and wrong code is given."""
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-        user.totpdevice_set.create()
 
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
-        self.assertRedirects(
-            resp, reverse("two-factor-authenticate"), fetch_redirect_response=False
-        )
+def login(client, *, expected_redirect_url: Optional[str], credentials=None):
+    if credentials is None:
+        credentials = JOHN_CREDENTIALS
+    resp = client.post(LOGIN_URL, credentials)
+    if expected_redirect_url:
+        assertRedirects(resp, expected_redirect_url, fetch_redirect_response=False)
 
-        # Ensure that logging in does not work with invalid token
-        resp = self.client.post(
-            reverse("two-factor-authenticate"), {"otp_token": "invalid"}
-        )
-        self.assertEqual(resp.status_code, 200)
-
-    def test_2fa_redirect(self):
-        """
-        Going to the 2FA login page when not logged in (or when fully logged in)
-        should redirect.
-        """
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-
-        # Not logged in.
-        resp = self.client.get(reverse("two-factor-authenticate"))
-        self.assertRedirects(
-            resp, reverse("account_login"), fetch_redirect_response=False
-        )
 
-        # Logged in.
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
+def get_token_from_totp_device(totp_model) -> str:
+    return TOTP(
+        key=totp_model.bin_key,
+        step=totp_model.step,
+        t0=totp_model.t0,
+        digits=totp_model.digits,
+    ).token()
 
-        resp = self.client.get(reverse("two-factor-authenticate"))
-        self.assertRedirects(
-            resp, reverse("account_login"), fetch_redirect_response=False
-        )
 
-    def test_2fa_reset_flow(self):
-        """
-        Ensure the login flow is reset when navigating away before entering
-        two-factor credentials.
-        """
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-        user.totpdevice_set.create()
-
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
-        self.assertRedirects(
-            resp, reverse("two-factor-authenticate"), fetch_redirect_response=False
-        )
+def do_totp_authentication(
+    client,
+    totp_device: TOTPDevice,
+    *,
+    expected_redirect_url: Optional[str],
+    auth_url: str = TWO_FACTOR_AUTH_URL,
+):
+    token = get_token_from_totp_device(totp_device)
+    resp = client.post(auth_url, {"otp_token": token})
+    if expected_redirect_url:
+        assertRedirects(resp, expected_redirect_url, fetch_redirect_response=False)
 
-        # The user ID should be in the session.
-        self.assertIn("allauth_2fa_user_id", self.client.session)
 
-        # Navigate to a different page.
-        self.client.get(reverse("account_login"))
+def test_standard_login(client, john, user_logged_in_count):
+    """Test login behavior when 2FA is not configured."""
+    login(client, expected_redirect_url=settings.LOGIN_REDIRECT_URL)
 
-        # The middleware should reset the login flow.
-        self.assertNotIn("allauth_2fa_user_id", self.client.session)
+    # Ensure the signal is received as expected.
+    assert user_logged_in_count() == 1
 
-        # Trying to continue with two-factor without logging in again will
-        # redirect to login.
-        resp = self.client.get(reverse("two-factor-authenticate"))
 
-        self.assertRedirects(
-            resp, reverse("account_login"), fetch_redirect_response=False
-        )
+def test_2fa_login(client, john_with_totp, user_logged_in_count):
+    """Test login behavior when 2FA is configured."""
+    user, totp_device = john_with_totp
+    login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
 
-    def test_2fa_login_forwarding_get_parameters(self):
-        """
-        Test that the 2FA workflow passes forward the GET parameters sent to the
-        TwoFactorAuthenticate view.
-        """
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-        user.totpdevice_set.create()
-
-        # Add a next to unnamed-view.
-        resp = self.client.post(
-            reverse("account_login") + "?existing=param&next=unnamed-view",
-            {"login": "john", "password": "doe"},
-            follow=True,
-        )
-
-        # Ensure that the unnamed-view is still being forwarded to.
-        resp.redirect_chain[-1] = (
-            normalize_url(resp.redirect_chain[-1][0]),
-            resp.redirect_chain[-1][1],
-        )
-        self.assertRedirects(
-            resp,
-            normalize_url(
-                reverse("two-factor-authenticate") + "?existing=param&next=unnamed-view"
-            ),
-            fetch_redirect_response=False,
-        )
-
-    def test_2fa_login_forwarding_next_via_post(self):
-        """
-        Test that the 2FA workflow passes forward to next via POST parameters sent to
-        the TwoFactorAuthenticate view.
-        """
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-        user.totpdevice_set.create()
-
-        # Add a next to unnamed-view.
-        resp = self.client.post(
-            reverse("account_login") + "?existing=param",
-            {"login": "john", "password": "doe", "next": "unnamed-view"},
-            follow=True,
-        )
+    # Ensure no signal is received yet.
+    assert user_logged_in_count() == 0
 
-        # Ensure that the unnamed-view is still being forwarded to, preserving existing
-        # query params.
-        resp.redirect_chain[-1] = (
-            normalize_url(resp.redirect_chain[-1][0]),
-            resp.redirect_chain[-1][1],
-        )
-        self.assertRedirects(
-            resp,
-            normalize_url(
-                reverse("two-factor-authenticate") + "?existing=param&next=unnamed-view"
-            ),
-            fetch_redirect_response=False,
-        )
+    # Now ensure that logging in actually works.
+    do_totp_authentication(
+        client,
+        totp_device=totp_device,
+        expected_redirect_url=settings.LOGIN_REDIRECT_URL,
+    )
 
-    def test_anonymous(self):
-        """
-        Views should not be hittable via an AnonymousUser.
-        """
-        # The authentication page redirects to the login page.
-        url = reverse("two-factor-authenticate")
-        resp = self.client.get(url)
-        self.assertRedirects(
-            resp, reverse("account_login"), fetch_redirect_response=False
-        )
+    # Ensure the signal is received as expected.
+    assert user_logged_in_count() == 1
 
-        # Some pages redirect to the login page and then will redirect back.
-        for url in [
-            "two-factor-setup",
-            "two-factor-backup-tokens",
-            "two-factor-remove",
-        ]:
-            url = reverse(url)
-            resp = self.client.get(url)
-            self.assertRedirects(
-                resp,
-                reverse("account_login") + "?next=" + url,
-                fetch_redirect_response=False,
-            )
-
-    def test_unnamed_view(self):
-        """Views without names should not throw an exception."""
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-        user.totpdevice_set.create()
-
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
-        self.assertRedirects(
-            resp, reverse("two-factor-authenticate"), fetch_redirect_response=False
-        )
 
-        # The user ID should be in the session.
-        self.assertIn("allauth_2fa_user_id", self.client.session)
+def test_invalid_2fa_login(client, john_with_totp):
+    """Test login behavior when 2FA is configured and wrong code is given."""
+    login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
 
-        # Navigate to a different (unnamed) page.
-        resp = self.client.get("/unnamed-view")
+    # Ensure that logging in does not work with invalid token
+    resp = client.post(TWO_FACTOR_AUTH_URL, {"otp_token": "invalid"})
+    assert resp.status_code == 200
 
-        # The middleware should reset the login flow.
-        self.assertNotIn("allauth_2fa_user_id", self.client.session)
+    # Check the user did not get logged in
+    url = reverse("login-required-view")
+    resp = client.get(url)
+    assertRedirects(resp, f"{LOGIN_URL}?next={url}")
 
-        # Trying to continue with two-factor without logging in again will
-        # redirect to login.
-        resp = self.client.get(reverse("two-factor-authenticate"))
 
-        self.assertRedirects(
-            resp, reverse("account_login"), fetch_redirect_response=False
-        )
+def test_2fa_redirect(client, john):
+    """
+    Going to the 2FA login page when not logged in (or when fully logged in)
+    should redirect.
+    """
 
-    def test_backwards_compatible_url(self):
-        """Ensure that the old 2FA URLs still work."""
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-        totp_model = user.totpdevice_set.create()
+    # Not logged in.
+    resp = client.get(TWO_FACTOR_AUTH_URL)
+    assertRedirects(resp, LOGIN_URL, fetch_redirect_response=False)
 
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
-        self.assertRedirects(
-            resp, reverse("two-factor-authenticate"), fetch_redirect_response=False
-        )
+    login(client, expected_redirect_url=settings.LOGIN_REDIRECT_URL)
 
-        # Now ensure that logging in actually works.
-        totp = TOTP(
-            totp_model.bin_key, totp_model.step, totp_model.t0, totp_model.digits
-        )
+    resp = client.get(TWO_FACTOR_AUTH_URL)
+    assertRedirects(resp, LOGIN_URL, fetch_redirect_response=False)
 
-        # The old URL doesn't have a trailing slash.
-        url = reverse("two-factor-authenticate").rstrip("/")
 
-        resp = self.client.post(url, {"otp_token": totp.token()})
-        self.assertRedirects(
-            resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
-        )
+@pytest.mark.parametrize("target_url", [LOGIN_URL, "/unnamed-view"])
+def test_2fa_reset_flow(client, john_with_totp, target_url):
+    """
+    Ensure the login flow is reset when navigating away before entering
+    two-factor credentials.
+    """
+    login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
 
-        # Ensure the signal is received as expected.
-        self.assertEqual(self.user_logged_in_count, 1)
+    # The user ID should be in the session.
+    assert client.session.get("allauth_2fa_user_id")
 
-    def test_not_configured_redirect(self):
-        """Viewing backup codes or disabling 2FA should redirect if 2FA is not
-        configured."""
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
+    # Navigate to a different page.
+    client.get(target_url)
 
-        # Login
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
+    # The middleware should reset the login flow.
+    assert not client.session.get("allauth_2fa_user_id")
 
-        # The 2FA pages should redirect.
-        for url_name in ["two-factor-backup-tokens", "two-factor-remove"]:
-            resp = self.client.get(reverse(url_name))
-            self.assertRedirects(
-                resp, reverse("two-factor-setup"), fetch_redirect_response=False
-            )
+    # Trying to continue with two-factor without logging in again will
+    # redirect to login.
+    resp = client.get(TWO_FACTOR_AUTH_URL)
 
+    assertRedirects(resp, LOGIN_URL, fetch_redirect_response=False)
 
-class Require2FA(BaseRequire2FAMiddleware):
-    def require_2fa(self, request):
-        return True
 
+@pytest.mark.parametrize("next_via", ["get", "post"])
+def test_2fa_login_forwarding_get_parameters(client, john_with_totp, next_via: str):
+    """
+    Test that the 2FA workflow passes forward the GET parameters sent to the
+    TwoFactorAuthenticate view.
+    """
 
-@override_settings(
-    # Don't redirect to an "allowed" URL.
-    LOGIN_REDIRECT_URL="/unnamed-view",
-    # Add the middleware that requires 2FA.
-    MIDDLEWARE=settings.MIDDLEWARE + ("tests.test_allauth_2fa.Require2FA",),
-)
-class TestRequire2FAMiddleware(TestCase):
-    def test_no_2fa(self):
-        """Test login behavior when 2FA is not configured."""
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}, follow=True
+    # Add a next to unnamed-view.
+    if next_via == "post":
+        resp = client.post(
+            LOGIN_URL + "?existing=param",
+            {**JOHN_CREDENTIALS, "next": "unnamed-view"},
+            follow=True,
         )
-        # The user is redirected to the 2FA setup page.
-        self.assertRedirects(
-            resp, reverse("two-factor-setup"), fetch_redirect_response=False
+    else:
+        resp = client.post(
+            LOGIN_URL + "?existing=param&next=unnamed-view",
+            JOHN_CREDENTIALS,
+            follow=True,
         )
 
-    def test_2fa(self):
-        """Test login behavior when 2FA is configured."""
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-        totp_model = user.totpdevice_set.create()
+    # Ensure that the unnamed-view is still being forwarded to.
+    assertRedirects(
+        resp,
+        TWO_FACTOR_AUTH_URL + "?existing=param&next=unnamed-view",
+        fetch_redirect_response=False,
+    )
 
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}
-        )
-        self.assertRedirects(
-            resp, reverse("two-factor-authenticate"), fetch_redirect_response=False
-        )
 
-        # Now ensure that logging in actually works.
-        totp = TOTP(
-            totp_model.bin_key, totp_model.step, totp_model.t0, totp_model.digits
-        )
-        resp = self.client.post(
-            reverse("two-factor-authenticate"), {"otp_token": totp.token()}
-        )
-        # The user ends up on the normal redirect login page.
-        self.assertRedirects(
-            resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False
+def test_anonymous(client):
+    """
+    Views should not be hittable via an AnonymousUser.
+    """
+    # The authentication page redirects to the login page.
+    resp = client.get(TWO_FACTOR_AUTH_URL)
+    assertRedirects(resp, LOGIN_URL, fetch_redirect_response=False)
+
+    # Some pages redirect to the login page and then will redirect back.
+    for url in [
+        "two-factor-setup",
+        "two-factor-backup-tokens",
+        "two-factor-remove",
+    ]:
+        url = reverse(url)
+        resp = client.get(url)
+        assertRedirects(
+            resp,
+            LOGIN_URL + "?next=" + url,
+            fetch_redirect_response=False,
         )
 
-    @override_settings(
-        INSTALLED_APPS=settings.INSTALLED_APPS + ("django.contrib.messages",),
-        # This doesn't seem to stack nicely with the class-based one, so add the
-        # middleware here.
-        MIDDLEWARE=settings.MIDDLEWARE
-        + (
-            "tests.test_allauth_2fa.Require2FA",
-            "django.contrib.messages.middleware.MessageMiddleware",
-        ),
+
+def test_backwards_compatible_url(client, john_with_totp, user_logged_in_count):
+    """Ensure that the old 2FA URLs still work."""
+    # TODO(1.0): remove this test
+    user, totp_device = john_with_totp
+    login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
+    # The old URL doesn't have a trailing slash.
+    do_totp_authentication(
+        client,
+        totp_device=totp_device,
+        auth_url=str(TWO_FACTOR_AUTH_URL).rstrip("/"),
+        expected_redirect_url=settings.LOGIN_REDIRECT_URL,
     )
-    def test_no_2fa_messages(self):
-        """Test login behavior when 2FA is not configured and the messages framework is
-        in use.
-        """
-        user = get_user_model().objects.create(username="john")
-        user.set_password("doe")
-        user.save()
-
-        resp = self.client.post(
-            reverse("account_login"), {"login": "john", "password": "doe"}, follow=True
-        )
 
+    # Ensure the signal is received as expected.
+    assert user_logged_in_count() == 1
+
+
+def test_not_configured_redirect(client, john):
+    """Viewing backup codes or disabling 2FA should redirect if 2FA is not
+    configured."""
+    login(client, expected_redirect_url=settings.LOGIN_REDIRECT_URL)
+
+    # The 2FA pages should redirect.
+    for url_name in ["two-factor-backup-tokens", "two-factor-remove"]:
+        resp = client.get(reverse(url_name))
+        assertRedirects(resp, TWO_FACTOR_SETUP_URL, fetch_redirect_response=False)
+
+
+class Require2FA(BaseRequire2FAMiddleware):
+    def require_2fa(self, request):
+        return True
+
+
+@pytest.mark.parametrize("with_messages", (False, True))
+def test_require_2fa_middleware(client, john, settings, with_messages):
+    new_middleware = [
+        # Add the middleware that requires 2FA.
+        "tests.test_allauth_2fa.Require2FA",
+    ]
+    new_installed_apps = []
+
+    if with_messages:
+        new_middleware.append("django.contrib.messages.middleware.MessageMiddleware")
+        new_installed_apps.append("django.contrib.messages")
+
+    with override_settings(
+        # Don't redirect to an "allowed" URL.
+        LOGIN_REDIRECT_URL="/unnamed-view",
+        MIDDLEWARE=settings.MIDDLEWARE + tuple(new_middleware),
+        INSTALLED_APPS=settings.INSTALLED_APPS + tuple(new_installed_apps),
+    ):
+        resp = client.post(LOGIN_URL, JOHN_CREDENTIALS, follow=True)
         # The user is redirected to the 2FA setup page.
-        self.assertRedirects(
-            resp, reverse("two-factor-setup"), fetch_redirect_response=False
-        )
+        # (In particular, see that the last redirect brought us to the 2FA setup page.)
+        assert resp.redirect_chain[-1][0] == TWO_FACTOR_SETUP_URL
+        # TODO: check messages?
diff --git a/tests/urls.py b/tests/urls.py
index a413f3f..7e11d54 100644
--- a/tests/urls.py
+++ b/tests/urls.py
@@ -1,3 +1,4 @@
+from django.contrib.auth.decorators import login_required
 from django.http import HttpResponse
 from django.urls import include
 from django.urls import path
@@ -7,10 +8,16 @@ def blank_view(request):
     return HttpResponse("<h1>HELLO WORLD!</h1>")
 
 
+@login_required
+def login_required_view(request):
+    return HttpResponse(f"<h1>Hello, {request.user}</h1>")
+
+
 urlpatterns = [
     # Include the allauth and 2FA urls from their respective packages.
     path("accounts/", include("allauth_2fa.urls")),
     path("accounts/", include("allauth.urls")),
     # A view without a name.
     path("unnamed-view", blank_view),
+    path("login-required-view", login_required_view, name="login-required-view"),
 ]