From d241c07f5453542d24cad7507d8d30d6dbc06d37 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 01:29:32 +0400 Subject: [PATCH] =?UTF-8?q?test(admin):=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20kept=5Froles,=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0?= =?UTF-8?q?=D1=8E=D1=89=D0=B8=D0=B9=20=D1=82=D0=B5=D1=81=D1=82=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F,=20=D0=BA=D0=BE=D0=B4=20LAST=5FADMIN=20(T12.1,=20T?= =?UTF-8?q?12.3,=20T12.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/test_api_admin.py | 57 +++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/api/tests/test_api_admin.py b/api/tests/test_api_admin.py index 7fb4c93..18356d3 100644 --- a/api/tests/test_api_admin.py +++ b/api/tests/test_api_admin.py @@ -183,14 +183,15 @@ async def test_list_users_search_filter_and_page(auth_client: AuthClientFactory, 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"]) >= 9 # noqa: PLR2004 - 7 dev-пользователей + 2 новых + 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"] + 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 @@ -364,6 +365,57 @@ async def test_second_admin_allows_dropping_own_admin_role( 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` не может деактивировать себя; при втором — может.""" @@ -401,6 +453,7 @@ async def test_deactivating_other_admin_respects_last_admin( 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