All checks were successful
State Corp Backend CI/CD / Quality gate (push) Successful in 2m42s
State Corp Backend CI/CD / Build linux/amd64 images once (push) Successful in 5m29s
State Corp Backend CI/CD / Refresh and release internal main (push) Has been skipped
State Corp Backend CI/CD / Release customer main (push) Has been skipped
State Corp Backend CI/CD / Release dev (push) Successful in 36s
160 lines
7.0 KiB
Python
160 lines
7.0 KiB
Python
"""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")
|