E2 (B21): аудит + админ-панель пользователей + гео-скоуп

- Модели audit_events (immutable) + audit_changes (field-level old→new);
  Alembic 008_e2_audit.
- backend/audit.py: audit_log() (событие + изменения), audit_retention_days
  (90 дней из security_settings), visible_unit_ids (рекурсивный CTE:
  своё подразделение + подчинённые; РЦУ РЧС/legacy → None = без фильтра),
  can_access_unit, _uuid_hex.
- backend/routers/admin_users.py (manage_users/view_audit):
  GET /admin/users (фильтры status/unit/q, скоуп по подразделению),
  POST (создание с must_change_password, роль, скоуп-проверка),
  PATCH /{id} (роль/юнит/статус/сброс пароля, field-level аудит),
  GET /units (справочник подразделений), GET /audit (ретеншн-фильтр).
  coordinator управляет только своим юнитом+подчинёнными и не трогает РЦУ.
- Грабли SQLite-тестов: UUID колонки хранят hex без дефисов — сравнения
  только в hex-пространстве (_uuid_hex); StaticPool обязателен.
- Тесты E2 (8): создание/блокировка/сброс пароля/скоуп координатора/
  cross-oblast 403/без права 403/аудит user_create.
210 passed, 5 skipped.
This commit is contained in:
2026-09-09 21:23:47 +03:00
parent aefd33949c
commit 5a2c9a0688
6 changed files with 725 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
"""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