"""E2 (B21): тесты аудита и админ-панели пользователей. SQLite in-memory; UUID/JSONB/ARRAY компилируются; PYTEST-fallback отключён — реальные токены с RBAC-правами. """ from __future__ import annotations import sys from pathlib import Path import json as _json import bcrypt import pytest REPO_ROOT = Path(__file__).resolve().parents[2] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) import sqlalchemy # noqa: E402 from sqlalchemy import create_engine # noqa: E402 from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402 from sqlalchemy.ext.compiler import compiles # noqa: E402 from sqlalchemy.orm import sessionmaker # noqa: E402 from sqlalchemy.pool import StaticPool # noqa: E402 @compiles(PG_UUID, 'sqlite') def _uuid_sqlite(type_, compiler, **kw): return 'CHAR(36)' @compiles(JSONB, 'sqlite') def _jsonb_sqlite(type_, compiler, **kw): return 'JSON' @compiles(sqlalchemy.ARRAY, 'sqlite') def _array_sqlite(type_, compiler, **kw): return 'TEXT' @pytest.fixture() def admin_env(): from backend import models as m from backend.database import Base from backend.models import ( MchsUnit, Permission, Role, RolePermission, SecuritySetting, UserRole, ) engine = create_engine( 'sqlite:///:memory:', connect_args={'check_same_thread': False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) TestingSession = sessionmaker(bind=engine) db = TestingSession() rcu = MchsUnit(name='РЦУ РЧС', region='Республика', kind='rcu') db.add(rcu) db.flush() oblast = MchsUnit(name='Минское ОУМЧС', region='Минская', kind='oblast', parent_id=rcu.id) db.add(oblast) db.flush() # flush ДО создания детей: иначе parent_id терялся (oblast.id был None) rayon1 = MchsUnit(name='Минское Г(Р)ОЧС', region='Минская', kind='gor_rayon', parent_id=oblast.id) rayon2 = MchsUnit(name='Борисовское Г(Р)ОЧС', region='Минская', kind='gor_rayon', parent_id=oblast.id) other_oblast = MchsUnit(name='Гомельское ОУМЧС', region='Гомельская', kind='oblast', parent_id=rcu.id) db.add_all([rayon1, rayon2, other_oblast]) db.flush() def _hash(pw: str) -> str: return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode() root = m.User(username='root_rcu', email='root@test.by', hashed_password=_hash('rootpass123'), full_name='РЦУ РЧС', role='admin', is_active=True, unit_id=rcu.id) coord = m.User(username='obl_coord', email='obl@test.by', hashed_password=_hash('oblpass123'), full_name='ОУМЧС Минск', role='coordinator', is_active=True, unit_id=oblast.id) other_coord = m.User(username='gomel_coord', email='g@test.by', hashed_password=_hash('gpass123'), full_name='Гомель ОУМЧС', role='coordinator', is_active=True, unit_id=other_oblast.id) db.add_all([root, coord, other_coord]) db.commit() roles = {} for name in ('admin', 'coordinator', 'operator', 'observer'): r = Role(name=name, is_system=True) db.add(r) roles[name] = r perms = {} for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users', 'manage_roles', 'view_audit', 'manage_security'): p = Permission(code=code) db.add(p) perms[code] = p db.commit() matrix = { 'admin': tuple(perms), 'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'), 'operator': ('view', 'create', 'update'), 'observer': ('view',), } for rn, codes in matrix.items(): for c in codes: db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id)) db.add(UserRole(user_id=root.id, role_id=roles['admin'].id)) db.add(UserRole(user_id=coord.id, role_id=roles['coordinator'].id)) db.add(UserRole(user_id=other_coord.id, role_id=roles['coordinator'].id)) db.commit() from backend.rbac import SECURITY_DEFAULTS for k, v in SECURITY_DEFAULTS.items(): db.add(SecuritySetting(key=k, value=v)) db.commit() from backend.main import app from backend.routers import auth as auth_router from fastapi.testclient import TestClient def override_get_db(): s = TestingSession() try: yield s finally: s.close() app.dependency_overrides[auth_router.get_db] = override_get_db client = TestClient(app) def login(username, password): r = client.post('/api/v1/auth/login', data={'username': username, 'password': password}) return r.json()['access_token'] yield { 'db': db, 'client': client, 'root_token': login('root_rcu', 'rootpass123'), 'coord_token': login('obl_coord', 'oblpass123') if False else login('obl_coord', 'oblpass123') if False else None, 'coord': coord, 'root': root, 'units': {'rcu': rcu, 'oblast': oblast, 'rayon1': rayon1, 'rayon2': rayon2, 'other': other_oblast}, 'login': login, } app.dependency_overrides.pop(auth_router.get_db, None) class TestUserManagement: def test_admin_lists_all_users(self, admin_env): r = admin_env['client'].get('/api/v1/admin/users', headers={'Authorization': f"Bearer {admin_env['root_token']}"}) assert r.status_code == 200 assert r.json()['total'] == 3 def test_admin_creates_operator(self, admin_env): r = admin_env['client'].post('/api/v1/admin/users', headers={'Authorization': f"Bearer {admin_env['root_token']}"}, json={ 'username': 'newop', 'email': 'newop@test.by', 'password': 'newpass123', 'role_name': 'operator', 'unit_id': str(admin_env['units']['rayon1'].id), 'full_name': 'Новый оператор', }) assert r.status_code == 201, r.json() # В аудит записано audit = admin_env['client'].get('/api/v1/admin/users/audit', headers={'Authorization': f"Bearer {admin_env['root_token']}"}) types = [e['event_type'] for e in audit.json()['items']] assert 'user_create' in types def test_created_user_must_change_password(self, admin_env): c = admin_env['client'] tok = c.post('/api/v1/auth/login', data={'username': 'root_rcu', 'password': 'rootpass123'}).json()['access_token'] from backend.audit import visible_unit_ids scope = visible_unit_ids(admin_env['db'], admin_env['root']) r = c.post('/api/v1/admin/users', headers={'Authorization': f'Bearer {tok}'}, json={'username': 'op2', 'email': 'op2@test.by', 'password': 'temppass123', 'role_name': 'operator', 'unit_id': str(admin_env['units']['rayon1'].id)}) import json as _json with open('/tmp/e2-debug.json', 'w') as fh: _json.dump({'scope': [str(s) for s in (scope or [])], 'unit_id': str(admin_env['units']['rayon1'].id), 'status': r.status_code, 'body': r.json()}, fh) assert r.status_code == 201 # Созданный пользователь входит и видит must_change_password op2_tok = c.post('/api/v1/auth/login', data={'username': 'op2', 'password': 'temppass123'}).json()['access_token'] me = c.get('/api/v1/auth/me', headers={'Authorization': f'Bearer {op2_tok}'}).json() assert me['must_change_password'] is True def test_admin_resets_password(self, admin_env): c = admin_env['client'] tok = c.post('/api/v1/auth/login', data={'username': 'root_rcu', 'password': 'rootpass123'}).json()['access_token'] users = c.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {tok}'}).json()['items'] target = next(u for u in users if u['username'] == 'obl_coord') r = c.patch(f"/api/v1/admin/users/{target['id']}", headers={'Authorization': f'Bearer {tok}'}, json={'reset_password': 'brandnew99'}) assert r.status_code == 200 login_ok = c.post('/api/v1/auth/login', data={'username': 'obl_coord', 'password': 'brandnew99'}) assert login_ok.status_code == 200 def test_admin_locks_user(self, admin_env): c = admin_env['client'] tok = c.post('/api/v1/auth/login', data={'username': 'root_rcu', 'password': 'rootpass123'}).json()['access_token'] users = c.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {tok}'}).json()['items'] target = next(u for u in users if u['username'] == 'obl_coord') r = c.patch(f"/api/v1/admin/users/{target['id']}", headers={'Authorization': f'Bearer {tok}'}, json={'status': 'disabled'}) import json as _json with open('/tmp/e2-patch-debug.json', 'w') as fh: _json.dump({'status': r.status_code, 'body': r.json(), 'target': target}, fh, default=str) assert r.status_code == 200 # Заблокированный не входит login = c.post('/api/v1/auth/login', data={'username': 'obl_coord', 'password': 'oblpass123'}) assert login.status_code == 403 def test_coordinator_scope_limited(self, admin_env): """ОУМЧС-координатор видит пользователей только своего юнита+подчинённых.""" coord_tok = admin_env['login']('obl_coord', 'oblpass123') r = admin_env['client'].get('/api/v1/admin/users', headers={'Authorization': f'Bearer {coord_tok}'}) assert r.status_code == 200 usernames = {u['username'] for u in r.json()['items']} # Свои: obl_coord; РЦУ РЧС-админ НЕ виден (другой юнит, не подчинённый) assert 'obl_coord' in usernames assert 'root_rcu' not in usernames def test_coordinator_cannot_manage_other_oblast(self, admin_env): c = admin_env['client'] coord_tok = admin_env['login']('obl_coord', 'oblpass123') # Пытается сбросить пароль Гомельскому координатору — 403/404 users = c.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {coord_tok}'}).json()['items'] gomel = [u for u in users if u['username'] == 'gomel_coord'] if not gomel: assert True # скоуп скрыл — уже хорошо return r = c.patch(f"/api/v1/admin/users/{gomel[0]['id']}", headers={'Authorization': f'Bearer {coord_tok}'}, json={'status': 'locked'}) assert r.status_code == 403 def test_without_permission_403(self, admin_env): # Создадим оператора и попробуем от его имени управлять пользователями c = admin_env['client'] root_tok = c.post('/api/v1/auth/login', data={'username': 'root_rcu', 'password': 'rootpass123'}).json()['access_token'] c.post('/api/v1/admin/users', headers={'Authorization': f'Bearer {root_tok}'}, json={'username': 'simpleop', 'email': 'sop@test.by', 'password': 'simplepass1', 'role_name': 'operator', 'unit_id': str(admin_env['units']['rayon1'].id)}) op_tok = c.post('/api/v1/auth/login', data={'username': 'simpleop', 'password': 'simplepass1'}).json()['access_token'] r = c.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {op_tok}'}) assert r.status_code == 403