Просмотр исходного кода

Merge pull request #1169 from rafalp/move-misago-admin-to-pytests

Move misago admin to pytests
Rafał Pitoń 6 лет назад
Родитель
Сommit
da52d419b0

+ 1 - 1
devproject/urls.py

@@ -43,7 +43,7 @@ urlpatterns = [
         name="django-i18n",
     ),
     # Uncomment next line if you plan to use Django admin for 3rd party apps
-    # url(r'^django-admin/', admin.site.urls),
+    url(r"^django-admin/", admin.site.urls),
 ]
 
 

+ 21 - 22
misago/admin/auth.py

@@ -7,49 +7,48 @@ from django.utils.translation import gettext as _
 
 from ..conf import settings
 
-KEY_TOKEN = "misago_admin_session_token"
-KEY_UPDATED = "misago_admin_session_updated"
-
-
-def make_user_admin_token(user):
-    formula = (str(user.pk), user.email, user.password, settings.SECRET_KEY)
-    return md5(":".join(formula).encode()).hexdigest()
-
+TOKEN_KEY = "misago_admin_session_token"
+UPDATED_KEY = "misago_admin_session_updated"
 
 # Admin session state controls
-def is_admin_session(request):
+def is_admin_authorized(request):
     if request.user.is_anonymous:
         return False
 
     if not request.user.is_staff:
         return False
 
-    admin_token = request.session.get(KEY_TOKEN)
+    admin_token = request.session.get(TOKEN_KEY)
     if not admin_token == make_user_admin_token(request.user):
         return False
 
-    updated = request.session.get(KEY_UPDATED, 0)
+    updated = request.session.get(UPDATED_KEY, 0)
     if updated < time() - (settings.MISAGO_ADMIN_SESSION_EXPIRATION * 60):
         if updated:
-            request.session.pop(KEY_UPDATED, None)
+            request.session.pop(UPDATED_KEY, None)
             messages.info(request, _("Your admin session has expired."))
         return False
 
     return True
 
 
-def start_admin_session(request, user):
-    request.session[KEY_TOKEN] = make_user_admin_token(user)
-    request.session[KEY_UPDATED] = int(time())
+def authorize_admin(request):
+    request.session[TOKEN_KEY] = make_user_admin_token(request.user)
+    request.session[UPDATED_KEY] = int(time())
 
 
-def update_admin_session(request):
-    request.session[KEY_UPDATED] = int(time())
+def update_admin_authorization(request):
+    request.session[UPDATED_KEY] = int(time())
 
 
-def close_admin_session(request):
-    request.session.pop(KEY_TOKEN, None)
-    request.session.pop(KEY_UPDATED, None)
+def remove_admin_authorization(request):
+    request.session.pop(TOKEN_KEY, None)
+    request.session.pop(UPDATED_KEY, None)
+
+
+def make_user_admin_token(user):
+    formula = (str(user.pk), user.email, user.password, settings.SECRET_KEY)
+    return md5(":".join(formula).encode()).hexdigest()
 
 
 # Login/logout exposed
@@ -65,14 +64,14 @@ def django_login_handler(sender, **kwargs):
     except AttributeError:
         admin_namespace = False
     if admin_namespace and user.is_staff:
-        start_admin_session(request, user)
+        authorize_admin(request)
 
 
 dj_auth.signals.user_logged_in.connect(django_login_handler)
 
 
 def django_logout_handler(sender, **kwargs):
-    close_admin_session(kwargs["request"])
+    remove_admin_authorization(kwargs["request"])
 
 
 dj_auth.signals.user_logged_out.connect(django_logout_handler)

+ 15 - 7
misago/admin/middleware.py

@@ -1,7 +1,11 @@
 from django.shortcuts import redirect
 from django.utils.deprecation import MiddlewareMixin
 
-from . import auth
+from .auth import (
+    is_admin_authorized,
+    remove_admin_authorization,
+    update_admin_authorization,
+)
 from .views import get_protected_namespace
 from .views.auth import login
 
@@ -10,9 +14,13 @@ class AdminAuthMiddleware(MiddlewareMixin):
     def process_view(self, request, view_func, view_args, view_kwargs):
         request.admin_namespace = get_protected_namespace(request)
         if request.admin_namespace:
-            if not auth.is_admin_session(request):
-                auth.close_admin_session(request)
-                if request.resolver_match.url_name == "index":
-                    return login(request)
-                return redirect("%s:index" % request.admin_namespace)
-            auth.update_admin_session(request)
+            return self.check_admin_authorization(request)
+
+    def check_admin_authorization(self, request):
+        if not is_admin_authorized(request):
+            remove_admin_authorization(request)
+            if request.resolver_match.url_name == "index":
+                return login(request)
+            return redirect("%s:index" % request.admin_namespace)
+
+        update_admin_authorization(request)

+ 9 - 0
misago/admin/tests/test_404_view.py

@@ -0,0 +1,9 @@
+from django.urls import reverse
+
+from ...test import assert_contains
+
+
+def test_admin_displays_own_error_page_on_404_error(admin_client):
+    response = admin_client.get(reverse("misago:admin:index") + "404-not-found/")
+    assert_contains(response, "Administration", 404)
+    assert_contains(response, "Requested page could not be found.", 404)

+ 0 - 44
misago/admin/tests/test_admin_hierarchy.py

@@ -1,44 +0,0 @@
-from django.test import TestCase
-
-from ..hierarchy import Node
-
-
-class NodeTests(TestCase):
-    def test_add_node(self):
-        """add_node added node"""
-        master = Node(name="Apples", link="misago:index")
-
-        child = Node(name="Oranges", link="misago:index")
-        master.add_node(child)
-
-        self.assertTrue(child in master.children())
-
-    def test_add_node_after(self):
-        """add_node added node after specific node"""
-        master = Node(name="Apples", link="misago:index")
-
-        child = Node(name="Oranges", link="misago:index")
-        master.add_node(child)
-
-        test = Node(name="Potatoes", link="misago:index")
-        master.add_node(test, after="misago:index")
-
-        all_nodes = master.children()
-        for i, node in enumerate(all_nodes):
-            if node.name == test.name:
-                self.assertEqual(all_nodes[i - 1].name, child.name)
-
-    def test_add_node_before(self):
-        """add_node added node  before specific node"""
-        master = Node(name="Apples", link="misago:index")
-
-        child = Node(name="Oranges", link="misago:index")
-        master.add_node(child)
-
-        test = Node(name="Potatoes", link="misago:index")
-        master.add_node(test, before="misago:index")
-
-        all_nodes = master.children()
-        for i, node in enumerate(all_nodes):
-            if node.name == test.name:
-                self.assertEqual(all_nodes[i + 1].name, child.name)

+ 0 - 76
misago/admin/tests/test_admin_index.py

@@ -1,76 +0,0 @@
-from django.test import TestCase, override_settings
-from django.urls import reverse
-
-from ..test import AdminTestCase
-from ..views.index import check_misago_address
-
-
-class AdminIndexViewTests(AdminTestCase):
-    def test_view_returns_200(self):
-        """admin index view returns 200"""
-        response = self.client.get(reverse("misago:admin:index"))
-
-        self.assertContains(response, self.user.username)
-
-    def test_view_contains_address_check(self):
-        """admin index view contains address check"""
-        response = self.client.get(reverse("misago:admin:index"))
-
-        self.assertContains(response, "MISAGO_ADDRESS")
-
-
-class RequestMock:
-    absolute_uri = "https://misago-project.org/somewhere/"
-
-    def build_absolute_uri(self, location):
-        assert location == "/"
-        return self.absolute_uri
-
-
-request = RequestMock()
-incorrect_address = "http://somewhere.com"
-correct_address = request.absolute_uri
-
-
-class AdminIndexAddressCheckTests(TestCase):
-    @override_settings(MISAGO_ADDRESS=None)
-    def test_address_not_set(self):
-        """check handles address not set"""
-        result = check_misago_address(request)
-
-        self.assertEqual(
-            result,
-            {
-                "is_correct": False,
-                "set_address": None,
-                "correct_address": request.absolute_uri,
-            },
-        )
-
-    @override_settings(MISAGO_ADDRESS=incorrect_address)
-    def test_address_set_invalid(self):
-        """check handles incorrect address"""
-        result = check_misago_address(request)
-
-        self.assertEqual(
-            result,
-            {
-                "is_correct": False,
-                "set_address": incorrect_address,
-                "correct_address": request.absolute_uri,
-            },
-        )
-
-    @override_settings(MISAGO_ADDRESS=correct_address)
-    def test_address_set_valid(self):
-        """check handles correct address"""
-        result = check_misago_address(request)
-
-        self.assertEqual(
-            result,
-            {
-                "is_correct": True,
-                "set_address": correct_address,
-                "correct_address": request.absolute_uri,
-            },
-        )

+ 60 - 0
misago/admin/tests/test_admin_index_view.py

@@ -0,0 +1,60 @@
+from django.test import override_settings
+from django.urls import reverse
+
+from ...test import assert_contains
+from ..views.index import check_misago_address
+
+admin_link = reverse("misago:admin:index")
+
+
+def test_view_has_no_showstoppers(admin_client):
+    response = admin_client.get(admin_link)
+    assert response.status_code == 200
+
+
+def test_view_has_misago_address_check(admin_client):
+    response = admin_client.get(admin_link)
+    assert_contains(response, "MISAGO_ADDRESS")
+
+
+class RequestMock:
+    absolute_uri = "https://misago-project.org/somewhere/"
+
+    def build_absolute_uri(self, location):
+        assert location == "/"
+        return self.absolute_uri
+
+
+request = RequestMock()
+incorrect_address = "http://somewhere.com"
+correct_address = request.absolute_uri
+
+
+@override_settings(MISAGO_ADDRESS=None)
+def test_misago_address_check_handles_setting_not_configured():
+    result = check_misago_address(request)
+    assert result == {
+        "is_correct": False,
+        "set_address": None,
+        "correct_address": request.absolute_uri,
+    }
+
+
+@override_settings(MISAGO_ADDRESS=incorrect_address)
+def test_misago_address_check_detects_invalid_address_configuration():
+    result = check_misago_address(request)
+    assert result == {
+        "is_correct": False,
+        "set_address": incorrect_address,
+        "correct_address": request.absolute_uri,
+    }
+
+
+@override_settings(MISAGO_ADDRESS=correct_address)
+def test_misago_address_check_detects_valid_address_configuration():
+    result = check_misago_address(request)
+    assert result == {
+        "is_correct": True,
+        "set_address": correct_address,
+        "correct_address": request.absolute_uri,
+    }

+ 41 - 0
misago/admin/tests/test_admin_site_hierarchy.py

@@ -0,0 +1,41 @@
+from ..hierarchy import Node
+
+
+def test_node_is_added_at_end_of_parent_children():
+    master = Node(name="Apples", link="misago:index")
+    child = Node(name="Oranges", link="misago:index")
+    master.add_node(child)
+
+    assert master.children()[-1].name == child.name
+
+
+def test_add_node_after():
+    """add_node added node after specific node"""
+    master = Node(name="Apples", link="misago:index")
+
+    child = Node(name="Oranges", link="misago:index")
+    master.add_node(child)
+
+    test = Node(name="Potatoes", link="misago:index")
+    master.add_node(test, after="misago:index")
+
+    all_nodes = master.children()
+    for i, node in enumerate(all_nodes):
+        if node.name == test.name:
+            assert all_nodes[i - 1].name == child.name
+
+
+def test_add_node_before():
+    """add_node added node  before specific node"""
+    master = Node(name="Apples", link="misago:index")
+
+    child = Node(name="Oranges", link="misago:index")
+    master.add_node(child)
+
+    test = Node(name="Potatoes", link="misago:index")
+    master.add_node(test, before="misago:index")
+
+    all_nodes = master.children()
+    for i, node in enumerate(all_nodes):
+        if node.name == test.name:
+            assert all_nodes[i + 1].name == child.name

+ 0 - 207
misago/admin/tests/test_admin_views.py

@@ -1,207 +0,0 @@
-from django.test import TestCase
-from django.urls import reverse
-
-from ...users.test import create_test_user
-from ..test import AdminTestCase
-from ..views import get_protected_namespace
-
-
-class MockRequest:
-    def __init__(self, path):
-        self.path = path
-
-
-class AdminProtectedNamespaceTests(TestCase):
-    def test_valid_cases(self):
-        """get_protected_namespace returns true for protected links"""
-        TEST_CASES = ("", "somewhere/", "ejksajdlksajldjskajdlksajlkdas")
-
-        links_prefix = reverse("misago:admin:index")
-
-        for case in TEST_CASES:
-            request = MockRequest(links_prefix + case)
-            self.assertEqual(get_protected_namespace(request), "misago:admin")
-
-    def test_invalid_cases(self):
-        """get_protected_namespace returns none for other links"""
-        TEST_CASES = ("/", "/somewhere/", "/ejksajdlksajldjskajdlksajlkdas")
-
-        for case in TEST_CASES:
-            request = MockRequest(case)
-            self.assertEqual(get_protected_namespace(request), None)
-
-
-class AdminLoginViewTests(TestCase):
-    def test_login_returns_200_on_get(self):
-        """unauthenticated request to admin index produces login form"""
-        response = self.client.get(reverse("misago:admin:index"))
-
-        self.assertContains(response, "Sign in")
-        self.assertContains(response, "Username or e-mail")
-        self.assertContains(response, "Password")
-
-    def test_login_returns_200_on_invalid_post(self):
-        """form handles invalid data gracefully"""
-        response = self.client.post(
-            reverse("misago:admin:index"), data={"username": "no", "password": "no"}
-        )
-
-        self.assertContains(response, "Login or password is incorrect.")
-        self.assertContains(response, "Sign in")
-        self.assertContains(response, "Username or e-mail")
-        self.assertContains(response, "Password")
-
-    def test_login_denies_non_staff_non_superuser(self):
-        """login rejects user thats non staff and non superuser"""
-        user = create_test_user("User", "user@example.com", "password")
-
-        user.is_staff = False
-        user.is_superuser = False
-        user.save()
-
-        response = self.client.post(
-            reverse("misago:admin:index"),
-            data={"username": "User", "password": "password"},
-        )
-
-        self.assertContains(response, "Your account does not have admin privileges.")
-
-    def test_login_denies_non_staff_superuser(self):
-        """login rejects user thats non staff and superuser"""
-        user = create_test_user("User", "user@example.com", "password")
-
-        user.is_staff = False
-        user.is_superuser = True
-        user.save()
-
-        response = self.client.post(
-            reverse("misago:admin:index"),
-            data={"username": "User", "password": "password"},
-        )
-
-        self.assertContains(response, "Your account does not have admin privileges.")
-
-    def test_login_signs_in_staff_non_superuser(self):
-        """login passess user thats staff and non superuser"""
-        user = create_test_user("User", "user@example.com", "password")
-
-        user.is_staff = True
-        user.is_superuser = False
-        user.save()
-
-        response = self.client.post(
-            reverse("misago:admin:index"),
-            data={"username": "User", "password": "password"},
-        )
-
-        self.assertEqual(response.status_code, 302)
-
-    def test_login_signs_in_staff_superuser(self):
-        """login passess user thats staff and superuser"""
-        user = create_test_user("User", "user@example.com", "password")
-
-        user.is_staff = True
-        user.is_superuser = True
-        user.save()
-
-        response = self.client.post(
-            reverse("misago:admin:index"),
-            data={"username": "User", "password": "password"},
-        )
-
-        self.assertEqual(response.status_code, 302)
-
-
-class AdminLogoutTests(AdminTestCase):
-    def test_admin_logout(self):
-        """admin logout logged from admin only"""
-        response = self.client.post(reverse("misago:admin:logout"))
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "Your admin session has been closed.")
-
-        response = self.client.get(reverse("misago:index"))
-        self.assertContains(response, self.user.username)
-
-    def test_complete_logout(self):
-        """complete logout logged from both admin and site"""
-        response = self.client.post(reverse("misago:logout"))
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "Sign in")
-
-        response = self.client.get(reverse("misago:index"))
-        self.assertContains(response, "Sign in")
-
-
-class AdminViewAccessTests(AdminTestCase):
-    def test_admin_denies_non_staff_non_superuser(self):
-        """admin middleware rejects user thats non staff and non superuser"""
-        self.user.is_staff = False
-        self.user.is_superuser = False
-        self.user.save()
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "Sign in")
-
-    def test_admin_denies_non_staff_superuser(self):
-        """admin middleware rejects user thats non staff and superuser"""
-        self.user.is_staff = False
-        self.user.is_superuser = True
-        self.user.save()
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "Sign in")
-
-    def test_admin_passess_in_staff_non_superuser(self):
-        """admin middleware passess user thats staff and non superuser"""
-        self.user.is_staff = True
-        self.user.is_superuser = False
-        self.user.save()
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, self.user.username)
-
-    def test_admin_passess_in_staff_superuser(self):
-        """admin middleware passess user thats staff and superuser"""
-        self.user.is_staff = True
-        self.user.is_superuser = True
-        self.user.save()
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, self.user.username)
-
-
-class Admin404ErrorTests(AdminTestCase):
-    def test_list_search_unicode_handling(self):
-        """querystring creation handles unicode strings"""
-        test_link = "%stotally-errored/" % reverse("misago:admin:index")
-
-        response = self.client.get(test_link)
-
-        self.assertContains(
-            response, "Requested page could not be found.", status_code=404
-        )
-
-
-class AdminGenericViewsTests(AdminTestCase):
-    def test_view_redirected_queryvar(self):
-        """querystring redirected value is handled"""
-        test_link = reverse("misago:admin:users:accounts:index")
-
-        # request resulted in redirect with redirected=1 bit
-        response = self.client.get("%s?username=lorem" % test_link)
-        self.assertEqual(response.status_code, 302)
-        self.assertIn("redirected=1", response["location"])
-
-        # request with flag muted redirect
-        response = self.client.get("%s?redirected=1&username=lorem" % test_link)
-        self.assertEqual(response.status_code, 200)
-
-    def test_list_search_unicode_handling(self):
-        """querystring creation handles unicode strings"""
-        test_link = reverse("misago:admin:users:accounts:index")
-        response = self.client.get("%s?redirected=1&username=%s" % (test_link, "łut"))
-        self.assertEqual(response.status_code, 200)

+ 43 - 0
misago/admin/tests/test_admin_views_are_protected.py

@@ -0,0 +1,43 @@
+from django.urls import reverse
+
+from ...test import assert_contains, assert_not_contains
+from ..auth import is_admin_authorized
+
+admin_link = reverse("misago:admin:index")
+
+
+def assert_requires_admin_login(response):
+    assert response.status_code == 200
+    assert_contains(response, "Administration")
+    assert_contains(response, "Sign in")
+
+
+def test_anonymous_user_is_asked_to_login_to_access_admin_view(db, client):
+    response = client.get(admin_link)
+    assert_requires_admin_login(response)
+
+
+def test_authenticated_user_is_asked_to_login_to_access_admin_view(client, user):
+    client.force_login(user)
+    response = client.get(admin_link)
+    assert_requires_admin_login(response)
+
+
+def test_unathorized_admin_is_asked_to_login_to_access_admin_view(client, superuser):
+    client.force_login(superuser)
+    response = client.get(admin_link)
+    assert_requires_admin_login(response)
+
+
+def test_authorized_admin_is_allowed_to_access_admin_view(admin_client):
+    response = admin_client.get(admin_link)
+    assert is_admin_authorized(response.wsgi_request)
+    assert_not_contains(response, "Sign in")
+
+
+def test_admin_authorization_is_checked_on_admin_view_access(mocker, client, user):
+    admin_authorization = mocker.patch(
+        "misago.admin.middleware.is_admin_authorized", return_value=False
+    )
+    response = client.get(admin_link)
+    admin_authorization.assert_called_once_with(response.wsgi_request)

+ 111 - 0
misago/admin/tests/test_authorization.py

@@ -0,0 +1,111 @@
+from time import time
+from unittest.mock import Mock
+
+import pytest
+from django.contrib.messages import get_messages
+from django.test import override_settings
+
+from ..auth import (
+    TOKEN_KEY,
+    UPDATED_KEY,
+    authorize_admin,
+    is_admin_authorized,
+    remove_admin_authorization,
+    update_admin_authorization,
+)
+
+
+@pytest.fixture
+def admin_request(superuser):
+    request = Mock(session={}, user=superuser)
+    authorize_admin(request)
+    return request
+
+
+def test_authorizing_admin_updates_request_session(user):
+    request = Mock(session={}, user=user)
+    authorize_admin(request)
+    assert request.session
+
+
+def test_staff_user_can_be_authorized(staffuser):
+    request = Mock(session={}, user=staffuser)
+    authorize_admin(request)
+    assert is_admin_authorized(request)
+
+
+def test_non_staff_user_admin_authorization_is_never_valid(user):
+    request = Mock(session={}, user=user)
+    authorize_admin(request)
+    assert not is_admin_authorized(request)
+
+
+def test_anonymous_user_admin_authorization_is_never_valid(user, anonymous_user):
+    request = Mock(session={}, user=user)
+    authorize_admin(request)
+    request.user = anonymous_user
+    assert not is_admin_authorized(request)
+
+
+def test_superuser_without_staff_flag_admin_authorization_is_never_valid(staffuser):
+    request = Mock(session={}, user=staffuser)
+    authorize_admin(request)
+    request.user.is_staff = False
+    assert not is_admin_authorized(request)
+
+
+def test_admin_authorization_is_invalidated_by_user_pk_change(admin_request, superuser):
+    admin_request.user.pk = superuser.pk + 1
+    assert not is_admin_authorized(admin_request)
+
+
+def test_admin_authorization_is_invalidated_by_user_email_change(admin_request):
+    admin_request.user.email = "changed@example.com"
+    assert not is_admin_authorized(admin_request)
+
+
+def test_admin_authorization_is_invalidated_by_user_password_change(admin_request):
+    admin_request.user.set_password("changed-password")
+    assert not is_admin_authorized(admin_request)
+
+
+def test_admin_authorization_is_invalidated_by_secret_key_change(admin_request):
+    with override_settings(SECRET_KEY="changed-secret-key"):
+        assert not is_admin_authorized(admin_request)
+
+
+def test_admin_authorization_is_invalidated_by_token_change(admin_request):
+    admin_request.session[TOKEN_KEY] = "authorization-token-changed"
+    assert not is_admin_authorized(admin_request)
+
+
+@override_settings(MISAGO_ADMIN_SESSION_EXPIRATION=5)
+def test_admin_authorization_is_invalidated_by_token_expiration(admin_request):
+    admin_request.session[UPDATED_KEY] = time() - 5 * 60 - 1
+    assert not is_admin_authorized(admin_request)
+
+
+def test_updating_authorization_extends_authorization_expiration_time(admin_request):
+    admin_request.session[UPDATED_KEY] = 0
+    update_admin_authorization(admin_request)
+    assert admin_request.session[UPDATED_KEY]
+
+
+def test_updating_authorization_validates_authorization(admin_request):
+    admin_request.session[UPDATED_KEY] = 0
+    update_admin_authorization(admin_request)
+    assert is_admin_authorized(admin_request)
+
+
+def test_removing_authorization_removes_autorization_from_request_session(
+    admin_request
+):
+    admin_request.session[UPDATED_KEY] = 0
+    remove_admin_authorization(admin_request)
+    assert not admin_request.session
+
+
+def test_removing_authorization_invalidates_autorization(admin_request):
+    admin_request.session[UPDATED_KEY] = 0
+    remove_admin_authorization(admin_request)
+    assert not is_admin_authorized(admin_request)

+ 0 - 28
misago/admin/tests/test_forms.py

@@ -1,28 +0,0 @@
-from django import forms
-from django.test import TestCase
-
-from ..forms import YesNoSwitch
-
-
-class YesNoForm(forms.Form):
-    test_field = YesNoSwitch(label="Hello!")
-
-
-class YesNoSwitchTests(TestCase):
-    def test_valid_inputs(self):
-        """YesNoSwitch returns valid values for valid input"""
-        for true in ("1", "True", "true", 1, True):
-            form = YesNoForm({"test_field": true})
-            form.full_clean()
-            self.assertEqual(form.cleaned_data["test_field"], 1)
-
-        for false in ("0", "False", "false", "egebege", False, 0):
-            form = YesNoForm({"test_field": false})
-            form.full_clean()
-            self.assertEqual(form.cleaned_data["test_field"], 0)
-
-    def test_dontstripme_input_is_ignored(self):
-        """YesNoSwitch returns valid values for invalid input"""
-        form = YesNoForm({"test_field": "221"})
-        form.full_clean()
-        self.assertFalse(form.cleaned_data.get("test_field"))

+ 39 - 0
misago/admin/tests/test_generic_admin_list_view.py

@@ -0,0 +1,39 @@
+from urllib.parse import urlencode
+
+from django.urls import reverse
+
+list_link = reverse("misago:admin:users:accounts:index")
+
+
+def test_view_redirects_if_redirected_flag_is_not_present_in_querystring(admin_client):
+    response = admin_client.get(list_link)
+    assert response.status_code == 302
+
+
+def test_view_sets_redirect_flag_in_redirect_url(admin_client):
+    response = admin_client.get(list_link)
+    assert response.status_code == 302
+    assert "redirected=1" in response["location"]
+
+
+def test_view_checks_only_redirect_flag_presence_and_not_value(admin_client):
+    response = admin_client.get(list_link + "?redirected")
+    assert response.status_code == 200
+
+
+def test_view_preserves_rest_of_querystring_in_redirect_url(admin_client):
+    response = admin_client.get(list_link + "?username=test")
+    assert response.status_code == 302
+    assert "redirected=1" in response["location"]
+    assert "username=test" in response["location"]
+
+
+def test_unicode_is_preserved_in_redirect_querystring(admin_client):
+    response = admin_client.get(list_link + "?username=łóć")
+    assert response.status_code == 302
+    assert urlencode({"username": "łóć"}) in response["location"]
+
+
+def test_view_is_not_redirecting_if_flag_is_set_in_querystring(admin_client):
+    response = admin_client.get("%s?redirected=1" % list_link)
+    assert response.status_code == 200

+ 63 - 0
misago/admin/tests/test_login_to_admin.py

@@ -0,0 +1,63 @@
+from django.urls import reverse
+
+from ...test import assert_contains
+from ..auth import is_admin_authorized
+
+admin_link = reverse("misago:admin:index")
+
+
+def test_login_form_is_displayed(db, client):
+    response = client.get(admin_link)
+    assert response.status_code == 200
+    assert_contains(response, "Administration")
+    assert_contains(response, "Sign in")
+    assert_contains(response, "Username or e-mail")
+    assert_contains(response, "Password")
+
+
+def test_attempt_to_login_using_invalid_credentials_fails(db, client):
+    response = client.post(admin_link, {"username": "no", "password": "no"})
+    assert_contains(response, "Login or password is incorrect.")
+
+
+def test_attempt_to_login_using_invalid_password_fails(client, superuser):
+    response = client.post(
+        admin_link, {"username": superuser.username, "password": "no"}
+    )
+    assert_contains(response, "Login or password is incorrect.")
+
+
+def test_attempt_to_login_without_staff_status_fails(client, user, user_password):
+    response = client.post(
+        admin_link, {"username": user.username, "password": user_password}
+    )
+    assert_contains(response, "Your account does not have admin privileges.")
+
+
+def test_attempt_to_login_as_superuser_without_staff_status_fails(
+    client, user, user_password
+):
+    user.is_superuser = True
+    user.save()
+
+    response = client.post(
+        admin_link, {"username": user.username, "password": user_password}
+    )
+    assert_contains(response, "Your account does not have admin privileges.")
+
+
+def test_user_with_staff_status_is_logged_to_admin(client, staffuser, user_password):
+    response = client.post(
+        admin_link, {"username": staffuser.username, "password": user_password}
+    )
+    assert is_admin_authorized(response.wsgi_request)
+    assert response.wsgi_request.user == staffuser
+
+
+def test_login_form_redirects_user_to_admin_index_after_successful_login(
+    client, staffuser, user_password
+):
+    response = client.post(
+        admin_link, {"username": staffuser.username, "password": user_password}
+    )
+    assert response["location"] == admin_link

+ 39 - 0
misago/admin/tests/test_logout_from_admin.py

@@ -0,0 +1,39 @@
+from django.contrib.messages import get_messages
+from django.urls import reverse
+
+from ...test import assert_contains
+from ..auth import is_admin_authorized
+
+admin_logout_link = reverse("misago:admin:logout")
+site_logout_link = reverse("misago:logout")
+site_link = reverse("misago:index")
+
+
+def test_admin_can_logout_from_admin_site_but_stay_logged(admin_client, superuser):
+    response = admin_client.post(admin_logout_link)
+    assert response.wsgi_request.user == superuser
+    assert not is_admin_authorized(response.wsgi_request)
+
+
+def test_admin_is_redirected_to_site_on_logout(admin_client, superuser):
+    response = admin_client.post(admin_logout_link)
+    assert response.status_code == 302
+    assert response["location"] == site_link
+
+
+def test_admin_is_displayed_a_message_after_logout(admin_client, superuser):
+    response = admin_client.post(admin_logout_link)
+    message = list(get_messages(response.wsgi_request))[0]
+    assert str(message) == "Your admin session has been closed."
+
+
+def test_admin_can_logout_from_entire_site(admin_client):
+    response = admin_client.post(site_logout_link)
+    assert response.wsgi_request.user.is_anonymous
+    assert not is_admin_authorized(response.wsgi_request)
+
+
+def test_admin_is_redirected_to_site_on_logout(admin_client, superuser):
+    response = admin_client.post(admin_logout_link)
+    assert response.status_code == 302
+    assert response["location"] == site_link

+ 49 - 0
misago/admin/tests/test_protected_urls_detection.py

@@ -0,0 +1,49 @@
+from unittest.mock import Mock
+
+from django.urls import reverse
+
+from ..views import get_protected_namespace
+
+django_admin_url = reverse("admin:index")
+misago_admin_url = reverse("misago:admin:index")
+site_url = reverse("misago:index")
+
+
+def test_request_to_misago_admin_root_url_is_protected():
+    request = Mock(path=misago_admin_url)
+    assert get_protected_namespace(request) == "misago:admin"
+
+
+def test_request_to_misago_admin_subpath_url_is_protected():
+    request = Mock(path=misago_admin_url + "users/")
+    assert get_protected_namespace(request) == "misago:admin"
+
+
+def test_request_to_django_admin_root_url_is_protected():
+    request = Mock(path=django_admin_url)
+    assert get_protected_namespace(request) == "admin"
+
+
+def test_request_to_django_admin_subpath_url_is_protected():
+    request = Mock(path=django_admin_url + "users/")
+    assert get_protected_namespace(request) == "admin"
+
+
+def test_request_to_django_admin_subpath_url_is_protected():
+    request = Mock(path=django_admin_url + "users/")
+    assert get_protected_namespace(request) == "admin"
+
+
+def test_request_to_site_root_url_is_not_protected():
+    request = Mock(path=site_url)
+    assert get_protected_namespace(request) is None
+
+
+def test_request_to_site_subpath_url_is_not_protected():
+    request = Mock(path=site_url + "t/some-thread-123/")
+    assert get_protected_namespace(request) is None
+
+
+def test_request_to_site_non_reversable_url_is_not_protected():
+    request = Mock(path=site_url + "somewhere-custom/")
+    assert get_protected_namespace(request) is None

+ 27 - 0
misago/admin/tests/test_yesno_switch.py

@@ -0,0 +1,27 @@
+from django import forms
+
+from ..forms import YesNoSwitch
+
+
+class YesNoForm(forms.Form):
+    test_field = YesNoSwitch(label="Hello!")
+
+
+def test_input_returns_int_true_for_valid_true_input():
+    for value in ("1", "True", "true", 1, True):
+        form = YesNoForm({"test_field": value})
+        form.full_clean()
+        assert form.cleaned_data["test_field"] == 1
+
+
+def test_input_returns_int_false_for_false_input():
+    for value in ("0", "False", "false", False, 0, None, ""):
+        form = YesNoForm({"test_field": value})
+        form.full_clean()
+        assert form.cleaned_data["test_field"] == 0
+
+
+def test_input_returns_int_false_for_invalid_input():
+    form = YesNoForm({"test_field": "invalid"})
+    form.full_clean()
+    assert form.cleaned_data["test_field"] == 0

+ 3 - 3
misago/admin/views/__init__.py

@@ -3,7 +3,7 @@ from django.shortcuts import render as dj_render
 
 from ...conf import settings
 from .. import site
-from ..auth import is_admin_session, update_admin_session
+from ..auth import is_admin_authorized, update_admin_authorization
 from .auth import login
 
 
@@ -56,8 +56,8 @@ def protected_admin_view(f):
     def decorator(request, *args, **kwargs):
         protected_view = get_protected_namespace(request)
         if protected_view:
-            if is_admin_session(request):
-                update_admin_session(request)
+            if is_admin_authorized(request):
+                update_admin_authorization(request)
                 return f(request, *args, **kwargs)
             request.admin_namespace = protected_view
             return login(request)

+ 1 - 1
misago/admin/views/auth.py

@@ -35,7 +35,7 @@ def login(request):
 @never_cache
 def logout(request):
     if request.method == "POST":
-        auth.close_admin_session(request)
+        auth.remove_admin_authorization(request)
         messages.info(request, _("Your admin session has been closed."))
         return redirect("misago:index")
     return redirect("misago:admin:index")

+ 5 - 5
misago/admin/views/errorpages.py

@@ -2,12 +2,12 @@ from django.shortcuts import redirect
 
 from . import get_protected_namespace, protected_admin_view, render
 from ...core.utils import get_exception_message
-from ..auth import is_admin_session, update_admin_session
+from ..auth import is_admin_authorized, update_admin_authorization
 
 # Magic error page used by admin
 @protected_admin_view
 def _error_page(request, code, exception=None, default_message=None):
-    if not is_admin_session(request):
+    if not is_admin_authorized(request):
         return redirect("misago:admin:index")
 
     template_pattern = "misago/admin/errorpages/%s.html" % code
@@ -25,7 +25,7 @@ def _error_page(request, code, exception=None, default_message=None):
 def admin_error_page(f):
     def decorator(request, *args, **kwargs):
         if get_protected_namespace(request):
-            update_admin_session(request)
+            update_admin_authorization(request)
             return _error_page(request, *args, **kwargs)
         return f(request, *args, **kwargs)
 
@@ -34,8 +34,8 @@ def admin_error_page(f):
 
 # Magic CSRF fail page for Admin
 def _csrf_failure(request, reason=""):
-    if is_admin_session(request):
-        update_admin_session(request)
+    if is_admin_authorized(request):
+        update_admin_authorization(request)
         response = render(
             request,
             "misago/admin/errorpages/csrf_failure_authenticated.html",

+ 1 - 1
misago/admin/views/generic/list.py

@@ -152,7 +152,7 @@ class ListView(AdminView):
                     "%s%s" % (reverse(self.root_link), context["querystring"])
                 )
 
-        if refresh_querystring and not request.GET.get("redirected"):
+        if refresh_querystring and "redirected" not in request.GET:
             return redirect("%s%s" % (request.path, context["querystring"]))
 
         return self.render(request, context)

+ 10 - 0
misago/conftest.py

@@ -1,6 +1,7 @@
 import pytest
 
 from .acl import ACL_CACHE, useracl
+from .admin.auth import authorize_admin
 from .conf import SETTINGS_CACHE
 from .conf.dynamicsettings import DynamicSettings
 from .conf.staticsettings import StaticSettings
@@ -79,3 +80,12 @@ def superuser(db, user_password):
 @pytest.fixture
 def superuser_acl(superuser, cache_versions):
     return useracl.get_user_acl(superuser, cache_versions)
+
+
+@pytest.fixture
+def admin_client(mocker, client, superuser):
+    client.force_login(superuser)
+    session = client.session
+    authorize_admin(mocker.Mock(session=session, user=superuser))
+    session.save()
+    return client

+ 10 - 0
misago/test.py

@@ -0,0 +1,10 @@
+def assert_contains(response, string, status_code=200):
+    assert response.status_code == status_code
+    fail_message = f'"{string}" not found in response.content'
+    assert string in response.content.decode("utf-8"), fail_message
+
+
+def assert_not_contains(response, string, status_code=200):
+    assert response.status_code == status_code
+    fail_message = f'"{string}" was found in response.content'
+    assert string not in response.content.decode("utf-8"), fail_message

+ 5 - 7
misago/users/views/admin/users.py

@@ -6,7 +6,7 @@ from django.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 
 from ....acl.useracl import get_user_acl
-from ....admin.auth import start_admin_session
+from ....admin.auth import authorize_admin
 from ....admin.views import generic
 from ....categories.models import Category
 from ....core.mail import mail_users
@@ -304,14 +304,8 @@ class EditUser(UserAdmin, generic.ModelFormView):
         if form.cleaned_data.get("new_password"):
             target.set_password(form.cleaned_data["new_password"])
 
-            if target.pk == request.user.pk:
-                start_admin_session(request, target)
-                update_session_auth_hash(request, target)
-
         if form.cleaned_data.get("email"):
             target.set_email(form.cleaned_data["email"])
-            if target.pk == request.user.pk:
-                start_admin_session(request, target)
 
         if form.cleaned_data.get("is_avatar_locked"):
             if not target.old_is_avatar_locked:
@@ -342,6 +336,10 @@ class EditUser(UserAdmin, generic.ModelFormView):
         target.update_acl_key()
         target.save()
 
+        if target.pk == request.user.pk:
+            authorize_admin(request)
+            update_session_auth_hash(request, target)
+
         messages.success(request, self.message_submit % {"user": target.username})