feat(users): add transactional employee deletion and token revocation
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
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
This commit is contained in:
159
tests/apps/user/test_management_concurrency.py
Normal file
159
tests/apps/user/test_management_concurrency.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user