feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались ссылкой с номером занятия, и пускало знание адреса. - Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная cookie; роль на сокетах читается из той же cookie в момент рукопожатия, отдельного протокола авторизации в канале нет. - Разграничение: control — преподавателю, observe — преподавателю и админу, call и station — обучающемуся и преподавателю. Отказ приходит событием error с кодом forbidden. - Обучающийся не видит чужого: история подменяет фильтр на его собственный идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ к чужим результатам, а не только к чужим экранам. - Администратору закрыта правка оценок — ТЗ запрещает это прямо. - make users заводит по записи на роль и печатает случайные пароли один раз: зашитый в репозиторий admin/admin пережил бы сдачу. - Экран входа и проверка роли на каждом маршруте фронта. Наши инструменты не сломались: make lesson и тесты входят через dev-token за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной функции не должно быть видно вовсе. У тестов появился conftest.py. Role уехала в домен и в generated.ts через EventCatalog.principal: иначе фронт переписывал бы список ролей руками. 181 тест зелёный (14 новых), make typecheck чистый.
This commit is contained in:
parent
4c2bbfd021
commit
d55c8bc8cd
30 changed files with 984 additions and 21 deletions
31
backend/tests/conftest.py
Normal file
31
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Общая настройка тестов.
|
||||
|
||||
Голосовой контур выключен: он грузит модели около десяти секунд, а проверяется
|
||||
отдельно в `test_voice_pipeline.py`. Вход включён как на стенде, но тесты
|
||||
логинятся через `dev-token` — так же, как `make lesson` (tasks/lct-23-auth-roles.md).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _dev_auth():
|
||||
"""Флаг обхода читается один раз при создании настроек."""
|
||||
get_settings.cache_clear()
|
||||
settings = get_settings()
|
||||
assert settings.dev_auth_bypass, "тесты должны ходить через dev-token"
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def as_instructor(client):
|
||||
"""Клиент, вошедший преподавателем. Cookie живёт в самом TestClient."""
|
||||
response = client.post("/api/auth/dev-token")
|
||||
assert response.status_code == 200, response.text
|
||||
return client
|
||||
128
backend/tests/test_auth.py
Normal file
128
backend/tests/test_auth.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Вход, роли и аудит.
|
||||
|
||||
Пункты приёмки lct-23: курсант не открывает пульт даже по прямой ссылке,
|
||||
администратор не правит оценки, чужой разбор закрыт, пароли не хранятся
|
||||
в открытом виде.
|
||||
|
||||
Тесты, которым нужна база, пропускаются, когда Postgres не поднят: остальные
|
||||
проверяют разграничение, для которого база не нужна.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import hash_password, verify_password
|
||||
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
|
||||
|
||||
|
||||
# ── пароли ──
|
||||
|
||||
|
||||
def test_password_is_hashed_not_stored():
|
||||
stored = hash_password("правильный-пароль")
|
||||
assert "правильный-пароль" not in stored
|
||||
assert stored.startswith("$argon2")
|
||||
|
||||
|
||||
def test_password_verifies():
|
||||
stored = hash_password("правильный-пароль")
|
||||
assert verify_password(stored, "правильный-пароль")
|
||||
assert not verify_password(stored, "другой")
|
||||
|
||||
|
||||
def test_broken_hash_does_not_let_anyone_in():
|
||||
"""Битая запись в базе не должна открывать вход."""
|
||||
assert not verify_password("не хеш вовсе", "что угодно")
|
||||
|
||||
|
||||
# ── вход ──
|
||||
|
||||
|
||||
def test_unknown_login_and_wrong_password_look_the_same(client):
|
||||
"""Иначе форма входа превращается в список действующих учётных записей."""
|
||||
first = client.post("/api/auth/login", json={"login": "нет-такого", "password": "x"})
|
||||
assert first.status_code == 401
|
||||
assert first.json()["detail"] == "bad_credentials"
|
||||
|
||||
|
||||
def test_me_requires_authentication(client):
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_dev_token_gives_an_instructor(client):
|
||||
response = client.post("/api/auth/dev-token")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["role"] == "instructor"
|
||||
assert client.get("/api/auth/me").json()["role"] == "instructor"
|
||||
|
||||
|
||||
def test_logout_clears_the_session(client):
|
||||
client.post("/api/auth/dev-token")
|
||||
client.post("/api/auth/logout")
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
# ── разграничение ──
|
||||
|
||||
|
||||
def test_anonymous_cannot_open_any_socket(client):
|
||||
"""Номер занятия перестал быть пропуском: раньше по ссылке пускало знание
|
||||
адреса, теперь — роль."""
|
||||
session_id = uuid4()
|
||||
for path in (f"/ws/control/{session_id}", f"/ws/call/{session_id}",
|
||||
f"/ws/observe/{session_id}", f"/ws/station/{session_id}"):
|
||||
with client.websocket_connect(path) as socket:
|
||||
message = socket.receive_json()
|
||||
assert message["type"] == "error"
|
||||
assert message["code"] == "forbidden", path
|
||||
|
||||
|
||||
def test_anonymous_cannot_read_history(client):
|
||||
assert client.get("/api/sessions").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_cannot_read_trainees(client):
|
||||
assert client.get("/api/trainees").status_code == 401
|
||||
|
||||
|
||||
def test_classifier_is_open_to_everyone(client):
|
||||
"""Справочник ЕКП прятать не от кого: оператор видит тот же список
|
||||
на боевом АРМ."""
|
||||
assert client.get("/api/ekp/groups").status_code == 200
|
||||
|
||||
|
||||
def test_health_stays_open(client):
|
||||
"""Экран «стенд прогревается» показывается до входа."""
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
def test_instructor_can_open_control(client):
|
||||
"""На `control` сервер не шлёт ничего — это канал только на запись
|
||||
(docs/arch/CONTRACT.md). Признак того, что он открыт, — поднявшееся
|
||||
занятие, а не ответное сообщение."""
|
||||
import time
|
||||
|
||||
client.post("/api/auth/dev-token")
|
||||
session_id = uuid4()
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as socket:
|
||||
socket.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training"})
|
||||
deadline = time.monotonic() + 3
|
||||
while time.monotonic() < deadline and hub.get(session_id) is None:
|
||||
time.sleep(0.02)
|
||||
assert hub.get(session_id) is not None, "занятие не поднялось: канал отвергнут"
|
||||
|
||||
|
||||
def test_roles_are_three_and_named_in_the_spec():
|
||||
assert {role.value for role in Role} == {"admin", "instructor", "trainee"}
|
||||
|
|
@ -76,7 +76,10 @@ def test_session_modes_are_the_three_from_docs():
|
|||
|
||||
|
||||
def test_event_catalog_covers_every_channel():
|
||||
"""`principal` каналом не является: он в каталоге ради того, чтобы список
|
||||
ролей приезжал на фронт из домена, а не переписывался руками (lct-23)."""
|
||||
assert set(EventCatalog.model_fields) == {
|
||||
"principal",
|
||||
"server_to_trainee",
|
||||
"trainee_to_server",
|
||||
"server_to_observer",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ def client():
|
|||
except OSError as exc:
|
||||
pytest.skip(f"Postgres недоступен ({exc}) — подними `make dev`")
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
test_client.post("/api/auth/dev-token")
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ from app.session.hub import hub
|
|||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ from app.session.hub import hub
|
|||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None # тесты не пишут в БД: проверяется поведение каналов
|
||||
yield test_client
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue