lct-hack/backend/tests/test_admin.py
Ivan Gerasimov 641e505c67 feat: АРМ администратора — записи, состояние стенда, аудит, копии (lct-24)
Целый раздел ТЗ, от которого не было ни одной функции.

- Учётные записи: завести, сменить роль и службу, заблокировать, сбросить
  пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль
  и проверка «это твой разбор».
- Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия
  классификатора, число живых занятий. Отдельной строкой — секрет сессии:
  значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть
  её должен администратор, а не проверяющий на защите.
- Журнал действий с фильтром, только на чтение.
- Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный,
  а если его нет — из контейнера базы; если нет ни того ни другого,
  администратор видит, чего не хватает, а кнопка не молчит.

Границы роли выражены отсутствием маршрутов, а не проверками внутри них:
в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста
проверяют это перебором маршрутов роутера — так запрет нельзя ослабить,
случайно добавив обработчик.

Найдено по ходу: тесты с базой падали в компании и проходили поодиночке.
Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой
событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура,
дающая движок на тест.

193 теста зелёных (12 новых).
2026-09-20 09:11:12 +03:00

205 lines
7.6 KiB
Python
Raw 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.

"""АРМ администратора: учётные записи, состояние стенда, аудит, копии.
Пункты приёмки lct-24. Главное здесь — не функции, а границы роли: ТЗ прямо
запрещает администратору вмешиваться в оценки, и проверяется именно это.
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
hub.journal = None
yield test_client
@pytest.fixture
def as_admin(client, monkeypatch):
"""Вход администратором. `dev-token` даёт преподавателя, поэтому роль
подменяется на уровне проверки: заводить запись в базе ради прав не нужно."""
client.post("/api/auth/dev-token")
import app.api.auth as auth_module
def admin_principal(request):
return Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
monkeypatch.setattr(auth_module, "current", admin_principal)
return client
@pytest.fixture
def as_instructor(client):
client.post("/api/auth/dev-token")
return client
def db_alive(client) -> bool:
"""Часть точек без Postgres работать не может, и это не повод падать:
на машине разработчика база может быть не поднята."""
return client.get("/api/health").status_code == 200
# ── границы роли ──
def test_instructor_cannot_open_admin(as_instructor):
"""Преподаватель не управляет учётными записями: ТЗ ограничивает его
в административных функциях прямо."""
assert as_instructor.get("/api/admin/users").status_code == 403
assert as_instructor.get("/api/admin/audit").status_code == 403
assert as_instructor.get("/api/admin/status").status_code == 403
def test_anonymous_cannot_open_admin(client):
assert client.get("/api/admin/users").status_code == 401
def test_admin_has_no_way_to_change_a_score():
"""Запрет выражен отсутствием точки, а не проверкой внутри неё: в модуле
администратора нет ни одного маршрута, трогающего оценки."""
from app.api.http import admin
paths = {route.path for route in admin.router.routes}
assert not any("report" in path or "score" in path for path in paths)
def test_admin_cannot_write_to_the_audit_log():
"""ТЗ требует хранения журнала, а не управления им."""
from app.api.http import admin
methods = {
(route.path, method)
for route in admin.router.routes
for method in getattr(route, "methods", set())
}
assert not [
path for path, method in methods if "audit" in path and method in {"POST", "PATCH", "DELETE"}
]
# ── учётные записи ──
def test_admin_creates_a_trainee_with_a_trainee_card(as_admin):
"""У обучающегося должна появиться карточка курсанта: на ней висят
профиль, история и проверка «это твой разбор» (lct-23)."""
if not db_alive(as_admin):
pytest.skip("нет базы")
login = f"курсант-{uuid.uuid4().hex[:8]}"
response = as_admin.post(
"/api/admin/users",
json={
"login": login,
"full_name": "Петров Пётр",
"password": "длинный-пароль",
"role": "trainee",
"service": "ДДС района",
},
)
assert response.status_code == 201, response.text
body = response.json()
assert body["role"] == "trainee"
assert body["service"] == "ДДС района"
listing = as_admin.get("/api/admin/users").json()
assert any(user["login"] == login for user in listing)
def test_duplicate_login_is_refused(as_admin):
if not db_alive(as_admin):
pytest.skip("нет базы")
login = f"двойник-{uuid.uuid4().hex[:8]}"
payload = {
"login": login, "full_name": "Первый", "password": "длинный-пароль", "role": "instructor",
}
assert as_admin.post("/api/admin/users", json=payload).status_code == 201
second = as_admin.post("/api/admin/users", json=payload)
assert second.status_code == 409
assert second.json()["detail"] == "login_taken"
def test_short_password_is_refused(as_admin):
response = as_admin.post(
"/api/admin/users",
json={"login": "короткий", "full_name": "Кто-то", "password": "123", "role": "instructor"},
)
assert response.status_code == 422
def test_admin_cannot_block_himself(as_admin):
"""Иначе стенд остаётся без администратора до похода в базу руками."""
if not db_alive(as_admin):
pytest.skip("нет базы")
created = as_admin.post(
"/api/admin/users",
json={
"login": "admin", "full_name": "Администратор",
"password": "длинный-пароль", "role": "admin",
},
)
user_id = created.json()["id"] if created.status_code == 201 else next(
user["id"] for user in as_admin.get("/api/admin/users").json() if user["login"] == "admin"
)
response = as_admin.patch(f"/api/admin/users/{user_id}", json={"blocked": True})
assert response.status_code == 409
assert response.json()["detail"] == "cannot_block_yourself"
# ── состояние стенда ──
def test_status_names_every_component(as_admin):
if not db_alive(as_admin):
pytest.skip("нет базы")
names = {item["name"] for item in as_admin.get("/api/admin/status").json()}
assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM",
"Классификатор ЕКП", "Секрет сессии"} <= names
def test_default_session_secret_is_reported_as_a_problem(as_admin):
"""На стенде это дыра, и увидеть её должен администратор, а не проверяющий."""
if not db_alive(as_admin):
pytest.skip("нет базы")
secret = next(
item for item in as_admin.get("/api/admin/status").json() if item["name"] == "Секрет сессии"
)
assert secret["ok"] is False, "значение по умолчанию не помечено как проблема"
# ── резервные копии ──
def test_backup_listing_is_open_to_admin(as_admin):
assert as_admin.get("/api/admin/backups").status_code == 200
def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
администратор видит, чего именно не хватает."""
from app.admin import backup as backup_service
def broken():
raise backup_service.BackupError("нет ни pg_dump, ни docker")
monkeypatch.setattr(backup_service, "create", broken)
response = as_admin.post("/api/admin/backups")
assert response.status_code == 503
assert "pg_dump" in response.json()["detail"]