Files
vector/backend/tests/test_e2_admin.py
Sadmin 5a2c9a0688 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.
2026-09-09 21:23:47 +03:00

253 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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