"""API администрирования `/admin/*` (spec users-admin, audit-trail; design D-8; Review Focus 5 фазы 2). Пользователи (создание, изменение, деактивация), назначение ролей, матрица «роль × право», аудит и защита `LAST_ADMIN`: последнее право `USERS_ADMIN` у активных пользователей нельзя снять ни деактивацией, ни заменой ролей, ни снятием права с роли. Новые пользователи — с доменом `example.com`: `EmailStr` не принимает зарезервированный `.local` dev-пользователей. """ import asyncio import uuid from collections.abc import Sequence from typing import Any import httpx import pytest from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.api.dependencies.audit import RequestAudit from src.common.exceptions import DomainError from src.models.access import AppUser, Permission, Role, RolePermission, UserRole from src.models.audit_ex import AuditEvent from src.models.directories import RegionalOffice from src.models.enums import AuditAction, AuditSection from src.repositories import users as users_repo from src.services.audit import AuditService from src.services.users_admin import UsersAdminService from tests.conftest import AuthClientFactory from tests.factories import make_user HTTP_OK = 200 HTTP_CREATED = 201 HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 HTTP_CONFLICT = 409 HTTP_UNPROCESSABLE = 422 ADMIN = "admin@armka.local" USERS = "/admin/users" ROSTOV = "Ростовский региональный филиал" HEAD_OFFICE = "Головной офис" PERMISSIONS_TOTAL = 16 # seed: 16 кодов прав (§10) EXPERT_PERMISSIONS = ["CONSOLIDATION_VIEW", "DIRECTORY_VIEW", "DOCUMENT_VIEW", "EX_VIEW", "KFO_VIEW"] async def _office(session: AsyncSession, name: str) -> RegionalOffice: return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one() async def _user_id(session: AsyncSession, email: str) -> uuid.UUID: return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one() async def _role_codes(session: AsyncSession, user_id: uuid.UUID) -> list[str]: stmt = select(Role.code).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id) return sorted((await session.execute(stmt)).scalars().all()) async def _events(session: AsyncSession, action: AuditAction) -> Sequence[AuditEvent]: stmt = select(AuditEvent).where(AuditEvent.action == action).order_by(AuditEvent.occurred_at, AuditEvent.id) return (await session.execute(stmt)).scalars().all() async def _create(client: httpx.AsyncClient, **body: Any) -> dict[str, Any]: response = await client.post(USERS, json=body) assert response.status_code == HTTP_CREATED, response.text created: dict[str, Any] = response.json() return created def _detail_code(response: httpx.Response) -> str: code: str = response.json()["detail"]["code"] return code # --- Пользователи ------------------------------------------------------------------------------------------------ @pytest.mark.integration async def test_create_user_with_role_gives_role_permissions_in_me( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: admin = await auth_client(ADMIN) rostov = await _office(seeded, ROSTOV) created = await _create( admin, email=" Ivan.Petrov@Example.com ", full_name="Иван Петров", regional_office_id=str(rostov.id), employee_no="E-1", business_segment="DKB", roles=["KA_RF"], ) assert created["email"] == "ivan.petrov@example.com" # v5.15: email хранится в нижнем регистре assert created["roles"] == ["KA_RF"] assert created["is_active"] is True assert created["regional_office"] == { "id": str(rostov.id), "name": ROSTOV, "is_head_office": False, "is_active": True, } assert set(created) == { "id", "email", "full_name", "employee_no", "regional_office", "business_segment", "is_active", "roles", "created_at", "updated_at", } user_id = uuid.UUID(created["id"]) assert await _role_codes(seeded, user_id) == ["KA_RF"] user = await seeded.get(AppUser, user_id) assert user is not None and user.external_id is None and user.created_at is not None events = await _events(seeded, AuditAction.USER_CHANGE) assert len(events) == 1 event = events[0] assert (event.section, event.object_type, event.object_id) == (AuditSection.USERS, "app_user", user_id) assert event.user_id == await _user_id(seeded, ADMIN) assert event.after_value == { "email": "ivan.petrov@example.com", "full_name": "Иван Петров", "employee_no": "E-1", "regional_office_id": str(rostov.id), "business_segment": "DKB", "roles": ["KA_RF"], } reference = (await (await auth_client("ka_rf@armka.local")).get("/me")).json() me = await (await auth_client("ivan.petrov@example.com")).get("/me") assert me.status_code == HTTP_OK, me.text assert me.json()["roles"] == ["KA_RF"] assert me.json()["permissions"] == reference["permissions"] @pytest.mark.integration async def test_create_user_stores_email_in_lower_case(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: """v5.15: `Qa1@Example.com` хранится как `qa1@example.com`; `/me` находит пользователя по любому регистру.""" admin = await auth_client(ADMIN) office_id = str((await _office(seeded, ROSTOV)).id) created = await _create( admin, email="Qa1@Example.com", full_name="Ку", regional_office_id=office_id, roles=["KA_RF"] ) assert created["email"] == "qa1@example.com" stored = (await seeded.execute(select(AppUser.email).where(AppUser.id == uuid.UUID(created["id"])))).scalar_one() assert stored == "qa1@example.com" for variant in ("Qa1@Example.com", "qa1@example.com", "QA1@EXAMPLE.COM"): response = await (await auth_client(variant)).get("/me") assert response.status_code == HTTP_OK assert response.json()["email"] == "qa1@example.com" @pytest.mark.integration async def test_create_user_email_in_other_case_conflicts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: admin = await auth_client(ADMIN) office_id = str((await _office(seeded, HEAD_OFFICE)).id) await _create(admin, email="anna@example.com", full_name="Анна", regional_office_id=office_id) response = await admin.post( USERS, json={"email": "ANNA@Example.COM", "full_name": "Анна 2", "regional_office_id": office_id} ) assert response.status_code == HTTP_CONFLICT assert _detail_code(response) == "EMAIL_EXISTS" total = (await seeded.execute(select(func.count()).where(func.lower(AppUser.email) == "anna@example.com"))).scalar() assert total == 1 @pytest.mark.integration async def test_create_user_validation(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: admin = await auth_client(ADMIN) office_id = str((await _office(seeded, HEAD_OFFICE)).id) base = {"email": "v@example.com", "full_name": "В", "regional_office_id": office_id} unknown_role = await admin.post(USERS, json={**base, "roles": ["KA_RF", "NO_SUCH_ROLE"]}) assert unknown_role.status_code == HTTP_UNPROCESSABLE assert unknown_role.json()["detail"]["code"] == "UNKNOWN_ROLE" assert unknown_role.json()["detail"]["roles"] == ["NO_SUCH_ROLE"] unknown_office = await admin.post(USERS, json={**base, "regional_office_id": str(uuid.uuid4())}) assert unknown_office.status_code == HTTP_UNPROCESSABLE assert _detail_code(unknown_office) == "OFFICE_NOT_FOUND" for body in ( {**base, "external_id": "raisa-1"}, # external_id через API не задаётся {**base, "email": "not-an-email"}, {**base, "full_name": " "}, ): response = await admin.post(USERS, json=body) assert response.status_code == HTTP_UNPROCESSABLE, body assert (await seeded.execute(select(AppUser).where(AppUser.email == "v@example.com"))).first() is None assert await _events(seeded, AuditAction.USER_CHANGE) == [] @pytest.mark.integration async def test_list_users_search_filter_and_page(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: office = await _office(seeded, ROSTOV) await make_user(seeded, email="Zoya_Search@example.com", office=office, roles=["KA_RF", "EXPERT"], full_name="Зоя") await make_user(seeded, email="off@example.com", office=office, full_name="Зоя Отключённая", is_active=False) await make_user(seeded, email="zoyaXs@example.com", office=office) # совпал бы с `zoya_s`, если `_` — шаблон LIKE admin = await auth_client(ADMIN) everyone = (await admin.get(USERS)).json() assert set(everyone) == {"items", "total", "limit", "offset"} assert everyone["total"] == len(everyone["items"]) >= 10 # noqa: PLR2004 - 7 dev-пользователей + 3 новых by_email = (await admin.get(USERS, params={"q": "zoya_s"})).json() # `_` ищется буквально, без регистра assert [item["email"] for item in by_email["items"]] == ["Zoya_Search@example.com"] # `zoyaXs@…` не найден assert by_email["items"][0]["roles"] == ["EXPERT", "KA_RF"] assert by_email["items"][0]["regional_office"]["name"] == ROSTOV by_name = (await admin.get(USERS, params={"q": "зоя"})).json() assert by_name["total"] == 2 # noqa: PLR2004 inactive = (await admin.get(USERS, params={"q": "зоя", "is_active": "false"})).json() assert [item["email"] for item in inactive["items"]] == ["off@example.com"] assert inactive["items"][0]["roles"] == [] page = (await admin.get(USERS, params={"limit": 2, "offset": 1})).json() assert (len(page["items"]), page["limit"], page["offset"], page["total"]) == (2, 2, 1, everyone["total"]) assert page["items"] == everyone["items"][1:3] @pytest.mark.integration async def test_patch_user_fields_audit_and_noop(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: rostov = await _office(seeded, ROSTOV) head = await _office(seeded, HEAD_OFFICE) user = await make_user(seeded, email="p@example.com", office=rostov, roles=["KA_RF"], full_name="Пётр") admin = await auth_client(ADMIN) response = await admin.patch( f"{USERS}/{user.id}", json={"full_name": "Пётр Петров", "regional_office_id": str(head.id)} ) assert response.status_code == HTTP_OK, response.text body = response.json() assert body["full_name"] == "Пётр Петров" assert body["regional_office"]["id"] == str(head.id) assert body["roles"] == ["KA_RF"] events = await _events(seeded, AuditAction.USER_CHANGE) assert len(events) == 1 assert (events[0].section, events[0].object_type, events[0].object_id) == (AuditSection.USERS, "app_user", user.id) assert events[0].before_value == {"full_name": "Пётр", "regional_office_id": str(rostov.id)} assert events[0].after_value == {"full_name": "Пётр Петров", "regional_office_id": str(head.id)} assert events[0].changed_field is None single = await admin.patch(f"{USERS}/{user.id}", json={"employee_no": "E-7"}) assert single.status_code == HTTP_OK last = (await _events(seeded, AuditAction.USER_CHANGE))[-1] assert (last.changed_field, last.before_value, last.after_value) == ( "employee_no", {"employee_no": None}, {"employee_no": "E-7"}, ) noop = await admin.patch(f"{USERS}/{user.id}", json={"full_name": "Пётр Петров", "employee_no": "E-7"}) assert noop.status_code == HTTP_OK assert len(await _events(seeded, AuditAction.USER_CHANGE)) == 2 # noqa: PLR2004 - no-op не пишет аудит for bad in ({"email": "x@example.com"}, {"full_name": None}, {"is_active": None}, {"regional_office_id": None}): assert (await admin.patch(f"{USERS}/{user.id}", json=bad)).status_code == HTTP_UNPROCESSABLE, bad office_missing = await admin.patch(f"{USERS}/{user.id}", json={"regional_office_id": str(uuid.uuid4())}) assert office_missing.status_code == HTTP_UNPROCESSABLE assert _detail_code(office_missing) == "OFFICE_NOT_FOUND" assert (await admin.patch(f"{USERS}/{uuid.uuid4()}", json={"full_name": "Х"})).status_code == HTTP_NOT_FOUND @pytest.mark.integration async def test_deactivated_user_loses_access(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: user_id = await _user_id(seeded, "expert@armka.local") expert = await auth_client("expert@armka.local") admin = await auth_client(ADMIN) response = await admin.patch(f"{USERS}/{user_id}", json={"is_active": False}) assert response.status_code == HTTP_OK assert response.json()["is_active"] is False assert (await expert.get("/me")).status_code == HTTP_FORBIDDEN event = (await _events(seeded, AuditAction.USER_CHANGE))[-1] assert (event.changed_field, event.before_value, event.after_value) == ( "is_active", {"is_active": True}, {"is_active": False}, ) # --- Назначение ролей -------------------------------------------------------------------------------------------- @pytest.mark.integration async def test_put_roles_replaces_set_with_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: user = await make_user(seeded, email="r@example.com", office=await _office(seeded, ROSTOV), roles=["KA_RF"]) admin = await auth_client(ADMIN) response = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": ["KA_GO", "EXPERT", "KA_GO"]}) assert response.status_code == HTTP_OK, response.text assert response.json()["roles"] == ["EXPERT", "KA_GO"] assert await _role_codes(seeded, user.id) == ["EXPERT", "KA_GO"] rows = (await seeded.execute(select(func.count()).where(UserRole.user_id == user.id))).scalar() assert rows == 2 # noqa: PLR2004 events = await _events(seeded, AuditAction.USER_ROLE_CHANGE) assert len(events) == 1 event = events[0] assert (event.section, event.object_type, event.object_id) == (AuditSection.USERS, "app_user", user.id) assert event.before_value == {"roles": ["KA_RF"]} assert event.after_value == {"roles": ["KA_GO", "EXPERT"]} same = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": ["EXPERT", "KA_GO"]}) assert same.status_code == HTTP_OK assert len(await _events(seeded, AuditAction.USER_ROLE_CHANGE)) == 1 # тот же набор — без аудита cleared = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": []}) assert cleared.status_code == HTTP_OK assert await _role_codes(seeded, user.id) == [] @pytest.mark.integration async def test_put_roles_rejects_unknown_and_inactive_role( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: user = await make_user(seeded, email="u@example.com", office=await _office(seeded, ROSTOV), roles=["KA_RF"]) seeded.add(Role(code="RETIRED", name="Упразднённая", is_system=False, is_active=False)) await seeded.flush() admin = await auth_client(ADMIN) for codes, bad in ((["KA_GO", "NOPE"], ["NOPE"]), (["RETIRED"], ["RETIRED"])): response = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": codes}) assert response.status_code == HTTP_UNPROCESSABLE assert response.json()["detail"]["code"] == "UNKNOWN_ROLE" assert response.json()["detail"]["roles"] == bad assert await _role_codes(seeded, user.id) == ["KA_RF"] missing = await admin.put(f"{USERS}/{uuid.uuid4()}/roles", json={"roles": ["KA_GO"]}) assert missing.status_code == HTTP_NOT_FOUND # --- LAST_ADMIN -------------------------------------------------------------------------------------------------- @pytest.mark.integration async def test_last_admin_cannot_drop_own_admin_role(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: admin_id = await _user_id(seeded, ADMIN) admin = await auth_client(ADMIN) response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) assert response.status_code == HTTP_CONFLICT assert _detail_code(response) == "LAST_ADMIN" assert await _role_codes(seeded, admin_id) == ["ADMIN"] assert await _events(seeded, AuditAction.USER_ROLE_CHANGE) == [] @pytest.mark.integration async def test_inactive_user_or_inactive_role_is_not_an_admin_source( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: head = await _office(seeded, HEAD_OFFICE) await make_user(seeded, email="sleeping.admin@example.com", office=head, roles=["ADMIN"], is_active=False) retired = Role(code="RETIRED_ADMIN", name="Бывшая админская", is_system=False, is_active=False) seeded.add(retired) await seeded.flush() users_admin = (await seeded.execute(select(Permission.id).where(Permission.code == "USERS_ADMIN"))).scalar_one() seeded.add(RolePermission(role_id=retired.id, permission_id=users_admin)) await make_user(seeded, email="retired.admin@example.com", office=head, roles=["RETIRED_ADMIN"]) admin_id = await _user_id(seeded, ADMIN) admin = await auth_client(ADMIN) response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) assert response.status_code == HTTP_CONFLICT assert _detail_code(response) == "LAST_ADMIN" @pytest.mark.integration async def test_second_admin_allows_dropping_own_admin_role( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: await make_user( seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"] ) admin_id = await _user_id(seeded, ADMIN) admin = await auth_client(ADMIN) response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) assert response.status_code == HTTP_OK, response.text assert await _role_codes(seeded, admin_id) == ["EXPERT"] assert (await admin.get("/admin/roles")).status_code == HTTP_FORBIDDEN # права пересчитаны на следующем запросе async def _grant_users_admin(session: AsyncSession, role_code: str, *, is_active: bool = True) -> None: """Даёт роли право `USERS_ADMIN` напрямую в БД (и задаёт `is_active` роли) — как после правки матрицы прав.""" role = (await session.execute(select(Role).where(Role.code == role_code))).scalar_one() permission_id = (await session.execute(select(Permission.id).where(Permission.code == "USERS_ADMIN"))).scalar_one() session.add(RolePermission(role_id=role.id, permission_id=permission_id)) role.is_active = is_active await session.flush() @pytest.mark.integration async def test_sole_admin_moved_to_active_role_with_users_admin_stays_admin( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: """Ветка `kept_roles`: оставшаяся активная роль даёт `USERS_ADMIN` — другого администратора не требуется (T12.1).""" await _grant_users_admin(seeded, "EXPERT") admin_id = await _user_id(seeded, ADMIN) admin = await auth_client(ADMIN) response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) assert response.status_code == HTTP_OK, response.text assert await _role_codes(seeded, admin_id) == ["EXPERT"] assert (await admin.get(USERS)).status_code == HTTP_OK # право USERS_ADMIN сохранено через EXPERT assert len(await _events(seeded, AuditAction.USER_ROLE_CHANGE)) == 1 @pytest.mark.integration async def test_sole_admin_cannot_be_moved_to_inactive_role_with_users_admin( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: """Неактивная роль с `USERS_ADMIN` не даёт права и не назначается: набор ролей и администратор не меняются (T12.1). Сервис отсекает её раньше проверки `LAST_ADMIN` — 422 `UNKNOWN_ROLE`; сам фильтр `is_active` в `kept_roles`-проверке (`roles_grant_permission`) защитный и до неё через API недостижим, поэтому проверяется отдельно. """ await _grant_users_admin(seeded, "EXPERT", is_active=False) admin_id = await _user_id(seeded, ADMIN) admin = await auth_client(ADMIN) response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) assert response.status_code == HTTP_UNPROCESSABLE, response.text assert _detail_code(response) == "UNKNOWN_ROLE" assert await _role_codes(seeded, admin_id) == ["ADMIN"] assert await _events(seeded, AuditAction.USER_ROLE_CHANGE) == [] assert (await admin.get(USERS)).status_code == HTTP_OK roles = { role.code: role.id for role in (await seeded.execute(select(Role).where(Role.code.in_(["ADMIN", "EXPERT"])))).scalars() } assert await users_repo.roles_grant_permission(seeded, {roles["ADMIN"]}, "USERS_ADMIN") is True assert await users_repo.roles_grant_permission(seeded, {roles["EXPERT"]}, "USERS_ADMIN") is False # неактивная роль @pytest.mark.integration async def test_last_admin_cannot_deactivate_self(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: """Review Focus 5: последний активный с `USERS_ADMIN` не может деактивировать себя; при втором — может.""" admin_id = await _user_id(seeded, ADMIN) admin = await auth_client(ADMIN) response = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False, "full_name": "Не применится"}) assert response.status_code == HTTP_CONFLICT assert _detail_code(response) == "LAST_ADMIN" user = await seeded.get(AppUser, admin_id) assert user is not None await seeded.refresh(user) assert user.is_active is True and user.full_name != "Не применится" assert await _events(seeded, AuditAction.USER_CHANGE) == [] await make_user( seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"] ) allowed = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False}) assert allowed.status_code == HTTP_OK, allowed.text assert allowed.json()["is_active"] is False assert (await admin.get(USERS)).status_code == HTTP_FORBIDDEN # отключённый пользователь — 403 @pytest.mark.integration async def test_deactivating_other_admin_respects_last_admin( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: second = await make_user( seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"] ) admin = await auth_client(ADMIN) second_client = await auth_client("second.admin@example.com") assert (await admin.patch(f"{USERS}/{second.id}", json={"is_active": False})).status_code == HTTP_OK # второй отключён — первый снова последний admin_id = await _user_id(seeded, ADMIN) response = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False}) assert response.status_code == HTTP_CONFLICT assert _detail_code(response) == "LAST_ADMIN" assert (await second_client.get(USERS)).status_code == HTTP_FORBIDDEN # --- Матрица прав ------------------------------------------------------------------------------------------------ @pytest.mark.integration async def test_roles_and_permissions_listing(auth_client: AuthClientFactory) -> None: admin = await auth_client(ADMIN) permissions = (await admin.get("/admin/permissions")).json() assert len(permissions) == PERMISSIONS_TOTAL codes = [item["code"] for item in permissions] assert codes == sorted(codes) and "USERS_ADMIN" in codes users_admin = next(item for item in permissions if item["code"] == "USERS_ADMIN") assert set(users_admin) == {"code", "object_type", "action", "name", "description"} assert (users_admin["object_type"], users_admin["action"]) == ("USERS", "ADMIN") roles = {item["code"]: item for item in (await admin.get("/admin/roles")).json()} assert {"ADMIN", "EXPERT", "KA_RF", "KA_GO"} <= set(roles) assert roles["EXPERT"]["permissions"] == EXPERT_PERMISSIONS assert "USERS_ADMIN" in roles["ADMIN"]["permissions"] assert set(roles["ADMIN"]) == {"code", "name", "description", "is_system", "is_active", "permissions"} assert roles["ADMIN"]["is_system"] is True @pytest.mark.integration async def test_role_permissions_change_reaches_me_and_audit( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: admin = await auth_client(ADMIN) expert = await auth_client("expert@armka.local") assert "EXPORT" not in (await expert.get("/me")).json()["permissions"] response = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "EXPORT"]}) assert response.status_code == HTTP_OK, response.text assert response.json()["permissions"] == sorted([*EXPERT_PERMISSIONS, "EXPORT"]) role = (await seeded.execute(select(Role).where(Role.code == "EXPERT"))).scalar_one() row = ( await seeded.execute( select(RolePermission) .join(Permission, Permission.id == RolePermission.permission_id) .where(RolePermission.role_id == role.id, Permission.code == "EXPORT") ) ).scalar_one() admin_id = await _user_id(seeded, ADMIN) assert row.granted_by == admin_id and row.granted_at is not None assert "EXPORT" in (await expert.get("/me")).json()["permissions"] events = await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE) assert len(events) == 1 event = events[0] assert (event.section, event.object_type, event.object_id, event.user_id) == ( AuditSection.ACCESS, "role", role.id, admin_id, ) assert event.before_value == {"permissions": EXPERT_PERMISSIONS} assert event.after_value == {"permissions": sorted([*EXPERT_PERMISSIONS, "EXPORT"])} same = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "EXPORT"]}) assert same.status_code == HTTP_OK assert len(await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE)) == 1 shrink = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": ["DIRECTORY_VIEW"]}) assert shrink.status_code == HTTP_OK assert (await expert.get("/me")).json()["permissions"] == ["DIRECTORY_VIEW"] @pytest.mark.integration async def test_role_permissions_validation_and_last_admin(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: admin = await auth_client(ADMIN) roles = {item["code"]: item for item in (await admin.get("/admin/roles")).json()} without_users_admin = [code for code in roles["ADMIN"]["permissions"] if code != "USERS_ADMIN"] last = await admin.put("/admin/roles/ADMIN/permissions", json={"permissions": without_users_admin}) assert last.status_code == HTTP_CONFLICT assert _detail_code(last) == "LAST_ADMIN" unknown = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": ["KFO_VIEW", "FLY"]}) assert unknown.status_code == HTTP_UNPROCESSABLE assert unknown.json()["detail"]["code"] == "UNKNOWN_PERMISSION" assert unknown.json()["detail"]["permissions"] == ["FLY"] missing = await admin.put("/admin/roles/NO_SUCH/permissions", json={"permissions": []}) assert missing.status_code == HTTP_NOT_FOUND assert await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE) == [] # USERS_ADMIN есть у другой роли активного пользователя — снять с ADMIN можно await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "USERS_ADMIN"]}) allowed = await admin.put("/admin/roles/ADMIN/permissions", json={"permissions": without_users_admin}) assert allowed.status_code == HTTP_OK, allowed.text assert "USERS_ADMIN" not in allowed.json()["permissions"] # --- Права доступа ----------------------------------------------------------------------------------------------- @pytest.mark.integration async def test_admin_routes_forbidden_without_users_admin(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: client = await auth_client("ka_go@armka.local") user_id = await _user_id(seeded, "ka_rf@armka.local") calls = [ ("GET", USERS, None), ("POST", USERS, {"email": "x@example.com", "full_name": "Х", "regional_office_id": str(uuid.uuid4())}), ("PATCH", f"{USERS}/{user_id}", {"full_name": "Х"}), ("PATCH", f"{USERS}/{uuid.uuid4()}", {"full_name": "Х"}), ("PUT", f"{USERS}/{user_id}/roles", {"roles": ["ADMIN"]}), ("GET", "/admin/roles", None), ("GET", "/admin/permissions", None), ("PUT", "/admin/roles/KA_GO/permissions", {"permissions": ["USERS_ADMIN"]}), ("PUT", "/admin/roles/NO_SUCH/permissions", {"permissions": []}), ("PATCH", f"{USERS}/not-a-uuid", {"bad": 1}), ] for method, path, body in calls: response = await client.request(method, path, json=body) assert response.status_code == HTTP_FORBIDDEN, (method, path, response.text) assert response.json() == {"detail": "недостаточно прав: требуется USERS_ADMIN"} assert await _role_codes(seeded, user_id) == ["KA_RF"] # --- Гонка двух администраторов ---------------------------------------------------------------------------------- async def _remove_admin_role(maker: async_sessionmaker[AsyncSession], actor_id: uuid.UUID, target_id: uuid.UUID) -> str: """Отдельная транзакция «как запрос»: `actor_id` заменяет роли `target_id` на `[EXPERT]`; итог — `ok` или код.""" try: async with maker() as session, session.begin(): audit = RequestAudit(service=AuditService(session), user_id=actor_id, ip_address=None, user_agent=None) await UsersAdminService(session, audit, actor_id).set_roles(target_id, ["EXPERT"]) except DomainError as error: return error.code return "ok" @pytest.mark.integration async def test_concurrent_mutual_admin_removal_keeps_one_admin( committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch ) -> None: """Два администратора в параллельных транзакциях снимают ADMIN друг с друга: успевает ровно один (D-8). Без блокировки обе транзакции насчитали бы «другого» администратора и обе зафиксировались бы — ноль администраторов. Проверка на уровне сервиса: обе транзакции стартуют одновременно, без предварительной аутентификации запроса. Барьер перед `lock_permission` гарантирует, что обе транзакции уже прочитали своих пользователей и дошли до проверки до того, как любая из них зафиксируется (без барьера первая могла бы успеть целиком и гонки бы не было). """ barrier = asyncio.Barrier(2) original_lock = users_repo.lock_permission async def lock_after_barrier(session: AsyncSession, code: str) -> None: async with asyncio.timeout(10): await barrier.wait() await original_lock(session, code) monkeypatch.setattr(users_repo, "lock_permission", lock_after_barrier) async with committed_sessionmaker() as session, session.begin(): first_id = await _user_id(session, ADMIN) second = await make_user( session, email="second.admin@example.com", office=await _office(session, HEAD_OFFICE), roles=["ADMIN"] ) second_id = second.id results = await asyncio.gather( _remove_admin_role(committed_sessionmaker, first_id, second_id), _remove_admin_role(committed_sessionmaker, second_id, first_id), ) assert sorted(results) == ["LAST_ADMIN", "ok"] async with committed_sessionmaker() as session: remaining = await users_repo.count_active_users_with_permission(session, "USERS_ADMIN") assert remaining == 1