lct-hack/backend/tests/test_admin.py
2026-09-24 01:10:49 +03:00

437 lines
16 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
from xml.etree import ElementTree as ET
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
assert as_instructor.get("/api/admin/diagnostics").status_code == 403
assert as_instructor.get("/api/admin/config.xml").status_code == 403
def test_anonymous_cannot_open_admin(client):
assert client.get("/api/admin/users").status_code == 401
def test_admin_downloads_safe_xml_configuration(as_admin):
response = as_admin.get("/api/admin/config.xml")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/xml")
assert "attachment" in response.headers["content-disposition"]
root = ET.fromstring(response.content)
assert root.tag == "lctConfiguration"
assert root.find("./workstations/workstation[@role='admin']") is not None
assert root.find("./timerLimits/timer[@code='dds_ack']") is not None
lowered = response.content.lower()
assert b"session_secret" not in lowered
assert b"llm_api_key" not in lowered
assert b"database_url" not in lowered
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_audit_api_applies_actor_action_and_offset_filters(as_admin):
"""The admin UI must be able to reach older records, not just page zero."""
from app.api.http import admin as admin_module
class FakeDb:
statement = None
async def scalars(self, statement):
self.statement = statement
return []
fake_db = FakeDb()
async def fake_session():
yield fake_db
app.dependency_overrides[admin_module.get_session] = fake_session
try:
response = as_admin.get(
"/api/admin/audit",
params={"action": "login.failed", "actor": "alice", "limit": 201, "offset": 400},
)
finally:
app.dependency_overrides.pop(admin_module.get_session, None)
assert response.status_code == 200, response.text
statement = fake_db.statement
assert statement._limit_clause.value == 201
assert statement._offset_clause.value == 400
assert "audit_log.action" in str(statement.whereclause)
assert "audit_log.actor" in str(statement.whereclause)
# ── учётные записи ──
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",
"Классификатор ЕКП", "Резервное копирование", "Секрет сессии",
"Нагрузка backend"} <= names
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin):
if not db_alive(as_admin):
pytest.skip("нет базы")
response = as_admin.get("/api/admin/diagnostics")
assert response.status_code == 200, response.text
body = response.json()
assert body["metrics"]["cpu_cores"] >= 1
assert body["metrics"]["disk_total_bytes"] > body["metrics"]["disk_free_bytes"]
assert body["metrics"]["active_sessions"] >= 0
assert isinstance(body["recent_system_events"], list)
assert isinstance(body["failed_actions_24h"], list)
download = as_admin.get("/api/admin/diagnostics.json")
assert download.status_code == 200
assert "attachment" in download.headers["content-disposition"]
def test_diagnostic_journal_redacts_credentials():
import logging
from app.monitoring import recent_events
logging.getLogger("app.redaction-test").warning(
"failure postgresql://user:secret@db/lct SESSION_SECRET=never-show"
)
event = recent_events(limit=1)[0]
assert "user:secret@" not in event["message"]
assert "never-show" not in event["message"]
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_listing_failure_does_not_break_admin_status(as_admin, monkeypatch):
from app.admin import backup as backup_service
def broken():
raise backup_service.BackupError("каталог временно недоступен")
monkeypatch.setattr(backup_service, "listing", broken)
response = as_admin.get("/api/admin/status")
assert response.status_code == 200, response.text
backup = next(item for item in response.json() if item["name"] == "Резервное копирование")
assert not backup["ok"]
assert "каталог временно недоступен" in backup["detail"]
def test_backup_list_returns_503_on_storage_error(as_admin, monkeypatch):
from app.admin import backup as backup_service
def broken():
raise backup_service.BackupError("каталог временно недоступен")
monkeypatch.setattr(backup_service, "listing", broken)
response = as_admin.get("/api/admin/backups")
assert response.status_code == 503
assert "каталог временно недоступен" in response.json()["detail"]
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"]
def test_pg17_transaction_timeout_is_removed_from_pg16_backup(monkeypatch, tmp_path):
"""Образ backend может содержать клиент новее сервера из Compose."""
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "DIR", tmp_path)
monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
monkeypatch.setattr(backup_service.shutil, "which", lambda name: "/usr/bin/pg_dump")
class Result:
returncode = 0
stderr = b""
stdout = (b"--\n-- PostgreSQL database dump\n--\n\n"
b"SET transaction_timeout = 0;\nCREATE TABLE demo(id integer);\n")
monkeypatch.setattr(backup_service.subprocess, "run", lambda *args, **kwargs: Result())
made = backup_service.create()
content = (tmp_path / made["name"]).read_bytes()
assert b"transaction_timeout" not in content
assert b"CREATE TABLE demo" in content
def test_docker_backup_does_not_put_database_password_in_process_arguments(monkeypatch, tmp_path):
import base64
from types import SimpleNamespace
from app.admin import backup as backup_service
password = "secret p@ss:word"
monkeypatch.setattr(backup_service, "DIR", tmp_path)
monkeypatch.setattr(backup_service, "_dsn", lambda: ("backup", password, "db", "5432", "lct"))
monkeypatch.setattr(
backup_service.shutil, "which", lambda name: "/usr/bin/docker" if name == "docker" else None
)
captured = {}
def fake_run(command, **kwargs):
captured["command"] = command
captured["input"] = kwargs["input"]
return SimpleNamespace(
returncode=0,
stderr=b"",
stdout=b"--\n-- PostgreSQL database dump\nCREATE TABLE demo(id integer);\n",
)
monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
backup_service.create()
assert password not in " ".join(captured["command"])
assert captured["input"] == base64.b64encode(password.encode()) + b"\n"
def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatch):
from types import SimpleNamespace
from app.admin import backup as backup_service
monkeypatch.setattr(
backup_service, "get_settings",
lambda: SimpleNamespace(database_url="postgresql://backup:p%40ss%3Aword@db.example:5433/lct"),
)
assert backup_service._dsn() == ("backup", "p@ss:word", "db.example", "5433", "lct")
monkeypatch.setattr(
backup_service, "get_settings",
lambda: SimpleNamespace(database_url="not-a-valid-url-with-secret"),
)
try:
backup_service._dsn()
except backup_service.BackupError as exc:
assert "secret" not in str(exc)
else:
raise AssertionError("invalid DATABASE_URL must be rejected")
def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path):
from app.admin import backup as backup_service
blocker = tmp_path / "not-a-directory"
blocker.write_text("occupied")
monkeypatch.setattr(backup_service, "DIR", blocker / "backups")
monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
with pytest.raises(backup_service.BackupError, match="каталог резервных копий"):
backup_service.create()
def test_backup_listing_io_failure_is_retryable_error(monkeypatch, tmp_path):
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "DIR", tmp_path)
def denied(_path):
raise PermissionError("access denied")
monkeypatch.setattr(backup_service.os, "scandir", denied)
with pytest.raises(backup_service.BackupError, match="прочитать каталог"):
backup_service.listing()
def test_windows_backup_permissions_use_explicit_private_acl(monkeypatch, tmp_path):
from types import SimpleNamespace
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "getuser", lambda: "operator")
monkeypatch.setenv("USERDOMAIN", "TRAINING")
monkeypatch.setenv("SystemRoot", str(tmp_path))
called = {}
def fake_run(command, **kwargs):
called["command"] = command
called["kwargs"] = kwargs
return SimpleNamespace(returncode=0)
monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
backup_service._restrict_file_permissions(-1, tmp_path / "copy.sql", platform_name="nt")
command = called["command"]
assert command[0].endswith("icacls.exe")
assert "/inheritance:r" in command
assert "TRAINING\\operator:(F)" in command
assert "*S-1-5-18:(F)" in command
assert called["kwargs"]["timeout"] == 15