Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
|
@ -47,12 +48,6 @@ def as_instructor(client):
|
|||
|
||||
|
||||
|
||||
def db_alive(client) -> bool:
|
||||
"""Часть точек без Postgres работать не может, и это не повод падать:
|
||||
на машине разработчика база может быть не поднята."""
|
||||
return client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
# ── границы роли ──
|
||||
|
||||
|
||||
|
|
@ -61,6 +56,7 @@ 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/audit.csv").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
|
||||
|
|
@ -68,6 +64,52 @@ def test_instructor_cannot_open_admin(as_instructor):
|
|||
|
||||
def test_anonymous_cannot_open_admin(client):
|
||||
assert client.get("/api/admin/users").status_code == 401
|
||||
assert client.get("/api/admin/audit.csv").status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [Role.INSTRUCTOR, Role.TRAINEE])
|
||||
def test_non_admin_roles_cannot_reach_any_admin_endpoint(client, monkeypatch, role):
|
||||
"""Exercise the complete current admin route surface with valid requests.
|
||||
|
||||
Stub only the DB dependency: every handler must reject the principal before
|
||||
reading or mutating any admin data. Keep this endpoint inventory explicit
|
||||
so a new admin route is added to the negative-role gate.
|
||||
"""
|
||||
import app.api.auth as auth_module
|
||||
from app.api.http import admin as admin_api
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_module,
|
||||
"current",
|
||||
lambda _request: Principal(login="not-admin", full_name="Пользователь", role=role),
|
||||
)
|
||||
|
||||
async def empty_session():
|
||||
yield object()
|
||||
|
||||
app.dependency_overrides[admin_api.get_session] = empty_session
|
||||
calls = [
|
||||
("GET", "/api/admin/config.xml", None),
|
||||
("GET", "/api/admin/users", None),
|
||||
("POST", "/api/admin/users", {
|
||||
"login": "new.user", "full_name": "Новый пользователь",
|
||||
"password": "long-enough-password", "role": "instructor",
|
||||
}),
|
||||
("PATCH", f"/api/admin/users/{uuid.uuid4()}", {"blocked": True}),
|
||||
("GET", "/api/admin/audit", None),
|
||||
("GET", "/api/admin/audit.csv", None),
|
||||
("GET", "/api/admin/diagnostics", None),
|
||||
("GET", "/api/admin/diagnostics.json", None),
|
||||
("GET", "/api/admin/status", None),
|
||||
("GET", "/api/admin/backups", None),
|
||||
("POST", "/api/admin/backups", None),
|
||||
]
|
||||
try:
|
||||
for method, path, payload in calls:
|
||||
response = client.request(method, path, json=payload)
|
||||
assert response.status_code == 403, (role, method, path, response.text)
|
||||
finally:
|
||||
app.dependency_overrides.pop(admin_api.get_session, None)
|
||||
|
||||
|
||||
def test_admin_downloads_safe_xml_configuration(as_admin):
|
||||
|
|
@ -78,6 +120,7 @@ def test_admin_downloads_safe_xml_configuration(as_admin):
|
|||
root = ET.fromstring(response.content)
|
||||
assert root.tag == "lctConfiguration"
|
||||
assert root.find("./workstations/workstation[@role='admin']") is not None
|
||||
assert root.find("./workstations/workstation[@role='admin']/screen[@path='/wall']") 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
|
||||
|
|
@ -141,15 +184,56 @@ def test_audit_api_applies_actor_action_and_offset_filters(as_admin):
|
|||
assert "audit_log.actor" in str(statement.whereclause)
|
||||
|
||||
|
||||
def test_audit_csv_streams_full_filtered_log_and_neutralizes_formulas(as_admin):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.main import app
|
||||
from app.api.http import admin as admin_module
|
||||
|
||||
row = SimpleNamespace(
|
||||
at=datetime(2026, 1, 2, tzinfo=timezone.utc), actor="=1+1", role="admin",
|
||||
action="login.failed", object_id=None, detail='строка; "подробности"',
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class FakeDb:
|
||||
async def stream_scalars(self, statement):
|
||||
captured["statement"] = statement
|
||||
|
||||
async def values():
|
||||
yield row
|
||||
|
||||
return values()
|
||||
|
||||
async def fake_session():
|
||||
yield FakeDb()
|
||||
|
||||
app.dependency_overrides[admin_module.get_session] = fake_session
|
||||
try:
|
||||
response = as_admin.get(
|
||||
"/api/admin/audit.csv", params={"action": "login.failed", "actor": "=1+1"}
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(admin_module.get_session, None)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-disposition"].endswith('filename="lct-audit.csv"')
|
||||
assert response.content.startswith(b"\xef\xbb\xbf")
|
||||
text = response.content.decode("utf-8-sig")
|
||||
assert ",\'=1+1," in text
|
||||
assert '"строка; ""подробности"""' in text
|
||||
statement = captured["statement"]
|
||||
assert statement._limit_clause is None, "CSV must not truncate older audit rows"
|
||||
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):
|
||||
def test_admin_creates_a_trainee_with_a_trainee_card(as_admin, postgres_access):
|
||||
"""У обучающегося должна появиться карточка курсанта: на ней висят
|
||||
профиль, история и проверка «это твой разбор» (lct-23)."""
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
login = f"курсант-{uuid.uuid4().hex[:8]}"
|
||||
response = as_admin.post(
|
||||
"/api/admin/users",
|
||||
|
|
@ -166,14 +250,15 @@ def test_admin_creates_a_trainee_with_a_trainee_card(as_admin):
|
|||
assert body["role"] == "trainee"
|
||||
assert body["service"] == "ДДС района"
|
||||
|
||||
audit_rows = as_admin.get("/api/admin/audit", params={"action": "user.create"}).json()
|
||||
assert any(row["object_id"] == login and row["detail"] == "Обучающийся"
|
||||
for row in audit_rows), "создание пользователя и audit row должны фиксироваться вместе"
|
||||
|
||||
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("нет базы")
|
||||
|
||||
def test_duplicate_login_is_refused(as_admin, postgres_access):
|
||||
login = f"двойник-{uuid.uuid4().hex[:8]}"
|
||||
payload = {
|
||||
"login": login, "full_name": "Первый", "password": "длинный-пароль", "role": "instructor",
|
||||
|
|
@ -182,6 +267,8 @@ def test_duplicate_login_is_refused(as_admin):
|
|||
second = as_admin.post("/api/admin/users", json=payload)
|
||||
assert second.status_code == 409
|
||||
assert second.json()["detail"] == "login_taken"
|
||||
audit_rows = as_admin.get("/api/admin/audit", params={"action": "user.create"}).json()
|
||||
assert sum(row["object_id"] == login for row in audit_rows) == 1
|
||||
|
||||
|
||||
def test_short_password_is_refused(as_admin):
|
||||
|
|
@ -192,11 +279,8 @@ def test_short_password_is_refused(as_admin):
|
|||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_admin_cannot_block_himself(as_admin):
|
||||
def test_admin_cannot_block_himself(as_admin, postgres_access):
|
||||
"""Иначе стенд остаётся без администратора до похода в базу руками."""
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
created = as_admin.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
|
|
@ -215,20 +299,14 @@ def test_admin_cannot_block_himself(as_admin):
|
|||
# ── состояние стенда ──
|
||||
|
||||
|
||||
def test_status_names_every_component(as_admin):
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
def test_status_names_every_component(as_admin, postgres_access):
|
||||
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("нет базы")
|
||||
|
||||
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin, postgres_access):
|
||||
response = as_admin.get("/api/admin/diagnostics")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -256,11 +334,8 @@ def test_diagnostic_journal_redacts_credentials():
|
|||
assert "never-show" not in event["message"]
|
||||
|
||||
|
||||
def test_default_session_secret_is_reported_as_a_problem(as_admin):
|
||||
def test_default_session_secret_is_reported_as_a_problem(as_admin, postgres_access):
|
||||
"""На стенде это дыра, и увидеть её должен администратор, а не проверяющий."""
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
secret = next(
|
||||
item for item in as_admin.get("/api/admin/status").json() if item["name"] == "Секрет сессии"
|
||||
)
|
||||
|
|
@ -304,6 +379,12 @@ def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
|
|||
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
|
||||
администратор видит, чего именно не хватает."""
|
||||
from app.admin import backup as backup_service
|
||||
from app.api.http import admin as admin_api
|
||||
|
||||
async def audit_is_available(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(admin_api, "audit_required", audit_is_available)
|
||||
|
||||
def broken():
|
||||
raise backup_service.BackupError("нет ни pg_dump, ни docker")
|
||||
|
|
@ -387,6 +468,34 @@ def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatc
|
|||
raise AssertionError("invalid DATABASE_URL must be rejected")
|
||||
|
||||
|
||||
def test_backup_endpoint_redacts_url_encoded_and_decoded_database_password(
|
||||
as_admin, monkeypatch,
|
||||
):
|
||||
from app.api.http import admin as admin_api
|
||||
from app.admin import backup as backup_service
|
||||
from app.admin.backup import BackupError
|
||||
|
||||
dsn = "postgresql://backup:p%40ss%3Aword@db.example:5433/lct"
|
||||
monkeypatch.setattr(
|
||||
admin_api, "get_settings", lambda: SimpleNamespace(database_url=dsn)
|
||||
)
|
||||
async def audit_is_available(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def fail_with_decoded_password():
|
||||
raise BackupError("connection failed for postgresql://backup:p@ss:word@db.example/lct")
|
||||
|
||||
monkeypatch.setattr(admin_api, "audit_required", audit_is_available)
|
||||
monkeypatch.setattr(backup_service, "create", fail_with_decoded_password)
|
||||
response = as_admin.post("/api/admin/backups")
|
||||
assert response.status_code == 503
|
||||
safe = response.json()["detail"]
|
||||
|
||||
assert "p@ss:word" not in safe
|
||||
assert "p%40ss%3Aword" not in safe
|
||||
assert "connection failed" in safe
|
||||
|
||||
|
||||
def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path):
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue