"""Use real row locks to exercise concurrent administrative mutations.""" from concurrent.futures import ThreadPoolExecutor from threading import Event from apps.core.exceptions import PermissionDeniedError from apps.user.admin import UserAdmin from apps.user.models import User from apps.user.services import UserService from django.contrib.admin import AdminSite from django.db import close_old_connections, connection, transaction from django.test import RequestFactory, TransactionTestCase, skipUnlessDBFeature from django.urls import reverse from rest_framework.test import APIClient from tests.apps.user.factories import UserFactory @skipUnlessDBFeature("has_select_for_update") class UserManagementConcurrencyTest(TransactionTestCase): """A request authenticated before deletion must lose mutation privileges.""" def _assert_deleted_actor_cannot_mutate(self, operation: str) -> None: actor = UserFactory.create_superuser() removed_actor = UserFactory.create_superuser() actor_id, removed_id = actor.pk, removed_actor.pk first_deleted = Event() second_lock_query = Event() release_first = Event() def delete_other_actor(): close_old_connections() try: with transaction.atomic(): UserService.delete_user(removed_id, actor_id=actor_id) first_deleted.set() if not release_first.wait(10): raise AssertionError("Concurrent test did not release deletion") finally: connection.close() def observe_lock(execute, sql, params, many, context): if "FOR UPDATE" in sql: second_lock_query.set() return execute(sql, params, many, context) def stale_request(): close_old_connections() try: with connection.execute_wrapper(observe_lock): if operation == "admin-deactivate": request = RequestFactory().post("/admin/user/user/") request.user = removed_actor model_admin = UserAdmin(User, AdminSite()) try: model_admin.deactivate_users( request, User.objects.filter(pk=actor_id) ) except PermissionDeniedError: return 403 return 200 client = APIClient() client.force_authenticate(removed_actor) if operation == "self-password": return client.post( reverse("api_v1:user:password_change"), { "old_password": "testpass123", "new_password": "changed-test-password", "new_password_confirm": "changed-test-password", }, format="json", ).status_code if operation == "self-update": return client.patch( reverse("api_v1:user:user_update"), { "username": "replacement-admin", }, format="json", ).status_code if operation == "create-admin": return client.post( reverse("api_v1:user:admin_users"), { "username": "replacement-admin", "email": "replacement@example.test", "password": "integration-test-password", "role": "admin", "first_name": "Test", "last_name": "Admin", }, format="json", ).status_code url = reverse("api_v1:user:admin-user-detail", args=[actor_id]) if operation == "delete": return client.delete(url).status_code if operation == "demote": return client.patch( url, {"role": "user"}, format="json" ).status_code if operation == "patch-inactive": return client.patch( url, {"is_active": False}, format="json" ).status_code url = reverse("api_v1:user:admin-user-deactivate", args=[actor_id]) return client.post(url).status_code finally: connection.close() with ThreadPoolExecutor(max_workers=2) as pool: first = pool.submit(delete_other_actor) try: self.assertTrue( first_deleted.wait(10), "First deletion did not reach its transaction barrier", ) second = pool.submit(stale_request) self.assertTrue( second_lock_query.wait(10), "Second request did not try to acquire row locks", ) self.assertFalse(second.done()) finally: release_first.set() first.result(timeout=10) expected_status = ( 404 if operation in {"self-password", "self-update"} else 403 ) self.assertEqual(second.result(timeout=10), expected_status) self.assertFalse(User.objects.filter(pk=removed_id).exists()) self.assertTrue( User.objects.filter(pk=actor_id, is_active=True, is_staff=True).exists() ) self.assertFalse(User.objects.filter(username="replacement-admin").exists()) def test_two_admins_cannot_delete_each_other(self): self._assert_deleted_actor_cannot_mutate("delete") def test_deleted_admin_cannot_deactivate_survivor(self): self._assert_deleted_actor_cannot_mutate("deactivate") def test_deleted_admin_cannot_demote_survivor(self): self._assert_deleted_actor_cannot_mutate("demote") def test_deleted_admin_cannot_patch_survivor_inactive(self): self._assert_deleted_actor_cannot_mutate("patch-inactive") def test_deleted_admin_cannot_use_django_admin_bulk_deactivation(self): self._assert_deleted_actor_cannot_mutate("admin-deactivate") def test_deleted_admin_cannot_create_a_replacement_admin(self): self._assert_deleted_actor_cannot_mutate("create-admin") def test_in_flight_self_password_change_cannot_recreate_deleted_user(self): self._assert_deleted_actor_cannot_mutate("self-password") def test_in_flight_self_update_cannot_recreate_deleted_user(self): self._assert_deleted_actor_cannot_mutate("self-update")