Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -6,6 +6,8 @@
"""
import os
import socket
from urllib.parse import urlparse
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
@ -14,6 +16,24 @@ import pytest # noqa: E402
from app.config import get_settings # noqa: E402
@pytest.fixture
def postgres_access():
"""Skip DB integration cases when the configured PostgreSQL is unreachable.
`/api/health` is a liveness endpoint and deliberately does not probe the
database. Use a short TCP check so sandbox/network-denied runs are reported
as unverified integration tests instead of misleading application failures.
"""
url = urlparse(get_settings().database_url)
if url.scheme not in {"postgres", "postgresql", "postgresql+asyncpg"}:
pytest.skip("PostgreSQL integration test requires a PostgreSQL DATABASE_URL")
try:
with socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2):
pass
except OSError as exc:
pytest.skip(f"PostgreSQL unavailable ({exc})")
@pytest.fixture(autouse=True, scope="session")
def _dev_auth():
"""Флаг обхода читается один раз при создании настроек."""

View file

@ -0,0 +1,34 @@
from app.api.ws.station import _address_matches
from app.scoring.address import address_matches
def test_street_abbreviation_matches_but_similarly_named_street_does_not():
expected = "Дубнинская улица, дом 10"
assert address_matches(expected, "ул. Дубнинская, д. 10")
assert not _address_matches(expected, "Дубининская улица, дом 10")
def test_street_type_is_part_of_the_operational_address():
expected = "Москва, улица Ленина, дом 10"
assert address_matches(expected, "г. Москва, ул. Ленина, д. 10")
assert not address_matches(expected, "Москва, переулок Ленина, дом 10")
assert not address_matches(
expected, "Москва, улица Ленина и переулок Ленина, дом 10"
)
def test_house_and_apartment_numbers_cannot_be_swapped():
expected = "дом 10, квартира 20"
assert address_matches(expected, "д. 10, кв. 20, подъезд 3")
assert not address_matches(expected, "дом 20, квартира 10")
def test_corpus_and_building_numbers_keep_their_roles():
expected = "Москва, улица Мира, дом 5, корпус 1, квартира 20"
assert address_matches(expected, "г. Москва, ул. Мира, д. 5, корп. 1, кв. 20")
assert not address_matches(expected, "Москва, ул. Мира, дом 1, корп. 5, кв. 20")
def test_empty_or_partial_operational_address_is_not_a_match():
assert not address_matches("улица Мира, дом 5", "")
assert not address_matches("улица Мира, дом 5", "улица Мира")

View file

@ -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

View file

@ -8,6 +8,8 @@
проверяют разграничение, для которого база не нужна.
"""
import time
from types import SimpleNamespace
from uuid import uuid4
import pytest
@ -46,10 +48,17 @@ def test_broken_hash_does_not_let_anyone_in():
assert not verify_password("не хеш вовсе", "что угодно")
def test_malformed_stored_hash_is_not_written_to_logs(caplog):
stored_hash = "private-stored-hash-marker"
assert not verify_password(stored_hash, "candidate-password")
assert stored_hash not in caplog.text
assert "InvalidHash" in caplog.text
# ── вход ──
def test_unknown_login_and_wrong_password_look_the_same(client):
def test_unknown_login_and_wrong_password_look_the_same(client, postgres_access):
"""Иначе форма входа превращается в список действующих учётных записей."""
first = client.post("/api/auth/login", json={"login": "нет-такого", "password": "x"})
assert first.status_code == 401
@ -78,9 +87,186 @@ def test_dev_token_gives_an_instructor(client):
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")
def test_directory_login_issues_the_mapped_role_and_identity(client, monkeypatch):
from app import directory
from app.api import auth
from app.config import get_settings
from app.directory import DirectoryIdentity
# This route test supplies its own account and sessionmaker below. Mark an
# empty auth-generation snapshot fresh as if startup had loaded the empty
# test directory; otherwise the production middleware correctly fails
# closed with 503 when the sandbox cannot reach PostgreSQL.
monkeypatch.setattr(auth, "_generations", {})
monkeypatch.setattr(auth, "_generations_synced_at", time.monotonic())
provisioned = {}
class EmptyDb:
async def scalar(self, query):
if "users.auth_version" in str(query) and "user" in provisioned:
return provisioned["user"].auth_version
return None
class DbContext:
async def __aenter__(self):
return EmptyDb()
async def __aexit__(self, *_args):
return None
settings = get_settings().model_copy(update={"ldap_enabled": True})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
identity = DirectoryIdentity(
login="trainee.one",
full_name="Курсант Один",
role=Role.TRAINEE,
service="01",
subject="directory-guid-1",
)
async def authenticate(login, password):
assert login == "trainee.one"
assert password == "directory-password"
return identity
async def provision(_identity):
provisioned["user"] = SimpleNamespace(
login=identity.login,
full_name=identity.full_name,
role=identity.role.value,
service=identity.service,
trainee_id=uuid4(),
auth_version=0,
blocked=False,
)
return provisioned["user"]
async def audit(*_args, **_kwargs):
return None
monkeypatch.setattr(directory, "authenticate", authenticate)
monkeypatch.setattr(auth, "_directory_account", provision)
monkeypatch.setattr(auth, "audit", audit)
response = client.post(
"/api/auth/login",
json={"login": "trainee.one", "password": "directory-password"},
)
assert response.status_code == 200, response.text
assert response.json()["role"] == "trainee"
assert response.json()["service"] == "01"
assert client.get("/api/auth/me").json()["login"] == "trainee.one"
def test_directory_outage_does_not_fall_back_or_issue_a_session(client, monkeypatch):
from app import directory
from app.api import auth
from app.config import get_settings
from app.directory import DirectoryUnavailable
class EmptyDb:
async def scalar(self, _query):
return None
class DbContext:
async def __aenter__(self):
return EmptyDb()
async def __aexit__(self, *_args):
return None
monkeypatch.setattr(
auth,
"get_settings",
lambda: get_settings().model_copy(update={"ldap_enabled": True}),
)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
async def unavailable(*_args):
raise DirectoryUnavailable("directory service unavailable")
async def audit(*_args, **_kwargs):
return None
monkeypatch.setattr(directory, "authenticate", unavailable)
monkeypatch.setattr(auth, "audit", audit)
response = client.post(
"/api/auth/login", json={"login": "trainee.one", "password": "anything"}
)
assert response.status_code == 503
assert response.json()["detail"] == "directory_unavailable"
assert client.get("/api/auth/me").status_code == 401
def test_blocked_directory_account_attempt_is_audited(client, monkeypatch):
from app import directory
from app.api import auth
from app.config import get_settings
from app.directory import DirectoryIdentity
class EmptyDb:
async def scalar(self, _query):
return None
class DbContext:
async def __aenter__(self):
return EmptyDb()
async def __aexit__(self, *_args):
return None
settings = get_settings().model_copy(update={"ldap_enabled": True})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
identity = DirectoryIdentity(
login="trainee.one",
full_name="Курсант Один",
role=Role.TRAINEE,
service="01",
subject="directory-guid-blocked",
)
async def authenticate(*_args):
return identity
async def provision(_identity):
return SimpleNamespace(
login=identity.login,
full_name=identity.full_name,
role=identity.role.value,
service=identity.service,
trainee_id=uuid4(),
auth_version=0,
blocked=True,
)
audit_events = []
async def audit(*args):
audit_events.append(args)
monkeypatch.setattr(directory, "authenticate", authenticate)
monkeypatch.setattr(auth, "_directory_account", provision)
monkeypatch.setattr(auth, "audit", audit)
response = client.post(
"/api/auth/login",
json={"login": "trainee.one", "password": "directory-password"},
)
assert response.status_code == 403
assert response.json()["detail"] == "blocked"
assert any(event[2] == "login.blocked" for event in audit_events)
assert client.get("/api/auth/me").status_code == 401
def test_logout_clears_and_revokes_the_session(client, postgres_access):
assert client.post("/api/auth/dev-token").status_code == 200
stale_cookie = client.cookies.get("lct_session")
response = client.post("/api/auth/logout")
assert response.status_code == 200, response.text
assert client.get("/api/auth/me").status_code == 401
# Replaying a copied pre-logout cookie must not restore the authenticated session.
client.cookies.set("lct_session", stale_cookie)
assert client.get("/api/auth/me").status_code == 401

View file

@ -1,6 +1,10 @@
"""Regressions for stale cookies and privileged admin operations."""
import asyncio
import re
import weakref
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
@ -9,13 +13,78 @@ from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import app.api.auth as auth
from app.api import auth
from app.api.http import admin
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@pytest.mark.parametrize(
"headers, scope, expected",
[
({"origin": "http://training.lan", "host": "training.lan"}, {"scheme": "ws"}, True),
(
{
"origin": "https://training.lan:5443",
"host": "backend:8000",
"x-forwarded-host": "training.lan:5443",
"x-forwarded-proto": "https",
},
{"scheme": "ws"},
True,
),
({"origin": "https://attacker.invalid", "host": "training.lan"}, {"scheme": "ws"}, False),
({"origin": "http://training.lan:5173", "host": "training.lan:8000"}, {"scheme": "ws"}, False),
(
{
"origin": "http://training.lan",
"host": "backend:8000",
"x-forwarded-host": "training.lan",
"x-forwarded-proto": "https",
},
{"scheme": "wss"},
False,
),
({"host": "training.lan"}, {"scheme": "ws"}, True),
({"origin": "not a URL", "host": "training.lan"}, {"scheme": "ws"}, False),
],
)
def test_websocket_origin_policy(headers, scope, expected):
assert auth.websocket_origin_allowed(SimpleNamespace(headers=headers, scope=scope)) is expected
def test_nginx_proxies_preserve_external_host_for_websocket_origin_validation():
project_root = Path(__file__).resolve().parents[2]
for config in ("nginx.conf.template", "nginx.tls.conf.template"):
text = (project_root / "frontend" / config).read_text(encoding="utf-8")
match = re.search(r"location /ws/ \{(.*?)^ \}", text, re.MULTILINE | re.DOTALL)
assert match is not None, f"{config}: missing WebSocket proxy block"
websocket_location = match.group(1)
assert "proxy_set_header X-Forwarded-Host $http_host;" in websocket_location
tls = (project_root / "frontend" / "nginx.tls.conf.template").read_text(encoding="utf-8")
match = re.search(r"location /ws/ \{(.*?)^ \}", tls, re.MULTILINE | re.DOTALL)
assert match is not None
tls_websocket_location = match.group(1)
assert "proxy_set_header X-Forwarded-Proto https;" in tls_websocket_location
def test_cluster_nginx_pins_all_session_channels_and_session_apis_to_one_hash_key():
project_root = Path(__file__).resolve().parents[2]
for config in ("nginx.cluster.conf.template", "nginx.cluster.tls.conf.template"):
text = (project_root / "frontend" / config).read_text(encoding="utf-8")
assert "hash $session_route_key consistent;" in text
assert "server backend:8000" in text and "server backend-b:8000" in text
assert re.search(
r"~\^/ws/\(\?:control\|call\|observe\|station\)/\(\[0-9a-fA-F-\]\{36\}\)",
text,
), f"{config}: all WebSocket channels must extract the same session UUID"
assert re.search(
r"~\^/api/sessions/\(\[0-9a-fA-F-\]\{36\}\)", text
), f"{config}: session REST endpoints must use the same routing key"
assert text.count("proxy_pass http://backend_cluster;") == 2
@pytest.fixture
def client():
# Each TestClient represents a fresh backend process. In particular,
@ -38,6 +107,43 @@ def test_account_change_revokes_http_and_new_websocket_handshakes(client):
assert client.get("/api/auth/me").status_code == 200
def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
class FakeResult:
def all(self):
return []
class FakeDb:
async def execute(self, _query):
return FakeResult()
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
async def run():
auth.prime_generations({"dev": 7})
await auth.sync_generations()
assert auth._generations["dev"] == 7
auth._generations.pop("dev", None)
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=True))
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
asyncio.run(run())
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
assert client.post("/api/auth/dev-token").status_code == 200
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(
f"/ws/control/{uuid4()}", headers={"origin": "https://attacker.invalid"}
):
pytest.fail("cross-origin websocket must not be accepted")
assert exc.value.code == 1008
def test_account_change_closes_an_existing_websocket(client):
assert client.post("/api/auth/dev-token").status_code == 200
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
@ -59,6 +165,334 @@ def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(clien
assert client.get("/api/auth/me").status_code == 200
def test_login_is_not_issued_when_security_audit_cannot_be_written(client, monkeypatch):
from app.config import get_settings
user = SimpleNamespace(
login="audit-login", auth_provider="local", password_hash="hash",
blocked=False, role="instructor", full_name="Преподаватель",
service=None, trainee_id=None, auth_version=0,
)
class FakeDb:
async def scalar(self, _statement):
return user
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
async def audit_failure(*_args, **_kwargs):
return False
monkeypatch.setattr(auth, "audit", audit_failure)
response = client.post(
"/api/auth/login", json={"login": user.login, "password": "valid"}
)
assert response.status_code == 503
assert response.json()["detail"] == "audit_unavailable"
assert client.get("/api/auth/me").status_code == 401
@pytest.mark.asyncio
async def test_audit_storage_failure_does_not_log_user_supplied_detail(caplog, monkeypatch):
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
def broken_session():
raise RuntimeError("private-user-comment-must-not-reach-logs")
monkeypatch.setattr(auth, "get_sessionmaker", lambda: broken_session)
assert not await auth.audit(
"teacher", "instructor", "score.override", "session-id",
"sensitive comment must not be logged",
)
assert "private-user-comment-must-not-reach-logs" not in caplog.text
assert "sensitive comment" not in caplog.text
assert "RuntimeError" in caplog.text
def test_demo_logout_revokes_replayed_cookie(client, monkeypatch):
settings = auth.get_settings().model_copy(update={"demo_no_db": True})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
assert client.post("/api/auth/dev-token").status_code == 200
stale_cookie = client.cookies.get("lct_session")
assert client.post("/api/auth/logout").status_code == 200
client.cookies.set("lct_session", stale_cookie)
assert client.get("/api/auth/me").status_code == 401
def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch):
login = "peer-revoked"
class FakeResult:
def all(self):
return [(login, 4)]
class FakeDb:
async def execute(self, _query):
return FakeResult()
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
class FakeSocket:
closed = False
async def close(self, **_kwargs):
self.closed = True
async def run():
auth.prime_generations({login: 3})
socket = FakeSocket()
auth._active_sockets[login] = weakref.WeakKeyDictionary({
socket: asyncio.get_running_loop(),
})
await auth.sync_generations()
await asyncio.sleep(0.01)
assert auth._generations[login] == 4
assert socket.closed
auth._active_sockets.pop(login, None)
auth._generations.pop(login, None)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
asyncio.run(run())
def test_revocation_does_not_log_error_if_socket_already_disconnected():
class DisconnectedSocket:
async def close(self, **_kwargs):
raise WebSocketDisconnect(code=1006)
asyncio.run(auth._close_revoked(DisconnectedSocket()))
def test_auth_middleware_rejects_cookie_with_old_database_epoch(monkeypatch):
login = "stale-cookie"
class FakeDb:
async def scalar(self, _query):
return 5
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
observed = {}
class InnerApp:
async def __call__(self, scope, _receive, _send):
observed["session"] = dict(scope["session"])
async def run():
auth.prime_generations({login: 5})
cookie_session = {
"principal": {"login": login},
"auth_instance": auth._INSTANCE,
"auth_generation": 4,
}
scope = {"type": "http", "session": cookie_session}
async def unused_receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def unused_send(_message):
return None
middleware = auth.AuthVersionMiddleware(InnerApp())
await middleware(scope, unused_receive, unused_send)
assert observed["session"] == {}
auth._generations.pop(login, None)
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
asyncio.run(run())
def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_logout(monkeypatch):
from app.config import get_settings
class BrokenSession:
async def __aenter__(self):
raise OSError("database unavailable")
async def __aexit__(self, *_args):
return None
class InnerApp:
def __init__(self):
self.called = False
async def __call__(self, _scope, _receive, _send):
self.called = True
async def run():
login = "db-outage-user"
auth.prime_generations({login: 0})
monkeypatch.setattr(
auth, "_generations_synced_at",
auth.time.monotonic() - auth.AUTH_GENERATION_MAX_AGE_SECONDS - 1,
)
principal = auth.Principal(
login=login, full_name="Учётная запись", role=Role.INSTRUCTOR
)
scope = {"type": "http", "path": "/api/admin/users", "session": {
"principal": principal.model_dump(mode="json"),
"auth_instance": auth._INSTANCE,
"auth_generation": 0,
}}
messages = []
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
messages.append(message)
protected = InnerApp()
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
assert not protected.called
assert messages[0]["status"] == 503
logout_scope = {**scope, "path": "/api/auth/logout", "session": dict(scope["session"])}
logout = InnerApp()
await auth.AuthVersionMiddleware(logout)(logout_scope, receive, send)
assert logout.called, "logout must reach the route so it can clear the cookie"
settings = get_settings().model_copy(update={"demo_no_db": False, "dev_auth_bypass": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
asyncio.run(run())
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
from app.config import get_settings
class InnerApp:
def __init__(self):
self.called = False
async def __call__(self, _scope, _receive, _send):
self.called = True
async def run():
login = "cached-generation-user"
auth.prime_generations({login: 6})
principal = auth.Principal(
login=login, full_name="Учётная запись", role=Role.INSTRUCTOR
)
scope = {"type": "http", "path": "/api/admin/users", "session": {
"principal": principal.model_dump(mode="json"),
"auth_instance": auth._INSTANCE,
"auth_generation": 6,
}}
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(_message):
return None
protected = InnerApp()
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
assert protected.called, "a fresh, matching epoch should reach role-protected route auth"
auth._generations.pop(login, None)
settings = get_settings().model_copy(update={"demo_no_db": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(
auth, "get_sessionmaker",
lambda: (_ for _ in ()).throw(AssertionError("middleware must use its synced cache")),
)
asyncio.run(run())
def test_stale_generation_sync_closes_existing_authenticated_websockets():
class FakeWebSocket:
def __init__(self):
self.closed_with = None
async def close(self, code, reason):
self.closed_with = (code, reason)
async def run():
login = "stale-cache-socket-user"
websocket = FakeWebSocket()
sockets = auth._active_sockets.setdefault(
login, weakref.WeakKeyDictionary()
)
sockets[websocket] = asyncio.get_running_loop()
try:
auth._close_unverified_sockets()
await asyncio.sleep(0)
await asyncio.sleep(0)
assert websocket.closed_with == (
1013, "Состояние доступа временно недоступно",
)
finally:
auth._active_sockets.pop(login, None)
asyncio.run(run())
def test_generation_watcher_fails_closed_after_database_sync_error(monkeypatch):
class FakeWebSocket:
def __init__(self):
self.closed_with = None
async def close(self, code, reason):
self.closed_with = (code, reason)
class StopWatcher(Exception):
pass
async def run():
login = "sync-error-socket-user"
websocket = FakeWebSocket()
auth._active_sockets.setdefault(
login, weakref.WeakKeyDictionary()
)[websocket] = asyncio.get_running_loop()
async def broken_sync():
raise OSError("database unavailable")
await_original_sleep = asyncio.sleep
async def stop_after_iteration(_seconds):
raise StopWatcher()
monkeypatch.setattr(auth, "sync_generations", broken_sync)
monkeypatch.setattr(auth.asyncio, "sleep", stop_after_iteration)
monkeypatch.setattr(
auth, "_generations_synced_at",
auth.time.monotonic() - auth.AUTH_GENERATION_MAX_AGE_SECONDS - 1,
)
try:
try:
await auth.watch_generations()
except StopWatcher:
pass
await await_original_sleep(0)
await await_original_sleep(0)
assert websocket.closed_with == (
1013, "Состояние доступа временно недоступно",
)
finally:
auth._active_sockets.pop(login, None)
asyncio.run(run())
class FakeDb:
def __init__(self, user):
self.user = user
@ -84,7 +518,8 @@ def fake_user(login="victim", role="instructor"):
return SimpleNamespace(
id=uuid4(), login=login, full_name="Проверка", role=role,
service=None, trainee_id=None, blocked=False,
password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc),
password_hash="old", auth_provider="local", directory_subject=None,
auth_version=0, created_at=datetime.now(timezone.utc),
)
@ -101,13 +536,34 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
lambda login, version=None: calls.append((login, version, db.commits)),
)
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert user.blocked is True
assert calls == [("victim", 1, 1)]
assert db.added[-1].action == "user.update"
assert db.added[-1].object_id == "victim"
assert "заблокирован" in db.added[-1].detail
@pytest.mark.asyncio
async def test_admin_patch_never_revokes_or_reports_success_when_audit_commit_fails(monkeypatch):
user = fake_user()
class BrokenCommitDb(FakeDb):
async def commit(self):
raise RuntimeError("audit table unavailable")
db = BrokenCommitDb(user)
invalidations = []
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
monkeypatch.setattr(admin, "invalidate_login", lambda *args: invalidations.append(args))
with pytest.raises(RuntimeError, match="audit table unavailable"):
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert invalidations == [], "сессию отзываем только после атомарного commit"
assert db.added[-1].action == "user.update"
@pytest.mark.asyncio
@ -131,36 +587,43 @@ async def test_promotion_to_trainee_creates_profile(monkeypatch):
login="admin", full_name="Администратор", role=Role.ADMIN,
))
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
assert user.role == "trainee"
assert user.trainee_id is not None
assert db.commits == 1
assert db.added[-1].action == "user.update"
assert db.added[-1].detail == "роль trainee"
@pytest.mark.asyncio
async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
calls = []
threadpool_calls = []
audit_calls = []
outcome_calls = []
async def fake_threadpool(fn):
calls.append(fn)
threadpool_calls.append(fn)
return fn()
async def fake_audit_required(*args, **kwargs):
audit_calls.append((args, kwargs))
async def fake_audit(*args, **kwargs):
calls.append((args, kwargs))
outcome_calls.append((args, kwargs))
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(admin, "audit_required", fake_audit_required)
monkeypatch.setattr(admin, "audit", fake_audit)
monkeypatch.setattr(admin.backup_service, "create", lambda: {
"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc),
})
assert (await admin.make_backup(object())).name == "example.sql"
assert calls[0] is admin.backup_service.create
assert threadpool_calls == [admin.backup_service.create]
assert [item[0][2] for item in audit_calls] == [
"backup.create.requested", "backup.create",
]
def broken():
raise admin.backup_service.BackupError("pg_dump failed")
@ -169,7 +632,40 @@ async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
with pytest.raises(HTTPException) as exc:
await admin.make_backup(object())
assert exc.value.status_code == 503
assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls)
assert outcome_calls[-1][0][2] == "backup.failed"
@pytest.mark.asyncio
async def test_backup_success_is_not_returned_when_audit_is_unavailable(monkeypatch):
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
created = []
async def fake_threadpool(fn):
return fn()
audit_actions = []
async def fail_after_backup(*args, **_kwargs):
audit_actions.append(args[2])
if args[2] == "backup.create":
raise HTTPException(status_code=503, detail="audit_unavailable")
def create_backup():
created.append("example.sql")
return {"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc)}
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(admin, "audit_required", fail_after_backup)
monkeypatch.setattr(admin.backup_service, "create", create_backup)
with pytest.raises(HTTPException) as exc:
await admin.make_backup(object())
assert exc.value.status_code == 503
assert exc.value.detail == "audit_unavailable"
assert audit_actions == ["backup.create.requested", "backup.create"]
assert created == ["example.sql"] # artifact exists; the response does not misreport audit success
def test_backup_error_redacts_database_credentials(monkeypatch):

View file

@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from scripts import backup_loop
@ -18,3 +19,42 @@ def test_overdue_backup_is_due_immediately(monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(days=2)}])
assert backup_loop.seconds_until_due(now, 86_400) == 0
def test_scheduler_waits_only_remainder_after_slow_backup(monkeypatch):
settings = SimpleNamespace(
backup_interval_seconds=86_400,
backup_retry_seconds=300,
backup_keep=14,
)
copies = []
waits = []
def listing():
return copies
def create():
# Model pg_dump taking 20 minutes before finishing the scheduler cycle.
copies.append({
"name": "recent.sql",
"at": datetime.now(timezone.utc) - timedelta(minutes=20),
})
return {"name": "recent.sql", "size_bytes": 123}
def sleep(seconds):
waits.append(seconds)
raise RuntimeError("stop after observing next scheduled wait")
monkeypatch.setattr(backup_loop, "get_settings", lambda: settings)
monkeypatch.setattr(backup_loop, "listing", listing)
monkeypatch.setattr(backup_loop, "create", create)
monkeypatch.setattr(backup_loop, "prune", lambda keep: 0)
monkeypatch.setattr(backup_loop.time, "sleep", sleep)
try:
backup_loop.run_forever()
except RuntimeError as exc:
assert str(exc) == "stop after observing next scheduled wait"
assert len(waits) == 1
assert 85_190 <= waits[0] <= 85_200

View file

@ -0,0 +1,47 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from app.api.ws import call
from app.dialog.slots import TurnResult
from app.domain.events import Exercise, TranscriptEntry
@pytest.mark.asyncio
async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body(caplog, monkeypatch):
secret = "private-incident-address-from-provider-error"
class Caller:
async def reply(self, *_args):
raise RuntimeError(secret)
class Slots:
def hear(self, text):
return TurnResult(text=text)
def revealed_facts(self):
return []
monkeypatch.setattr(call.hub, "journal", None)
monkeypatch.setattr(call.hub, "to_trainee", lambda *_args: None)
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
state = SimpleNamespace(
ended=False,
exercise=Exercise.CARD,
dispatched_card=None,
caller=Caller(),
persona=object(),
scenario=SimpleNamespace(facts=[], checklist=[]),
slots=Slots(),
text_revealed_facts={},
append=lambda speaker, text: TranscriptEntry(
ref="transcript-ref", speaker=speaker, text=text, at=datetime.now(timezone.utc),
),
)
await call._handle(uuid4(), state, SimpleNamespace(type="text.turn", text="where is the incident"))
assert secret not in caplog.text
assert "RuntimeError" in caplog.text

View file

@ -1,17 +1,26 @@
"""Текстовая вводная 112: карточка без голоса, опроса и ДДС-оценки."""
"""Текстовое упражнение 112: переписка с заявителем без голоса."""
import time
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.main import app
from app.api.ws.call import _text_turn
from app.scenarios import store
from app.scoring.grammar import basic_check
from app.session.hub import hub
@pytest.fixture
def client():
def client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.journal = None
@ -36,13 +45,22 @@ def read_until(socket, wanted):
raise AssertionError(f"событие {wanted} не пришло")
def start(client, *, handoff_to_dds=False):
async def rules_only_grammar(text):
return basic_check(text)
def start(client, *, handoff_to_dds=False, criteria=None, scenario_ids=None):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": "card",
"handoff_to_dds": handoff_to_dds})
payload = {"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": "card",
"handoff_to_dds": handoff_to_dds}
if scenario_ids is not None:
payload["scenario_ids"] = scenario_ids
if criteria is not None:
payload["criteria"] = criteria
control.send_json(payload)
wait_for(lambda: hub.get(session_id))
return session_id, context
@ -53,12 +71,13 @@ def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
state = hub.get(session_id)
assert state.exercise.value == "card"
assert state.dispatched_card is None
assert state.slots is None and state.voice is None
assert state.voice is None
assert state.caller is not None and state.persona is not None
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
briefing = read_until(trainee, "card.briefing")
assert briefing["scenario_id"] == "fire-apartment-l2"
assert "Помогите" in briefing["text"]
assert "горит балкон" in briefing["text"]
assert "горит балкон" not in briefing["text"]
assert "ground_truth" not in briefing
assert briefing["card"]["address"] is None
assert "address" in briefing["required_fields"]
@ -70,7 +89,7 @@ def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
control.__exit__(None, None, None)
def test_correct_card_scores_100_without_call_or_dds_metrics(client):
def test_correct_card_scores_100_including_grammar_without_call_or_dds_metrics(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -79,7 +98,7 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
assert "dds" not in briefing["required_fields"]
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"victims_count": 2, "description": "Горит балкон.",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
@ -93,9 +112,17 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
assert state.dispatched_card is not None
assert state.score["score_auto"] == 100.0
assert not state.score["findings"]
grammar_metric = next(item for item in state.score["metrics"]
if item["key"] == "description_grammar")
assert grammar_metric["passed"]
assert {item["key"] for item in state.score["metrics"]} == {
"incident_signs", "address", "victims_count", "required_fields"
"incident_signs", "address", "victims_count", "required_fields",
"description_grammar", "card_fill_time",
}
assert state.score["summary"]["card_fill_ms"] is not None
fill_metric = next(item for item in state.score["metrics"] if item["key"] == "card_fill_time")
assert fill_metric["passed"]
assert "норматива" in fill_metric["fact"]
with client.websocket_connect(f"/ws/call/{session_id}") as late:
assert read_until(late, "card.briefing")["card"]["address"] == "улица Ленина, 14"
assert read_until(late, "call.ended")["reason"] == "complete"
@ -104,6 +131,114 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
control.__exit__(None, None, None)
def test_text_exercise_allows_questions_and_returns_grounded_caller_reply(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "text.turn", "text": "Что горит?"})
accepted = read_until(trainee, "text.turn.accepted")
assert accepted["text"] == "Что горит?"
reply = read_until(trainee, "caller.utterance")
assert "балкон" in reply["text"] or "загорел" in reply["text"]
assert hub.get(session_id).text_revealed_facts["f_what_burns"] == "горит балкон, дым пошёл в квартиру"
finally:
control.__exit__(None, None, None)
def test_natural_request_for_precise_address_reveals_refinement_without_embedder(client):
scenario = store.get("t01-1-fire-container")
assert scenario is not None
address = next(fact for fact in scenario.facts if fact.id == "f_address")
assert address.refined == scenario.ground_truth.address
state = SimpleNamespace(
scenario=scenario,
slots=None,
text_revealed_facts={address.id: address.value},
)
turn = _text_turn(state, "а точнее можете назвать на ближайшем доме?")
assert turn.refined == ["f_address"]
assert turn.matched == ["q_address_check"]
assert state.text_revealed_facts[address.id] == address.refined
def test_card_fill_overrun_is_reported_as_e3_with_actual_and_norm(client):
session_id, control = start(client, criteria={"card_fill_time_limit_seconds": 60})
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
state = hub.get(session_id)
assert state.timers.limits[next(code for code in state.timers.limits
if code.value == "card_fill")] == 60_000
state.timers.timers[next(code for code in state.timers.timers
if code.value == "card_fill")].started_at = time.monotonic() - 61
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
read_until(trainee, "score.ready")
state = hub.get(session_id)
metric = next(item for item in state.score["metrics"] if item["key"] == "card_fill_time")
assert not metric["passed"]
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
assert metric["norm"] == "сдать карточку за 60 с"
finding = next(item for item in state.score["findings"] if item["code"] == "E3")
assert "Время заполнения карточки" in finding["summary"]
finally:
control.__exit__(None, None, None)
def test_grammar_criterion_flags_incorrect_card_description(client, monkeypatch):
monkeypatch.setattr("app.session.finish.assess", rules_only_grammar)
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
read_until(trainee, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
metric = next(item for item in score["metrics"] if item["key"] == "description_grammar")
assert not metric["passed"]
assert metric["fact"]
assert any(item["code"] == "E4" for item in score["findings"])
e4 = next(item for item in score["findings"] if item["code"] == "E4")
assert e4["source"] == "grammar"
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)
def test_disabled_grammar_criterion_does_not_change_card_score(client, monkeypatch):
monkeypatch.setattr("app.session.finish.assess", rules_only_grammar)
session_id, control = start(client, criteria={"require_correct_grammar": False})
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
read_until(trainee, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert score["score_auto"] == 100
assert not any(item["key"] == "description_grammar" for item in score["metrics"])
finally:
control.__exit__(None, None, None)
def test_incomplete_card_has_only_card_findings(client):
session_id, control = start(client)
try:
@ -155,7 +290,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(item for item in snapshot["crew_options"] if item.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
station.send_json({"type": "card.status", "service": service, "status": "accepted",
"comment": "Старший группы подтвердил приём карточки."})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
@ -167,7 +303,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
"request": "Прошу подтвердить выезд и доложить о прибытии"})
assert read_until(station, "phone.report")["phase"] == "dispatched"
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
station.send_json({"type": "card.status", "service": service, "status": "responding",
"comment": "Старший группы сообщил о начале реагирования."})
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
@ -180,3 +317,45 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)
def test_handoff_queue_mixes_trainee_card_then_selected_prepared_card(client):
session_id, control = start(
client, handoff_to_dds=True,
scenario_ids=["fire-apartment-l2", "t01-1-fire-container"],
)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
with client.websocket_connect(f"/ws/station/{session_id}") as station:
first = read_until(station, "card.received")
assert first["from_operator"] == "Иванов"
assert first["card"]["address"] == "улица Ленина, 14"
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card_total"] == 2
assert {item["scenario_id"] for item in snapshot["queue_cards"]} == {
"fire-apartment-l2", "t01-1-fire-container"
}
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
state = wait_for(lambda: hub.get(session_id).score)
assert state["card_results"]
assert [item["scenario_id"] for item in state["card_results"]] == [
"fire-apartment-l2", "t01-1-fire-container"
]
assert {item["key"] for item in state["metrics"]} >= {
"address", "incident_signs", "dds_primary"
}
assert state["score_auto"] < 100
finally:
control.__exit__(None, None, None)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from app.config import Settings
def test_database_pool_defaults_fit_two_backend_node_budget():
settings = Settings(_env_file=None)
assert settings.db_pool_size == 20
assert settings.db_pool_max_overflow == 10
# Two backend nodes use at most 60 application connections, leaving room
# under PostgreSQL's common 100-connection default for admin/backup work.
assert 2 * (settings.db_pool_size + settings.db_pool_max_overflow) == 60
@pytest.mark.parametrize("values", [{"db_pool_size": 0}, {"db_pool_max_overflow": -1}])
def test_database_pool_rejects_invalid_limits(values):
with pytest.raises(ValidationError):
Settings(_env_file=None, **values)
def test_engine_uses_configured_pool_limits(monkeypatch):
from app.db import base
observed = {}
sentinel = object()
def capture(url, **options):
observed["url"] = url
observed.update(options)
return sentinel
settings = SimpleNamespace(
database_url="postgresql+asyncpg://lct:test@localhost/lct",
db_pool_size=12,
db_pool_max_overflow=7,
)
monkeypatch.setattr(base, "get_settings", lambda: settings)
monkeypatch.setattr(base, "create_async_engine", capture)
base.reset()
try:
assert base.get_engine() is sentinel
finally:
base.reset()
assert observed == {
"url": settings.database_url,
"pool_pre_ping": True,
"pool_size": 12,
"max_overflow": 7,
"hide_parameters": True,
}

View file

@ -1,5 +1,6 @@
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
import asyncio
import time
from datetime import datetime
from uuid import uuid4
@ -7,17 +8,48 @@ from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.api.ws.control import _start
from app.config import get_settings
from app.db.base import get_session
from app.domain.events import Exercise, ScenarioStart, SessionMode
from app.domain.timers import TimerCode
from app.main import app
from app.scenarios import store
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.journal = None
yield test_client
def client(monkeypatch):
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
async def session_override():
# These tests exercise live in-memory sessions; no endpoint below needs
# persistence, but the report route still requires its DB dependency.
yield object()
async def audit_override(*_args, **_kwargs):
return None
monkeypatch.setitem(app.dependency_overrides, get_session, session_override)
async def optional_session_override():
yield None
monkeypatch.setitem(
app.dependency_overrides,
sessions_http.optional_session,
optional_session_override,
)
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
monkeypatch.setattr("app.api.ws.control.audit", audit_override)
try:
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.journal = None
yield test_client
finally:
get_settings.cache_clear()
def wait_for(predicate, timeout=3):
@ -31,23 +63,39 @@ def wait_for(predicate, timeout=3):
def read_until(socket, wanted):
received = []
for _ in range(20):
event = socket.receive_json()
received.append(event["type"])
if event["type"] == wanted:
return event
raise AssertionError(f"событие {wanted} не пришло")
raise AssertionError(f"событие {wanted} не пришло; получены: {received}")
def start(client, exercise="dds", criteria=None, dds_service=None, scenario_id="fire-apartment-l2"):
def start(
client,
exercise="dds",
criteria=None,
dds_service=None,
scenario_id="fire-apartment-l2",
random_scenario_ids=None,
):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
payload = {"type": "scenario.start", "scenario_id": scenario_id,
"trainee": "Иванов", "mode": "training", "exercise": exercise}
payload = {
"type": "scenario.start",
"scenario_id": scenario_id,
"trainee": "Иванов",
"mode": "training",
"exercise": exercise,
}
if criteria is not None:
payload["criteria"] = criteria
if dds_service is not None:
payload["dds_service"] = dds_service
if random_scenario_ids is not None:
payload["random_scenario_ids"] = random_scenario_ids
control.send_json(payload)
wait_for(lambda: hub.get(session_id))
return session_id, context
@ -59,11 +107,18 @@ def complete_phone_call(station, state, expected_phase):
assert greeting["speaker"] == "crew"
read_until(station, "station.state")
if expected_phase == "dispatched":
station.send_json({"type": "phone.brief", "address": state.dispatched_card.address,
"incident": state.scenario_title,
"request": "Прошу подтвердить выезд и сообщить о прибытии"})
station.send_json(
{
"type": "phone.brief",
"address": state.dispatched_card.address,
"incident": state.scenario_title,
"request": "Прошу подтвердить выезд и сообщить о прибытии",
}
)
else:
station.send_json({"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"})
station.send_json(
{"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"}
)
assert read_until(station, "phone.line")["speaker"] == "dispatcher"
assert read_until(station, "phone.line")["speaker"] == "crew"
report = read_until(station, "phone.report")
@ -93,6 +148,183 @@ def test_dds_starts_with_prepared_card_without_call(client):
control.__exit__(None, None, None)
def test_repeated_ack_and_crew_selection_do_not_duplicate_dds_log(client):
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
crew = snapshot["crew_options"][0]
station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."})
read_until(station, "station.state")
station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."})
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
assert sum(action == "card.ack" for action, *_ in state.dds_log) == 1
station.send_json({"type": "crew.select", "crew": crew})
station.send_json({"type": "zone.decision", "in_zone": True})
read_until(station, "station.state")
assert sum(action == "crew.select" for action, *_ in state.dds_log) == 1
finally:
control.__exit__(None, None, None)
def test_zone_decision_is_one_shot_and_restored_in_station_snapshot(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
initial = read_until(station, "station.state")["snapshot"]
assert initial["zone_decision"] is None
station.send_json({"type": "zone.decision", "in_zone": True})
accepted = read_until(station, "station.state")["snapshot"]
assert accepted["zone_decision"] is True
state = hub.get(session_id)
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
station.send_json({"type": "zone.decision", "in_zone": True})
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
station.send_json({"type": "zone.decision", "in_zone": False})
error = read_until(station, "error")
assert "уже записано" in error["message"]
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
station.close()
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
restored = read_until(station, "station.state")["snapshot"]
assert restored["zone_decision"] is True
finally:
control.__exit__(None, None, None)
def test_server_randomly_selects_only_from_instructor_filtered_card_pool(
client, monkeypatch
):
pool = ["fire-apartment-l2", "t01-1-fire-container"]
monkeypatch.setattr(
"app.api.ws.control.secrets.choice", lambda scenarios: scenarios[-1]
)
session_id, control = start(client, random_scenario_ids=pool)
try:
state = hub.get(session_id)
assert state.scenario_id == "t01-1-fire-container"
assert state.dispatched_card is not None
assert [item.id for item in state.dds_scenarios] == pool[::-1]
assert state.dds_next_scenario_index == 2
assert state.dds_next_arrival_at is None
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
@pytest.mark.parametrize("scenario_id", [
"t01-3-child-other-region",
"t02-2-megafon-consultation",
"t07-2-headache-ryazan",
"t11-3-lost-in-forest",
"t12-2-heart-pain",
"t16-2-child-bicycle-volzhsky",
"t19-1-field-fire",
"t19-2-snake-bite",
"t27-3-wall-crack",
"t29-2-accident-fight",
])
def test_dds_rejects_non_card_outcomes_instead_of_making_fake_cards(
client, monkeypatch, scenario_id
):
scenario = store.get(scenario_id)
assert scenario is not None and scenario.outcome.value in {"consultation", "transfer_region"}
emitted = []
monkeypatch.setattr(
hub, "to_observers", lambda session_id, event: emitted.append(event)
)
session_id = uuid4()
event = ScenarioStart(
scenario_id=scenario.id,
scenario_ids=[scenario.id],
trainee="Иванов",
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
)
asyncio.run(_start(session_id, event))
assert hub.get(session_id) is None
assert emitted[-1].code.value == "scenario_invalid"
assert "готовые карточки" in emitted[-1].message
def test_instructor_live_registry_shows_owned_dds_session_and_deadline_state(client):
session_id, control = start(client)
try:
response = client.get("/api/sessions/active")
assert response.status_code == 200
rows = response.json()
row = next(item for item in rows if item["session_id"] == str(session_id))
assert row["exercise"] == "dds"
assert row["scenario_title"]
assert row["dds_card_total"] == 1
assert row["dds_open_cards"] == 1
assert row["dds_overdue_cards"] == 0
assert row["dds_snapshot"]["queue_cards"][0]["active"] is True
assert row["dds_snapshot"]["queue_cards"][0]["title"]
assert row["dds_snapshot"]["queue_cards"][0]["service_status"] == "added"
assert row["dds_snapshot"]["phone_reports"] == []
state = hub.get(session_id)
live_card = state.dds_live_cards[0]
live_card.timers.on_event("dds.open")
live_card.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 181
overdue_response = client.get("/api/sessions/active")
overdue_row = next(
item
for item in overdue_response.json()
if item["session_id"] == str(session_id)
)
assert overdue_row["dds_work_overdue_cards"] == 1
assert row["dds_statuses"]
finally:
control.__exit__(None, None, None)
def test_instructor_live_registry_includes_current_crew_report(client, monkeypatch):
from app.api import auth
async def keep_test_auth_state_fresh():
auth.prime_generations({})
auth.prime_generations({})
monkeypatch.setattr(auth, "sync_generations", keep_test_auth_state_fresh)
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
crew = state.crew_options()[0]
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
service = state.crew_service(crew)
station.send_json({
"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.",
})
read_until(station, "station.state")
report = complete_phone_call(station, state, "dispatched")
rows = client.get("/api/sessions/active").json()
row = next(item for item in rows if item["session_id"] == str(session_id))
saved_report = row["dds_snapshot"]["phone_reports"][0]
assert saved_report["crew"] == report["crew"]
assert saved_report["phase"] == "dispatched"
assert saved_report["text"] == report["text"]
finally:
control.__exit__(None, None, None)
def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
session_id, control = start(client, scenario_id="t01-1-fire-container")
try:
@ -128,25 +360,47 @@ def test_dds_can_change_only_its_own_service_status(client):
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["services"] == ["МВД"]
assert "Служба 101" in snapshot["recipient_services"]
station.send_json({
"type": "card.status", "service": "Служба 101", "status": "accepted",
})
station.send_json(
{
"type": "card.status",
"service": "Служба 101",
"status": "accepted",
}
)
assert "только своей ДДС" in read_until(station, "error")["message"]
station.send_json({
"type": "card.status", "service": "МВД", "status": "accepted",
})
assert read_until(station, "station.state")["snapshot"]["statuses"]["МВД"] == "accepted"
station.send_json(
{
"type": "card.status",
"service": "МВД",
"status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка для нашей службы.",
}
)
assert (
read_until(station, "station.state")["snapshot"]["statuses"]["МВД"]
== "accepted"
)
finally:
control.__exit__(None, None, None)
def test_instructor_criteria_change_timer_and_success_threshold(client):
session_id, control = start(client, criteria={
"decision_time_limit_seconds": 45,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
})
def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypatch):
from app.api import auth
async def keep_test_auth_state_fresh():
auth.prime_generations({})
auth.prime_generations({})
monkeypatch.setattr(auth, "sync_generations", keep_test_auth_state_fresh)
session_id, control = start(
client,
criteria={
"decision_time_limit_seconds": 45,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
},
)
try:
state = hub.get(session_id)
assert state.criteria.decision_time_limit_seconds == 45
@ -155,11 +409,30 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
card = read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "card.reply", "card_id": card["card"]["card_id"],
"text": "Сообщение приняты, бригада направлено."})
station.send_json(
{
"type": "card.reply",
"card_id": card["card"]["card_id"],
"text": "Сообщение приняты, бригада направлено.",
}
)
read_until(station, "station.state")
# Simulate the acknowledgement being lost during reconnect. The
# buffered replacement may be replayed, but its journal is unique.
station.send_json(
{
"type": "card.reply",
"card_id": card["card"]["card_id"],
"text": "Сообщение приняты, бригада направлено.",
}
)
read_until(station, "station.state")
assert len(state.reply_log) == 1
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
@ -167,10 +440,20 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
ack = next(item for item in score["metrics"] if item["key"] == "dds_ack")
assert ack["norm"] == "≤ 45 с"
assert ack["weight"] == 3.5
assert not any(item["key"] in {"dds_reply", "dds_grammar"} for item in score["metrics"])
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
assert reply_metric["passed"]
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
assert (
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
"passed"
]
is False
)
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"] == {
"decision_time_limit_seconds": 45,
"card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@ -180,6 +463,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
finally:
control.__exit__(None, None, None)
def test_dds_statuses_do_not_require_phone_reports(client):
session_id, control = start(client)
try:
@ -187,19 +471,100 @@ def test_dds_statuses_do_not_require_phone_reports(client):
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
station.send_json(
{"type": "card.status", "service": service, "status": "responding",
"comment": "Основание: доклад старшего.\nСведения: начало реагирования подтверждено."}
)
assert (
read_until(station, "station.state")["snapshot"]["statuses"][service]
== "responding"
)
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
keys = {metric["key"] for metric in score["metrics"]}
assert "dds_primary" in keys and "dds_progress" in keys
assert "dds_contact" not in keys and "dds_crew" not in keys
crew_metric = next(
metric for metric in score["metrics"] if metric["key"] == "dds_crew"
)
assert not crew_metric["passed"]
assert "dds_contact" not in keys
assert "answer_time" not in keys and "interview_time" not in keys
assert 0 < score["score_auto"] < 100
assert all(not finding["code"].startswith("E") for finding in score["findings"])
assert any(
finding["code"] == "D2" and "бригады" in finding["summary"]
for finding in score["findings"]
)
assert any(
finding["code"] == "E3" and "времени отработки" in finding["summary"]
for finding in score["findings"]
)
finding_codes = {finding["code"] for finding in score["findings"]}
penalty_codes = {
"dds_primary": {"D1"},
"dds_ack": {"D1"},
"dds_decision": {"D2", "D3"},
"dds_crew": {"D2"},
"dds_progress": {"D6"},
"dds_completion": {"D6"},
"dds_reply": {"D5"},
"dds_work_time": {"E3"},
}
unexplained = [
metric["key"]
for metric in score["metrics"]
if not metric["passed"]
and not penalty_codes.get(metric["key"], set()).intersection(finding_codes)
]
assert not unexplained, f"проваленные метрики без кода и пояснения: {unexplained}"
finally:
control.__exit__(None, None, None)
def test_dds_d5_comment_is_explanatory_and_does_not_change_numeric_score(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")["card"]
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
for status in ("responding", "arrived", "working", "completed"):
station.send_json(
{"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад ответственной службы.\nСведения: этап {status}."}
)
read_until(station, "station.state")
station.send_json(
{
"type": "card.reply",
"card_id": card["card_id"],
"text": "Все принято.",
}
)
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert score["score_auto"] == 100.0
d5 = next(finding for finding in score["findings"] if finding["code"] == "D5")
assert "получателя" in d5["summary"]
finally:
control.__exit__(None, None, None)
@ -211,8 +576,15 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
@ -221,18 +593,37 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
pending = read_until(station, "station.state")["snapshot"]
assert pending["phone_pending"]["phase"] == "dispatched"
assert not pending["phone_reports"]
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
station.send_json({"type": "phone.brief", "address": "другая улица, дом 99",
"incident": "Пожар в квартире",
"request": "Прошу направить бригаду"})
station.send_json(
{"type": "card.status", "service": service, "status": "responding",
"comment": "Основание: доклад старшего.\nСведения: начало реагирования подтверждено."}
)
assert (
read_until(station, "station.state")["snapshot"]["statuses"][service]
== "responding"
)
station.send_json(
{
"type": "phone.brief",
"address": "другая улица, дом 99",
"incident": "Пожар в квартире",
"request": "Прошу направить бригаду",
}
)
assert "адрес" in read_until(station, "error")["message"]
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
"incident": "Ничего не произошло",
"request": "Прошу направить бригаду"})
station.send_json(
{
"type": "phone.brief",
"address": "улица Ленина, 14",
"incident": "Ничего не произошло",
"request": "Прошу направить бригаду",
}
)
assert "характер" in read_until(station, "error")["message"]
station.send_json({"type": "phone.hangup"})
assert read_until(station, "station.state")["snapshot"]["phone_pending"] is None
assert (
read_until(station, "station.state")["snapshot"]["phone_pending"]
is None
)
assert not hub.get(session_id).phone_reports
complete_phone_call(station, hub.get(session_id), "dispatched")
read_until(station, "station.state")
@ -240,7 +631,9 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
station.send_json({"type": "phone.dial"})
read_until(station, "phone.line")
read_until(station, "station.state")
station.send_json({"type": "phone.check", "text": "Здравствуйте, хорошая погода"})
station.send_json(
{"type": "phone.check", "text": "Здравствуйте, хорошая погода"}
)
assert "обстановку" in read_until(station, "error")["message"]
assert len(hub.get(session_id).phone_reports) == 1
finally:
@ -250,24 +643,43 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
def test_default_exercise_remains_call(client):
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training"})
control.send_json(
{
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"trainee": "Иванов",
"mode": "training",
}
)
state = wait_for(lambda: hub.get(session_id))
assert state.exercise.value == "call"
assert state.dispatched_card is None
def test_complete_dds_workflow_scores_100_without_any_call(client):
def test_complete_dds_workflow_scores_100_with_assignment_without_call(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
for service in snapshot["services"]:
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
read_until(station, "station.state")
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json({"type": "crew.select", "crew": crew})
snapshot = read_until(station, "station.state")["snapshot"]
for status in ("responding", "arrived", "working", "completed"):
station.send_json({"type": "card.status", "service": service, "status": status})
station.send_json(
{"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
)
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card"] == "completed"
station.send_json({"type": "station.finish"})
@ -276,8 +688,115 @@ def test_complete_dds_workflow_scores_100_without_any_call(client):
assert score["score_auto"] == 100.0
assert not score["findings"]
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
assert not any(metric["key"] in {"dds_contact", "dds_crew", "dds_reply", "dds_grammar"}
for metric in score["metrics"])
crew_metric = next(
metric for metric in score["metrics"] if metric["key"] == "dds_crew"
)
assert crew_metric["passed"]
assert next(
metric for metric in score["metrics"] if metric["key"] == "dds_work_time"
)["passed"]
assert not any(
metric["key"] in {"dds_contact", "dds_grammar"}
for metric in score["metrics"]
)
finally:
control.__exit__(None, None, None)
def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client):
session_id, control = start(
client, criteria={"dds_card_work_time_limit_seconds": 60}
)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
snapshot = read_until(station, "station.state")["snapshot"]
state.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
for status in ("responding", "arrived", "working", "completed"):
station.send_json(
{"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
)
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: state.score)
metric = next(
item for item in score["metrics"] if item["key"] == "dds_work_time"
)
assert not metric["passed"]
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
finding = next(item for item in score["findings"] if item["code"] == "E3")
assert "времени отработки карточки" in finding["summary"]
finally:
control.__exit__(None, None, None)
def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
for status, phase in (
("responding", "arrived"),
("arrived", "working"),
("working", "completed"),
("completed", None),
):
station.send_json(
{"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
)
read_until(station, "station.state")
if phase:
complete_phone_call(station, state, phase)
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: state.score)
assert score["score_auto"] == 100.0
assert [report.phase for report in state.phone_reports] == [
"dispatched",
"arrived",
"working",
"completed",
]
assert all(report.crew == crew for report in state.phone_reports)
assert not score["findings"]
finally:
control.__exit__(None, None, None)
@ -286,12 +805,17 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов", "mode": "training", "exercise": "dds",
})
wait_for(lambda: hub.get(session_id))
control.send_json(
{
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов",
"mode": "training",
"exercise": "dds",
}
)
state = wait_for(lambda: hub.get(session_id))
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
first = read_until(station, "card.received")
@ -302,16 +826,30 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
first_row, second_row = snapshot["queue_cards"]
second_card_id = second_row["card_id"]
assert first_row["active"] is True and second_row["active"] is False
assert abs(
datetime.fromisoformat(first_row["received_at"]).timestamp()
- datetime.fromisoformat(second_row["received_at"]).timestamp()
) < 1
first_live = next(
item
for item in state.dds_live_cards
if str(item.card_id) == first_card_id
)
assert TimerCode.DDS_WORK not in first_live.timers.timers
assert (
abs(
datetime.fromisoformat(first_row["received_at"]).timestamp()
- datetime.fromisoformat(second_row["received_at"]).timestamp()
)
< 1
)
first_service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": first_service,
"status": "accepted"})
station.send_json(
{"type": "card.status", "service": first_service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
snapshot = read_until(station, "station.state")["snapshot"]
first_elapsed = next(item for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id)["elapsed_ms"]
first_elapsed = next(
item
for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id
)["elapsed_ms"]
time.sleep(0.03)
# Card switching must publish its own fresh station snapshot; do
@ -320,28 +858,48 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
station.send_json({"type": "card.open", "card_id": second_card_id})
second = read_until(station, "card.received")
assert second["card"]["card_id"] == second_card_id
second_live = next(
item
for item in state.dds_live_cards
if str(item.card_id) == second_card_id
)
assert TimerCode.DDS_WORK in second_live.timers.timers
assert second_live.timers.timers[TimerCode.DDS_WORK].started_at is not None
assert second["card"]["incident_type"] == "medical"
assert (second["card_index"], second["card_total"]) == (2, 2)
snapshot = read_until(station, "station.state")["snapshot"]
second_queue_row = next(item for item in snapshot["queue_cards"]
if item["card_id"] == second_card_id)
second_queue_row = next(
item
for item in snapshot["queue_cards"]
if item["card_id"] == second_card_id
)
assert second_queue_row["active"] is True
assert second_queue_row["elapsed_ms"] >= first_elapsed
first_queue_row = next(item for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id)
first_queue_row = next(
item
for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id
)
assert first_queue_row["service_status"] == "accepted"
assert first_queue_row["timer_stopped"] is True
second_service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": second_service,
"status": "accepted"})
station.send_json(
{"type": "card.status", "service": second_service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "card.open", "card_id": first_card_id})
assert read_until(station, "card.received")["card"]["card_id"] == first_card_id
assert (
read_until(station, "card.received")["card"]["card_id"] == first_card_id
)
restored = read_until(station, "station.state")["snapshot"]
assert restored["statuses"][first_service] == "accepted"
station.send_json({"type": "card.next", "card_id": first_card_id})
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
assert (
read_until(station, "card.received")["card"]["card_id"]
== second_card_id
)
after_close = read_until(station, "station.state")["snapshot"]
assert len(after_close["queue_cards"]) == 1
assert after_close["statuses"][second_service] == "accepted"
@ -354,9 +912,11 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
assert "dds_primary" in {item["key"] for item in state.score["metrics"]}
assert not any(item["key"] in {"dds_reply", "dds_grammar"}
for item in state.score["metrics"])
assert all(item["code"].startswith("D") for item in state.score["findings"])
assert not any(
item["key"] == "dds_grammar"
for item in state.score["metrics"]
)
assert any(item["code"] == "E3" for item in state.score["findings"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["scenario_id"] == "fire-apartment-l2"
assert len(report["card_results"]) == 2
@ -372,11 +932,16 @@ def test_instructor_end_grades_all_concurrently_issued_cards(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов", "mode": "training", "exercise": "dds",
})
control.send_json(
{
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов",
"mode": "training",
"exercise": "dds",
}
)
wait_for(lambda: hub.get(session_id))
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -388,7 +953,8 @@ def test_instructor_end_grades_all_concurrently_issued_cards(client):
assert state.ended
assert len(state.score["card_results"]) == 2
assert [item["scenario_id"] for item in state.score["card_results"]] == [
"fire-apartment-l2", "t20-2-stroke",
"fire-apartment-l2",
"t20-2-stroke",
]
finally:
control_ctx.__exit__(None, None, None)
@ -410,8 +976,12 @@ def test_each_dds_card_uses_its_own_scenario_weights():
first.score_weights = {"dds_primary": 7.0}
second.score_weights = {"dds_primary": 2.0}
state = SessionState(
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
session_id=uuid4(),
scenario_id=base.id,
scenario_title=base.title,
level=base.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=[first, second],
)
prepare_card(state, first)
@ -419,5 +989,11 @@ def test_each_dds_card_uses_its_own_scenario_weights():
state.dds_card_index = 1
prepare_card(state, second)
second_record = score_current_dds(state)
assert next(item.weight for item in first_record.metrics if item.key == "dds_primary") == 7.0
assert next(item.weight for item in second_record.metrics if item.key == "dds_primary") == 2.0
assert (
next(item.weight for item in first_record.metrics if item.key == "dds_primary")
== 7.0
)
assert (
next(item.weight for item in second_record.metrics if item.key == "dds_primary")
== 2.0
)

View file

@ -53,8 +53,9 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None,
"service": "Служба 101"}
]
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
# Пустая volatile-история доступна в демо без PostgreSQL.
assert client.get("/api/sessions").status_code == 200
assert client.get("/api/sessions").json() == []
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as control:
@ -65,6 +66,9 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
})
state = _wait_for(lambda: hub.get(session_id))
assert state.trainee_id == DEMO_TRAINEE_ID
listed = client.get("/api/sessions").json()
assert len(listed) == 1 and listed[0]["session_id"] == str(session_id)
assert listed[0]["scenario_id"] == "fire-apartment-l2"
who = client.post("/api/auth/login", json={
"login": "demo-trainee", "password": "demo"
}).json()

View file

@ -0,0 +1,397 @@
from __future__ import annotations
from types import SimpleNamespace
from uuid import UUID
import pytest
from app.config import Settings
from app.db.models import AuditLog, Trainee, User
from app.directory import (
DirectoryDenied,
DirectoryUnavailable,
_authenticate_sync,
map_groups,
)
from app.domain.roles import Role
def test_directory_role_and_service_mappings_are_explicit_and_unambiguous():
roles = {"CN=LCT Trainees,DC=training,DC=lan": "trainee"}
services = {"CN=DDS 01,DC=training,DC=lan": "01"}
assert map_groups(
["cn=dds 01,dc=training,dc=lan", "cn=lct trainees,dc=training,dc=lan"],
roles,
services,
) == (Role.TRAINEE, "01")
with pytest.raises(DirectoryDenied):
map_groups([], roles, services)
with pytest.raises(DirectoryDenied):
map_groups(
[
"CN=LCT Trainees,DC=training,DC=lan",
"CN=Other Trainees,DC=training,DC=lan",
],
{
"CN=LCT Trainees,DC=training,DC=lan": "trainee",
"CN=Other Trainees,DC=training,DC=lan": "instructor",
},
{},
)
with pytest.raises(DirectoryDenied):
map_groups(
[
"CN=LCT Trainees,DC=training,DC=lan",
"CN=DDS 01,DC=training,DC=lan",
"CN=DDS 02,DC=training,DC=lan",
],
roles,
{
"CN=DDS 01,DC=training,DC=lan": "01",
"CN=DDS 02,DC=training,DC=lan": "02",
},
)
def test_directory_role_mapping_rejects_unknown_privilege_names():
with pytest.raises(DirectoryUnavailable, match="invalid application role"):
map_groups(
["CN=LCT Admins,DC=training,DC=lan"],
{"CN=LCT Admins,DC=training,DC=lan": "superuser"},
{},
)
@pytest.mark.parametrize(
"url, expected_tls",
[
("ldaps://dc.training.lan:636", "ldaps"),
("ldap://dc.training.lan:389", "starttls"),
],
)
def test_directory_search_then_user_bind_uses_tls_and_escapes_login(
monkeypatch, url, expected_tls
):
import ldap3
calls = []
class Attribute:
def __init__(self, value=None, values=None):
self.value = value
self.values = values or []
entry = SimpleNamespace(
entry_dn="CN=Training User,OU=People,DC=training,DC=lan",
sAMAccountName=Attribute("Training.User"),
displayName=Attribute("Учебный пользователь"),
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
objectGUID=Attribute(bytes(range(16))),
entryUUID=Attribute(None),
)
class FakeServer:
def __init__(self, host, **kwargs):
calls.append(("server", host, kwargs))
class FakeConnection:
def __init__(self, server, **kwargs):
calls.append(("connection", kwargs))
self.entries = [entry]
self.bound = False
self.result = {"result": 0}
def open(self):
calls.append(("open",))
return True
def start_tls(self):
calls.append(("start_tls",))
return True
def bind(self):
calls.append(("service_bind",))
self.bound = True
return True
def search(self, **kwargs):
calls.append(("search", kwargs))
return True
def rebind(self, user, password):
calls.append(("user_bind", user, password))
self.bound = password == "correct-password"
self.result = {"result": 0 if self.bound else 49}
return self.bound
def unbind(self):
calls.append(("unbind",))
monkeypatch.setattr(ldap3, "Server", FakeServer)
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
monkeypatch.setattr(
ldap3, "Tls", lambda **kwargs: calls.append(("tls", kwargs)) or object()
)
settings = Settings(
ldap_enabled=True,
ldap_url=url,
ldap_base_dn="DC=training,DC=lan",
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
ldap_bind_password="service-secret",
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
)
result = _authenticate_sync("Training.*(User", "correct-password", settings)
assert result.login == "training.user"
assert result.role is Role.TRAINEE
assert result.subject == "03020100-0504-0706-0809-0a0b0c0d0e0f"
search_call = next(call for call in calls if call[0] == "search")
assert r"Training.\2a\28User" in search_call[1]["search_filter"]
user_bind = next(call for call in calls if call[0] == "user_bind")
assert user_bind[1] == entry.entry_dn
if expected_tls == "starttls":
assert calls.index(("start_tls",)) < calls.index(("service_bind",))
else:
server_call = next(call for call in calls if call[0] == "server")
assert server_call[2]["use_ssl"] is True
assert not any(call[0] == "start_tls" for call in calls)
def test_invalid_directory_password_is_denied(monkeypatch):
import ldap3
class Attribute:
def __init__(self, value=None, values=None):
self.value = value
self.values = values or []
entry = SimpleNamespace(
entry_dn="CN=Training User,DC=training,DC=lan",
sAMAccountName=Attribute("trainee"),
displayName=Attribute("Trainee"),
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
objectGUID=Attribute("stable-guid"),
entryUUID=Attribute(None),
)
class FakeConnection:
def __init__(self, *args, **kwargs):
self.entries = [entry]
self.bound = False
self.result = {"result": 0}
def open(self):
return True
def bind(self):
self.bound = True
return True
def search(self, **kwargs):
return True
def rebind(self, user, password):
self.bound = False
self.result = {"result": 49}
return False
def unbind(self):
pass
monkeypatch.setattr(ldap3, "Server", lambda *args, **kwargs: object())
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
monkeypatch.setattr(ldap3, "Tls", lambda **kwargs: object())
settings = Settings(
ldap_enabled=True,
ldap_url="ldaps://dc.training.lan",
ldap_base_dn="DC=training,DC=lan",
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
ldap_bind_password="service-secret",
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
)
with pytest.raises(DirectoryDenied, match="invalid directory credentials"):
_authenticate_sync("trainee", "wrong-password", settings)
def test_directory_account_without_stable_identifier_is_rejected(monkeypatch):
import ldap3
class Attribute:
def __init__(self, value=None, values=None):
self.value = value
self.values = values or []
entry = SimpleNamespace(
entry_dn="CN=Training User,DC=training,DC=lan",
sAMAccountName=Attribute("trainee"),
displayName=Attribute("Trainee"),
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
objectGUID=Attribute(None),
entryUUID=Attribute(None),
)
class FakeConnection:
def __init__(self, *args, **kwargs):
self.entries = [entry]
def open(self):
return True
def bind(self):
return True
def search(self, **kwargs):
return True
def unbind(self):
pass
monkeypatch.setattr(ldap3, "Server", lambda *args, **kwargs: object())
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
monkeypatch.setattr(ldap3, "Tls", lambda **kwargs: object())
settings = Settings(
ldap_enabled=True,
ldap_url="ldaps://dc.training.lan",
ldap_base_dn="DC=training,DC=lan",
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
ldap_bind_password="service-secret",
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
)
with pytest.raises(DirectoryUnavailable, match="objectGUID or entryUUID"):
_authenticate_sync("trainee", "correct-password", settings)
@pytest.mark.asyncio
async def test_directory_account_is_jit_provisioned_and_role_sync_revokes_sessions(
monkeypatch,
):
from app.api import auth
class FakeDb:
user = None
trainee = None
audits = []
async def scalar(self, _query):
return self.user
def add(self, row):
if isinstance(row, Trainee):
row.id = UUID("00000000-0000-4000-8000-000000000321")
self.trainee = row
elif isinstance(row, User):
self.user = row
elif isinstance(row, AuditLog):
self.audits.append(row)
async def get(self, model, _key):
return self.trainee if model is Trainee else None
async def flush(self):
pass
async def commit(self):
pass
async def rollback(self):
pass
async def refresh(self, _row):
pass
class Context:
def __init__(self, db):
self.db = db
async def __aenter__(self):
return self.db
async def __aexit__(self, *_args):
return None
fake_db = FakeDb()
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: Context(fake_db))
identity = SimpleNamespace(
login="trainee.one",
full_name="Курсант Один",
role=Role.TRAINEE,
service="01",
subject="stable-object-guid",
)
user = await auth._directory_account(identity)
assert user.auth_provider == "ldap"
assert user.directory_subject == "stable-object-guid"
assert user.role == "trainee"
assert user.service == "01"
assert user.trainee_id == UUID("00000000-0000-4000-8000-000000000321")
assert user.password_hash != "correct-password"
assert [row.action for row in fake_db.audits] == ["user.provision.ldap"]
auth._generations[user.login] = user.auth_version
identity = SimpleNamespace(
**{**vars(identity), "full_name": "Курсант Одинов", "service": "02"}
)
updated = await auth._directory_account(identity)
assert updated.auth_version == 1
assert updated.full_name == "Курсант Одинов"
assert updated.service == "02"
assert [row.action for row in fake_db.audits] == [
"user.provision.ldap", "user.sync.ldap",
]
assert auth._generations[user.login] == 0 # persistent value is loaded at login
@pytest.mark.asyncio
async def test_blocked_directory_account_is_returned_without_directory_sync(monkeypatch):
from app.api import auth
user = User(
id=UUID("00000000-0000-4000-8000-000000000987"),
login="trainee.one",
full_name="Старое имя",
role="trainee",
service="01",
trainee_id=None,
blocked=True,
password_hash="unused",
auth_provider="ldap",
directory_subject="stable-object-guid",
auth_version=4,
)
class FakeDb:
commits = 0
async def scalar(self, _query):
return user
async def commit(self):
self.commits += 1
class Context:
def __init__(self, db):
self.db = db
async def __aenter__(self):
return self.db
async def __aexit__(self, *_args):
return None
fake_db = FakeDb()
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: Context(fake_db))
identity = SimpleNamespace(
login="trainee.one",
full_name="Новое имя из каталога",
role=Role.ADMIN,
service=None,
subject="stable-object-guid",
)
returned = await auth._directory_account(identity)
assert returned is user
assert user.full_name == "Старое имя"
assert user.role == "trainee"
assert user.auth_version == 4
assert fake_db.commits == 0

View file

@ -0,0 +1,282 @@
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
def test_late_primary_status_has_d1_finding_even_when_status_exists():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=31_000,
)
d1 = next(finding for finding in findings if finding.code is ErrorCode.D1)
assert "31 с" in d1.fact
def test_primary_status_within_deadline_does_not_have_d1_finding():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=30_000,
)
assert ErrorCode.D1 not in [finding.code for finding in findings]
def test_d5_flags_comment_without_recipient_but_not_a_named_crew():
at = datetime.now(timezone.utc)
base = StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=at,
comment="Основание: не обслуживаем.\nСведения: информация передана.",
)
findings = evaluate_dispatcher(
entries=[base],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "получателя" in d5.summary
assert "передана" in d5.fact
complete = evaluate_dispatcher(
entries=[base.model_copy(update={"comment": "Основание: принято.\nСведения: карточка передана бригаде 12."})],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
assert ErrorCode.D5 not in [finding.code for finding in complete]
def test_recipient_in_one_status_comment_does_not_mask_another_status_comment():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="Основание: карточка принята.\nСведения: переданы бригаде 12.",
),
StatusEntry(
service="Служба 101", status=ServiceStatus.RESPONDING,
at=at, comment="Основание: доклад.\nСведения: подтверждено начало движения.",
),
]
findings = evaluate_dispatcher(
entries=entries,
services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 12"},
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "Начало реагирования" in d5.fact
assert "Бригаде 12 переданы сведения" not in d5.fact
def test_missing_manual_status_comment_has_a_d5_finding():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "не заполнены комментарии" in d5.fact
def test_each_manual_status_comment_is_part_of_the_numeric_reply_metric():
at = datetime.now(timezone.utc)
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"],
status_log=[
StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="Основание: карточка принята.\nСведения: принято в работу.",
),
StatusEntry(
service="Служба 101", status=ServiceStatus.RESPONDING,
at=at, comment="",
),
],
crew_assignments={"Служба 101": "Бригада 1"},
dispatched_at=at,
)
reply = next(
metric for metric in dispatcher_metrics(state, 30_000)
if metric.key == "dds_reply"
)
assert not reply.passed
assert "к каждой ручной отметке" in reply.norm
findings = evaluate_dispatcher(
entries=state.status_log,
services=["Служба 101"],
crew_assignments=state.crew_assignments,
deadline_ms=30_000,
elapsed_ms=0,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "Начало реагирования" in d5.fact
def test_d5_does_not_treat_address_house_number_as_recipient():
entry = StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
comment="Основание: доклад.\nСведения: в доме 101 проведён осмотр.",
)
findings = evaluate_dispatcher(
entries=[entry],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
assert ErrorCode.D5 in [finding.code for finding in findings]
def test_scenario_may_define_a_valid_decline_for_duplicate_or_territory():
at = datetime.now(timezone.utc)
entries = [StatusEntry(
service="Служба 101", status=ServiceStatus.DECLINED, at=at,
comment="Основание: дубль.\nСведения: передано в дежурную часть.",
)]
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"], deadline_ms=30_000,
elapsed_ms=1_000, expected_decision="decline",
expected_decision_reason="дублирующая карточка",
)
assert ErrorCode.D3 not in [finding.code for finding in findings]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={}, dispatched_at=at,
)
metrics = dispatcher_metrics(
state, 30_000, expected_decision="decline",
expected_decision_reason="дублирующая карточка",
)
decision = next(metric for metric in metrics if metric.key == "dds_decision")
assert decision.passed
assert "дублирующая карточка" in decision.norm
def test_nonempty_unstructured_comment_fails_reply_metric_but_structured_passes():
at = datetime.now(timezone.utc)
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"],
status_log=[StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="бригада на связи",
)],
crew_assignments={"Служба 101": "Бригада 12"},
dispatched_at=at,
)
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
assert not reply.passed
findings = evaluate_dispatcher(
entries=state.status_log, services=["Служба 101"],
crew_assignments=state.crew_assignments,
deadline_ms=30_000, elapsed_ms=0,
)
assert any("не разделяют основание и сведения" in f.summary for f in findings)
state.status_log[0] = state.status_log[0].model_copy(update={
"comment": "Основание: доклад старшего.\nСведения: бригада на связи.",
})
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
assert reply.passed
def test_accepting_card_with_scenario_expected_decline_is_explained():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"], deadline_ms=30_000, elapsed_ms=1_000,
expected_decision="decline", expected_decision_reason="не наша территория",
)
decision_finding = next(
finding for finding in findings
if finding.code is ErrorCode.D2 and "вопреки эталону" in finding.summary
)
assert decision_finding.norm == "не наша территория"
def test_scenario_decline_expectation_requires_an_explicit_reason():
from app.scenarios.schema import DdsDecision
with pytest.raises(ValidationError, match="reason обязателен"):
DdsDecision(expected="decline")
assert DdsDecision(expected="decline", reason="дубль").expected == "decline"
def test_incomplete_work_path_has_d6_for_failed_progress_metrics():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
StatusEntry(service="Служба 101", status=ServiceStatus.RESPONDING, at=at),
]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
)
metrics = dispatcher_metrics(state, 30_000)
assert not next(item for item in metrics if item.key == "dds_progress").passed
assert not next(item for item in metrics if item.key == "dds_completion").passed
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 1"},
deadline_ms=30_000, elapsed_ms=0,
)
d6 = next(finding for finding in findings if finding.code is ErrorCode.D6)
assert "Прибытие" in d6.fact and "Проведение работ" in d6.fact
def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
StatusEntry(
service="Служба 101", status=ServiceStatus.REFUSED, at=at,
comment="Бригаде переданы сведения, выезд не выполнялся по причине угрозы.",
),
]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
)
metrics = dispatcher_metrics(state, 30_000)
assert next(item for item in metrics if item.key == "dds_progress").passed
assert next(item for item in metrics if item.key == "dds_completion").passed
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 1"},
deadline_ms=30_000, elapsed_ms=0,
)
assert ErrorCode.D6 not in [finding.code for finding in findings]

View file

@ -1,6 +1,6 @@
"""Тесты контракта. Домен — общий шов, ломать его молча нельзя."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from uuid import uuid4
import pytest
@ -27,6 +27,16 @@ def test_interview_normative_is_75_seconds():
assert NORMATIVES[TimerCode.INTERVIEW].limit_ms == 75_000
def test_card_fill_normative_defaults_to_three_minutes_and_is_configurable():
from app.domain.events import LessonCriteria
assert NORMATIVES[TimerCode.CARD_FILL].limit_ms == 180_000
assert LessonCriteria().card_fill_time_limit_seconds == 180
assert LessonCriteria(card_fill_time_limit_seconds=240).card_fill_time_limit_seconds == 240
with pytest.raises(ValidationError):
LessonCriteria(card_fill_time_limit_seconds=20)
@pytest.mark.parametrize(
"elapsed_ms,expected",
[(0, TimerState.OK), (59_000, TimerState.OK), (70_000, TimerState.WARN), (94_000, TimerState.VIOLATED)],
@ -65,7 +75,7 @@ def test_server_events_round_trip_through_json():
"type": "caller.utterance",
"utterance_id": str(uuid4()),
"text": "Алло! Помогите! Горим!",
"at": datetime.now(timezone.utc).isoformat(),
"at": datetime.now(UTC).isoformat(),
"mood": "panic",
}
assert adapter.validate_python(payload).text.startswith("Алло")

View file

@ -4,9 +4,9 @@
производен и не выбирается руками, сценарий размечается признаками.
"""
import pytest
from pathlib import Path
import pytest
from app.domain import ekp
from app.domain.kio import KIO, derive_incident
from app.scenarios.loader import load_library
@ -19,43 +19,240 @@ def test_reference_loads_whole_book():
assert reference.version == "046.24"
assert len(reference.incidents) == 1283
assert len(reference.groups) == 23
assert all(incident.type for incident in reference.incidents), "код без итогового типа"
assert all(incident.type for incident in reference.incidents), (
"код без итогового типа"
)
def test_all_customer_ticket_cards_are_complete_and_classified():
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None]
cards = [
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None
]
assert len(cards) == 96
assert {scenario.ticket for scenario in cards} == set(range(1, 33))
for ticket in range(1, 33):
assert {scenario.position for scenario in cards if scenario.ticket == ticket} == {1, 2, 3}
assert all(scenario.facts and scenario.signs and scenario.ground_truth.address
and scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code)
for scenario in cards)
unknown_victim_counts = {scenario.id for scenario in cards
if scenario.ground_truth.victims is None}
assert unknown_victim_counts == {
"t02-2-megafon-consultation", "t02-3-car-in-water", "t03-2-loud-music",
"t04-1-balcony-fire", "t04-3-open-door", "t12-3-men-on-bridge-rail",
"t07-3-lost-elderly", "t08-1-mall-smoke", "t14-1-grass-fire-azs", "t17-1-fire-alarm",
"t12-1-restaurant-smoke", "t30-3-gas-smell-house", "t31-3-gas-pipe-whistle",
"t11-3-lost-in-forest", "t16-1-smoke-column",
"t18-1-unknown-fire", "t24-1-parking-quarrel", "t25-1-drunk-at-stop",
"t23-3-lost-child", "t27-1-suspicious-car", "t28-1-stranger-at-door", "t29-1-ticking-box",
"t29-3-threat-to-blow-up", "t30-1-car-theft-yesterday",
"t31-1-car-theft-witnessed", "t32-1-carjacking", "t32-3-street-lights",
assert {
scenario.position for scenario in cards if scenario.ticket == ticket
} == {1, 2, 3}
assert all(scenario.facts and scenario.ground_truth.address for scenario in cards)
unclassified_ids = {
"t02-3-car-in-water", # источник не уточняет, был ли человек в воде
"t03-2-loud-music", # время, нужное для признака тишины, не дано
"t05-3-worker-in-pit", # падение в котлован не означает обрушение/коммуникации
"t22-3-suicide-sms", # намерение в СМС не подтверждает попытку/приготовление
"t26-3-death-care-home", # точный код для смерти в центре не подтверждён
"t32-1-carjacking", # срок угона для кода не дан
"t32-3-street-lights", # время суток для признака не дано
}
assert {scenario.id for scenario in cards if not scenario.signs} == unclassified_ids
assert all(
scenario.ground_truth.incident_code is None and not scenario.ground_truth.notify
for scenario in cards
if scenario.id in unclassified_ids
)
assert all(
scenario.signs
and scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code)
for scenario in cards
if scenario.id not in unclassified_ids
)
unknown_victim_counts = {
scenario.id for scenario in cards if scenario.ground_truth.victims is None
}
assert unknown_victim_counts == {
"t02-2-megafon-consultation",
"t02-3-car-in-water",
"t03-2-loud-music",
"t04-1-balcony-fire",
"t04-3-open-door",
"t12-3-men-on-bridge-rail",
"t07-3-lost-elderly",
"t08-1-mall-smoke",
"t14-1-grass-fire-azs",
"t17-1-fire-alarm",
"t12-1-restaurant-smoke",
"t30-3-gas-smell-house",
"t31-3-gas-pipe-whistle",
"t11-3-lost-in-forest",
"t16-1-smoke-column",
"t18-1-unknown-fire",
"t24-1-parking-quarrel",
"t25-1-drunk-at-stop",
"t23-3-lost-child",
"t27-1-suspicious-car",
"t28-1-stranger-at-door",
"t29-1-ticking-box",
"t29-3-threat-to-blow-up",
"t30-1-car-theft-yesterday",
"t31-1-car-theft-witnessed",
"t32-1-carjacking",
"t32-3-street-lights",
}
def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data():
root = Path(__file__).resolve().parents[2] / "scenarios"
cards = {
scenario.id: scenario
for scenario in load_library(root)
if scenario.id
in {
"t02-1-smoke-chute",
"t02-2-megafon-consultation",
"t02-3-car-in-water",
}
}
assert len(cards) == 3
for scenario in cards.values():
assert scenario.ground_truth.address.startswith("Москва,")
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
assert address_fact.value.startswith("Москва,")
consultation = cards["t02-2-megafon-consultation"]
victim_fact = next(fact for fact in consultation.facts if fact.id == "f_victims")
assert victim_fact.value == "в исходном билете сведения о пострадавших не указаны"
assert consultation.ground_truth.victims is None
def test_ticket_14_refined_address_retains_azs_and_approach_landmarks():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t14-1-grass-fire-azs"
)
address = next(fact for fact in scenario.facts if fact.id == "f_address")
for landmark in ("АЗС", "Роснефть", "не доезжая до Расторгуевского шоссе"):
assert landmark in address.value
assert landmark in address.refined
assert landmark in scenario.ground_truth.address
assert "25 км по столбам в сторону Москвы" in address.refined
assert "владение 2" in scenario.ground_truth.address
def test_ticket_05_02_does_not_invent_callers_age():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t05-2-wrong-medicine"
)
who = next(fact for fact in scenario.facts if fact.id == "f_who")
assert who.value == "Иванова Ирина Петровна, дата рождения 10.03.1975"
assert "на вид" not in who.value
def test_ticket_25_01_refined_address_keeps_stop_and_side_landmarks():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t25-1-drunk-at-stop"
)
address = next(fact for fact in scenario.facts if fact.id == "f_address")
assert "62" in address.refined
assert "Штурвальная" in address.refined
assert "на стороне улицы Фабрициуса, дом 18" in address.refined
def test_ticket_address_code_is_not_guessed_to_be_an_intercom():
root = Path(__file__).resolve().parents[2] / "scenarios"
expected_codes = {
"t04-3-open-door": "45В",
"t05-2-wrong-medicine": "142",
"t17-1-fire-alarm": "2215",
"t18-2-husband-wont-wake": "5В",
"t22-1-flat-fight": "2В",
"t23-1-drunk-husband": "80В",
"t28-1-stranger-at-door": "100",
"t29-3-threat-to-blow-up": "67",
"t31-3-gas-pipe-whistle": "5В",
}
cards = {
scenario.id: scenario
for scenario in load_library(root)
if scenario.id in expected_codes
}
assert cards.keys() == expected_codes.keys()
for scenario_id, code in expected_codes.items():
address = cards[scenario_id].ground_truth.address
assert address.endswith(f"код {code}")
assert "домофон" not in address.casefold()
def test_ticket_09_02_retains_the_source_motorway_designation_in_caller_facts():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t09-2-labour"
)
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
assert "Горьковского шоссе (М7)" in address_fact.value
assert "Горьковского шоссе (М7)" in scenario.ground_truth.address
def test_ticket_18_01_retains_both_source_motorway_designations_in_caller_facts():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t18-1-unknown-fire"
)
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
assert "М3" in address_fact.value
assert "М1" in address_fact.value
assert "М3" in scenario.ground_truth.address
assert "М1" in scenario.ground_truth.address
def test_ticket_8_2_preserves_both_highway_designations_from_source():
scenario = next(
item
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if item.id == "t08-2-unconscious-roadside"
)
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
for designation in ("М3", "М1"):
assert designation in address_fact.value
assert designation in scenario.ground_truth.address
def test_ticket_five_preserves_source_observation_without_inventing_age():
cards = {
item.id: item
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if item.id in {"t05-1-window-fire-people", "t05-2-wrong-medicine"}
}
assert len(cards) == 2
fire_facts = {fact.id: fact.value for fact in cards["t05-1-window-fire-people"].facts}
assert fire_facts["f_observer"] == "заявитель наблюдает за пожаром с улицы"
medicine_facts = {fact.id: fact.value for fact in cards["t05-2-wrong-medicine"].facts}
assert medicine_facts["f_who"] == "Иванова Ирина Петровна, дата рождения 10.03.1975"
def test_ticket_seven_preserves_ambiguous_address_code_verbatim():
scenario = next(
item
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if item.id == "t07-3-lost-elderly"
)
assert scenario.ground_truth.address.endswith("код 5В")
assert "домофон" not in scenario.ground_truth.address
def test_unknown_ticket_victim_count_is_not_scored():
from app.scoring.card import evaluate_card
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None and scenario.ground_truth.victims is None]
cards = [
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None and scenario.ground_truth.victims is None
]
assert len(cards) == 27
assert all("victims_count" not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
for scenario in cards)
assert all(
"victims_count"
not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
for scenario in cards
)
def test_signs_give_the_code_from_the_book():
@ -89,7 +286,10 @@ def test_category_filters_all_three_incident_choices_and_card_derivation():
assert fire.signs[2] in ekp.signs_at_level(3, fire.signs[:2], group=fire.group)
card = derive_incident(KIO(incident_group=fire.group, signs=fire.signs))
assert card.incident_code == fire.code
assert derive_incident(KIO(incident_group=other_group, signs=fire.signs)).incident_code is None
assert (
derive_incident(KIO(incident_group=other_group, signs=fire.signs)).incident_code
is None
)
def test_service_rows_hidden_from_operator_are_not_offered():
@ -156,9 +356,14 @@ def test_victims_bring_the_ambulance():
в поле `victims_count` поднимают скорую (docs/spec/DATASET.md)."""
from app.domain.kio import KIO, derive_incident
quiet = derive_incident(KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"]))
quiet = derive_incident(
KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"])
)
hurt = derive_incident(
KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"], victims_count=5)
KIO(
signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"],
victims_count=5,
)
)
assert "СМП" not in quiet.notify
assert "СМП" in hurt.notify
@ -169,7 +374,10 @@ def test_gasified_object_brings_mosgaz():
from app.domain.kio import KIO, FireDetails, derive_incident
card = derive_incident(
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
KIO(
signs=["жилой дом", "балкон", "открытое пламя"],
fire=FireDetails(gasified=True),
)
)
assert "МОСГАЗ" in card.notify
@ -178,7 +386,10 @@ def test_removed_modifier_recalculates_notify_without_stale_service():
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
card = derive_incident(
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
KIO(
signs=["жилой дом", "балкон", "открытое пламя"],
fire=FireDetails(gasified=True),
)
)
assert "МОСГАЗ" in card.notify
updated = apply_patch(card, {"fire.gasified": False})

View file

@ -1,6 +1,7 @@
"""Групповая сводка считает людей, а не число их повторных попыток."""
from uuid import uuid4
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
@ -9,6 +10,7 @@ from starlette.requests import Request
from app.api import auth
from app.api.http import groups as group_api
from app.api.auth import Principal
from app.db.models import AuditLog
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
@ -47,25 +49,53 @@ def test_unknown_codes_do_not_break_group_summary():
assert [item["code"] for item in result["errors"]] == ["E5"]
@pytest.mark.asyncio
async def test_group_insight_fails_closed_if_audit_cannot_be_saved(monkeypatch):
who = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(group_api, "require", lambda _request, *_roles: who)
async def fake_analytics(*_args, **_kwargs):
return SimpleNamespace(scored_attempts=1, model_dump=lambda **_kwargs: {})
async def fake_insight(_data):
return {"summary": "Повторить уточнение адреса", "priorities": []}
async def unavailable_audit(*_args, **_kwargs):
raise HTTPException(status_code=503, detail="audit_unavailable")
monkeypatch.setattr(group_api, "_analytics", fake_analytics)
monkeypatch.setattr(group_api, "generate_group_insight", fake_insight)
monkeypatch.setattr(group_api, "audit_required", unavailable_audit)
with pytest.raises(HTTPException) as exc:
await group_api.ai_insight(uuid4(), object(), object())
assert exc.value.status_code == 503
assert exc.value.detail == "audit_unavailable"
@pytest.mark.asyncio
async def test_group_creation_requires_staff_and_returns_new_group(monkeypatch):
class FakeDb:
def add(self, group):
group.id = uuid4()
def __init__(self):
self.added = []
self.commits = 0
def add(self, row):
self.added.append(row)
async def commit(self):
pass
async def no_audit(*args):
pass
monkeypatch.setattr(group_api, "audit", no_audit)
self.commits += 1
instructor = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
request = Request({"type": "http", "session": {}})
auth._issue_session(request, instructor)
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, FakeDb())
db = FakeDb()
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, db)
assert created.name == "Группа 1"
assert created.id
assert db.commits == 1
audit_row = next(row for row in db.added if isinstance(row, AuditLog))
assert audit_row.action == "group.create" and audit_row.object_id == str(created.id)
trainee = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE)
forbidden = Request({"type": "http", "session": {}})

View file

@ -1,10 +1,8 @@
"""Живые проверки LLM: клиент, кэш и звонящий своими словами.
"""Живые проверки локальной LLM: клиент, кэш и ответы звонящего.
Читают `backend/.env.test` — бесплатная модель через OpenRouter. Без этого файла
или без сети тест пропускается: обычные тесты в сеть не ходят вовсе.
Запускать отдельно (`make test-llm`): бесплатная рассуждающая модель отвечает
десятки секунд, и в общем прогоне ей не место.
Читают `backend/.env.test` с `LLM_PROVIDER=local` и loopback URL. Запускайте
после `make local-llm` отдельной командой `make test-llm-local`. Сеть не нужна;
основной pytest намеренно исключает медленный инференс.
"""
import os
@ -15,37 +13,97 @@ import pytest
ENV_TEST = Path(__file__).resolve().parents[1] / ".env.test"
def _load_test_env() -> bool:
if not ENV_TEST.exists():
return False
def _read_test_env() -> dict[str, str]:
values: dict[str, str] = {}
for line in ENV_TEST.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
os.environ[key.strip()] = value.strip()
from app.config import get_settings
get_settings.cache_clear()
return True
values[key.strip()] = value.strip()
return values
HAS_TEST_ENV = ENV_TEST.is_file()
pytestmark = [
pytest.mark.llm,
pytest.mark.skipif(not _load_test_env(), reason="нет backend/.env.test — живые проверки LLM пропущены"),
pytest.mark.skipif(not HAS_TEST_ENV, reason="нет backend/.env.test — живые проверки LLM пропущены"),
]
@pytest.fixture(autouse=True)
def local_llm_test_environment():
"""Изолировать live-конфиг: collection обычных тестов не меняет env."""
if not HAS_TEST_ENV:
yield
return
values = _read_test_env()
original = {key: os.environ.get(key) for key in values}
for key, value in values.items():
os.environ[key] = value
from app.config import get_settings
from app.dialog.llm import is_loopback_url
get_settings.cache_clear()
try:
settings = get_settings()
if (
settings.llm_provider != "local"
or not is_loopback_url(settings.llm_base_url)
or settings.llm_api_key
):
raise pytest.UsageError(
"test-llm-local требует LLM_PROVIDER=local, loopback URL и пустой LLM_API_KEY"
)
yield
finally:
for key, value in original.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
get_settings.cache_clear()
@pytest.fixture
async def client():
from app.dialog.llm import LlmClient
# Бесплатная рассуждающая модель думает по минуте: в живой проверке
# это допустимо, в занятии — нет, там таймаут 8 секунд и откат на заготовки.
# Локальная модель может быть медленной на слабом CPU; в занятии таймаут
# короче, и при отказе используются проверенные заготовки.
llm = LlmClient(timeout=180)
yield llm
await llm.aclose()
@pytest.fixture
async def database_client(postgres_access):
"""Подключить проверку кэша к PostgreSQL, когда тестовый стенд её дал.
Живые inference smoke должны работать и без БД, но проверка устойчивого
кэша имеет смысл только в отдельной DB-интеграционной цели.
"""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import LlmCache
from app.dialog.llm import LlmClient
sessionmaker = get_sessionmaker()
try:
async with sessionmaker() as db:
await db.scalar(select(LlmCache.context_hash).limit(1))
except Exception as exc: # noqa: BLE001 — кэш необязателен для обычного inference smoke
if os.environ.get("DATABASE_URL"):
raise
pytest.skip(f"таблица кэша LLM недоступна: {type(exc).__name__}")
db_client = LlmClient(sessionmaker=sessionmaker, timeout=180)
yield db_client
await db_client.aclose()
async def test_provider_answers(client):
from app.dialog.llm import LlmRequest, LlmUnavailable
@ -58,7 +116,7 @@ async def test_provider_answers(client):
try:
text = await client.complete(request, use_cache=False)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
pytest.fail(f"локальная модель недоступна: {exc}")
assert text, "пустой ответ модели"
@ -81,14 +139,14 @@ async def test_caller_speaks_only_revealed_facts(client):
# Оператор спрашивает не об адресе — адрес прозвучать не должен.
reply = await caller.reply(slots.hear("Что у вас случилось?"), persona, slots)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
pytest.fail(f"локальная модель недоступна: {exc}")
assert caller.fallbacks == 0, "ответила не модель, а заготовка"
assert "Ленина" not in reply.text, f"звонящий выдал адрес без вопроса: «{reply.text}»"
assert len(reply.text) < 300, "звонящий пишет объяснительную вместо крика"
async def test_same_context_comes_from_cache(client):
async def test_same_context_comes_from_cache(database_client):
"""Кэш по хешу контекста: та же реплика на том же месте занятия звучит
одинаково у каждой группы и не стоит второго запроса."""
from app.dialog.llm import LlmRequest, LlmUnavailable
@ -100,17 +158,15 @@ async def test_same_context_comes_from_cache(client):
max_tokens=400,
)
try:
first = await client.complete(request)
first = await database_client.complete(request)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
pytest.fail(f"локальная модель недоступна: {exc}")
import time
started = time.monotonic()
second = await client.complete(request)
second = await database_client.complete(request)
elapsed = time.monotonic() - started
if client._sessionmaker is None:
pytest.skip("кэш выключен: база недоступна")
assert second == first, "кэш вернул другой ответ"
assert elapsed < 1.0, f"второй запрос занял {elapsed:.2f} с — кэш не сработал"

View file

@ -14,8 +14,10 @@ from app.dialog.persona import PersonaState
from app.scoring.grammar import assess, basic_check
from app.dialog.slots import SlotMachine
from app.voice.models import WhisperRecognizer
from scripts import local_llms
from scripts import local_stt
from tests.test_slots import SCENARIO, StemEmbedder
from tests.test_refinement import SCENARIO as REFINED_SCENARIO
@pytest.fixture(autouse=True)
@ -42,10 +44,11 @@ def test_offline_model_address_must_be_literal_loopback():
@pytest.mark.asyncio
async def test_local_llm_uses_loopback_without_api_key(monkeypatch):
@pytest.mark.parametrize("api_key", ["", "leftover-cloud-key"])
async def test_local_llm_never_sends_an_api_key(monkeypatch, api_key):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_API_KEY", "")
monkeypatch.setenv("LLM_API_KEY", api_key)
requests = []
def answer(request):
@ -114,6 +117,58 @@ async def test_malformed_local_answer_falls_back_instead_of_crashing(monkeypatch
await client.aclose()
@pytest.mark.asyncio
async def test_llm_error_does_not_expose_provider_body(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
client = LlmClient(transport=httpx.MockTransport(
lambda _: httpx.Response(500, text="private incident address: 17 Example Street")
))
try:
with pytest.raises(LlmUnavailable) as raised:
await client.complete(
LlmRequest(messages=[{"role": "user", "content": "redacted prompt"}],
model="Qwen3-1.7B"),
use_cache=False,
)
assert "HTTP 500" in str(raised.value)
assert "Example Street" not in str(raised.value)
assert "redacted prompt" not in str(raised.value)
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_llm_cache_write_failure_does_not_log_prompt_or_response(caplog):
class FakeDb:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
def add(self, _row):
return None
async def commit(self):
raise RuntimeError("sensitive prompt echoed by database driver")
request = LlmRequest(
messages=[{"role": "user", "content": "private caller address"}],
model="Qwen3-1.7B",
)
client = LlmClient(sessionmaker=FakeDb)
try:
await client._to_cache("hash", request, "private caller response")
finally:
await client.aclose()
assert "sensitive prompt" not in caplog.text
assert "private caller address" not in caplog.text
assert "private caller response" not in caplog.text
assert "RuntimeError" in caplog.text
@pytest.mark.asyncio
async def test_qwen_disabled_thinking_closing_marker_is_not_spoken(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
@ -243,6 +298,37 @@ async def test_rejected_qwen_turn_does_not_poison_next_turn():
assert all("99" not in message["content"] for message in client.requests[1].messages)
@pytest.mark.asyncio
async def test_address_correction_discards_old_value_from_qwen_context():
old_address = "улица Станционная, дом 28"
new_address = "Королёв, улица Станционная, дом 28"
class FakeClient:
def __init__(self):
self.requests = []
self.answers = iter([old_address, new_address])
async def complete(self, request):
self.requests.append(request)
return next(self.answers)
client = FakeClient()
caller = LlmCaller(client, "Qwen3-1.7B")
slots = SlotMachine(REFINED_SCENARIO, StemEmbedder(), floor=0.5)
persona = PersonaState(REFINED_SCENARIO.persona)
first = await caller.reply(slots.hear("Назовите адрес"), persona, slots)
assert first.source == "local_llm"
refined_turn = slots.hear("Это точно Москва город?")
second = await caller.reply(refined_turn, persona, slots)
assert refined_turn.refined == ["f_address"]
assert second.source == "local_llm"
assert second.text == new_address
assert len(client.requests[1].messages) == 2 # system + current user turn; no stale dialogue history
assert client.requests[1].messages[-1]["content"] == "Это точно Москва город?"
def test_whisper_cpp_uses_loopback_wav_only():
requests = []
@ -274,3 +360,44 @@ def test_whisper_cpp_command_is_local_and_uses_downloaded_weight(tmp_path, monke
assert "127.0.0.1" in argv
assert "18082" in argv
assert "ggml-small-q5_1.bin" in " ".join(argv)
def test_windows_llama_runner_uses_explicit_exe_and_ignores_bundled_macos(
tmp_path, monkeypatch,
):
mac_binary = tmp_path / "models" / "bin" / "llama-b10934" / "llama-server"
mac_binary.parent.mkdir(parents=True)
mac_binary.write_bytes(b"Mach-O test fixture")
windows_binary = tmp_path / "llama-server.exe"
windows_binary.write_bytes(b"Windows test fixture")
monkeypatch.setattr(local_llms, "ROOT", tmp_path)
monkeypatch.setattr(local_llms.sys, "platform", "win32")
monkeypatch.setattr(local_llms.shutil, "which", lambda _name: None)
monkeypatch.setenv("LLAMA_SERVER_BIN", str(windows_binary))
assert local_llms.binary_path() == str(windows_binary)
monkeypatch.delenv("LLAMA_SERVER_BIN")
with pytest.raises(RuntimeError, match="llama-server"):
local_llms.binary_path()
def test_windows_whisper_runner_uses_explicit_exe_and_ignores_bundled_macos(
tmp_path, monkeypatch,
):
mac_binary = (
tmp_path / "models" / "bin" / "whisper.cpp-1.9.4" / "build" / "bin"
/ "whisper-server"
)
mac_binary.parent.mkdir(parents=True)
mac_binary.write_bytes(b"Mach-O test fixture")
windows_binary = tmp_path / "whisper-server.exe"
windows_binary.write_bytes(b"Windows test fixture")
monkeypatch.setattr(local_stt, "ROOT", tmp_path)
monkeypatch.setattr(local_stt.sys, "platform", "win32")
monkeypatch.setattr(local_stt.shutil, "which", lambda _name: None)
monkeypatch.setenv("WHISPER_SERVER_BIN", str(windows_binary))
assert local_stt.binary_path() == str(windows_binary)
monkeypatch.delenv("WHISPER_SERVER_BIN")
with pytest.raises(RuntimeError, match="whisper-server"):
local_stt.binary_path()

View file

@ -141,16 +141,24 @@ def test_unassigned_trainee_cannot_download_resource(client):
def test_archive_hides_material_from_trainee_but_keeps_record(client):
_instructor(client)
seeded = client.get("/api/materials").json()[0]
archived = client.delete(f"/api/materials/{seeded['id']}")
created = client.post("/api/materials", json={
"title": "Архивируемая памятка",
"kind": "text",
"body": "Уникальный тестовый материал для проверки архивации.",
})
assert created.status_code == 201, created.text
material_id = created.json()["id"]
archived = client.delete(f"/api/materials/{material_id}")
assert archived.status_code == 200
assert archived.json()["active"] is False
assert client.get("/api/materials").json() == []
assert all(item["id"] != material_id for item in client.get("/api/materials").json())
archived_list = client.get("/api/materials?include_archived=true").json()
assert len(archived_list) == 1 and archived_list[0]["active"] is False
archived_item = next(item for item in archived_list if item["id"] == material_id)
assert archived_item["active"] is False
_trainee(client)
assert client.get("/api/materials").json() == []
assert all(item["id"] != material_id for item in client.get("/api/materials").json())
def test_trainee_starts_assigned_practice_in_self_mode(client):

View file

@ -0,0 +1,55 @@
import pytest
from fastapi.testclient import TestClient
from app import main
from app.config import Settings
def prod_settings(**overrides) -> Settings:
values = {
"app_env": "production",
"database_url": "postgresql+asyncpg://lct:postgres-secret-with-more-than-32-characters@localhost:5432/lct",
"session_secret": "a-unique-secret-that-is-at-least-32-characters-long",
"secure_cookies": True,
"dev_auth_bypass": False,
"offline": True,
"llm_provider": "local",
**overrides,
}
return Settings(_env_file=None, **values)
def test_production_accepts_unique_secret_https_cookie_and_password_auth():
prod_settings().validate_deployment_security()
@pytest.mark.parametrize(
("overrides", "message"),
[
({"session_secret": "dev-secret-поменять-на-стенде"}, "SESSION_SECRET"),
({"session_secret": "short"}, "SESSION_SECRET"),
({"database_url": "postgresql+asyncpg://lct:short@localhost:5432/lct"}, "PostgreSQL password"),
({"database_url": "postgresql+asyncpg://lct:has%40unsafe%40characters-over-32@localhost:5432/lct"}, "PostgreSQL password"),
({"secure_cookies": False}, "SECURE_COOKIES"),
({"dev_auth_bypass": True}, "DEV_AUTH_BYPASS"),
({"demo_no_db": True}, "DEMO_NO_DB"),
({"offline": False}, "OFFLINE"),
({"llm_provider": "openai"}, "LLM_PROVIDER"),
],
)
def test_production_rejects_insecure_authentication_defaults(overrides, message):
with pytest.raises(ValueError, match=message):
prod_settings(**overrides).validate_deployment_security()
def test_development_keeps_local_http_and_dev_token_available():
settings = Settings(_env_file=None, app_env="development")
settings.validate_deployment_security()
def test_production_app_startup_fails_before_serving_with_default_secret(monkeypatch):
settings = prod_settings(session_secret="dev-secret-поменять-на-стенде")
monkeypatch.setattr(main, "get_settings", lambda: settings)
with pytest.raises(RuntimeError, match="SESSION_SECRET"):
with TestClient(main.app):
pass

View file

@ -4,12 +4,18 @@
профиль читается по HTTP. Без Postgres пропускается.
"""
import asyncio
import time
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.config import get_settings
from app.api.auth import Principal
from app.api.http import trainees as trainees_api
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@ -25,7 +31,7 @@ def client():
try:
socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2).close()
except OSError as exc:
pytest.skip(f"Postgres недоступен ({exc}) — подними `make dev`")
pytest.skip(f"Postgres недоступен ({exc}) — запусти `make test-db`")
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
@ -86,6 +92,102 @@ def test_profile_shows_attempts_and_delta(client):
if delta["facts_got"] is not None:
assert delta["facts_got"] >= 0, "во второй попытке фактов добыто не меньше"
# Личный совет должен отражать последнюю оценённую попытку, а не копить
# нарушения за всю историю и не раскрывать неизвестные коды таксономии.
latest_scored = next(item for item in reversed(profile["attempts"]) if item["score"] is not None)
latest_codes = latest_scored["codes"]
from app.scoring.group import RECOMMENDATIONS
recommendations = profile["recommendations"]
assert len(recommendations) <= 5
expected_codes = {
code for code, count in latest_codes.items()
if code in RECOMMENDATIONS and isinstance(count, int) and count > 0
}
assert {item["code"] for item in recommendations} == expected_codes
for item in recommendations:
assert item["occurrences"] == latest_codes[item["code"]]
assert item["title"] and item["recommendation"]
def test_profile_of_unknown_trainee_is_404(client):
assert client.get(f"/api/trainees/{uuid4()}/profile").status_code == 404
def test_personal_recommendations_are_explainable_and_limited_to_known_codes():
from app.api.http.trainees import _personal_recommendations
result = _personal_recommendations({"D6": 1, "E1": 3, "unknown": 99, "D2": 0})
assert [item.code for item in result] == ["E1", "D6"]
assert result[0].title == "Пропущенный факт"
assert result[0].recommendation
assert result[0].occurrences == 3
def test_profile_read_is_audited_without_copying_profile_data(monkeypatch):
trainee_id = uuid4()
trainee = SimpleNamespace(id=trainee_id, name="Курсант Петров", group_id=None)
who = Principal(
login="trainee-login", full_name=trainee.name, role=Role.TRAINEE,
trainee_id=trainee_id,
)
class Rows:
def __iter__(self):
return iter(())
class Database:
async def get(self, model, key):
assert key == trainee_id
return trainee
async def execute(self, _statement):
return Rows()
events = []
async def capture(actor, role, action, object_id=None, detail=""):
events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(trainees_api, "require", lambda *_args, **_kwargs: who)
monkeypatch.setattr(trainees_api, "audit_required", capture)
result = asyncio.run(trainees_api.profile(trainee_id, object(), Database()))
assert result.trainee.name == "Курсант Петров"
assert events == [("trainee-login", "trainee", "trainee.profile.read", str(trainee_id), "")]
def test_certificate_export_is_audited_before_response(monkeypatch):
trainee_id = uuid4()
trainee = SimpleNamespace(id=trainee_id, name="Курсант Петров", group_id=None)
who = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
class Result:
def one(self):
return 1, 90.0, datetime(2026, 9, 26, tzinfo=timezone.utc)
class Database:
async def scalar(self, _statement):
return uuid4()
async def get(self, model, key):
assert key == trainee_id
return trainee
async def execute(self, _statement):
return Result()
events = []
async def capture(actor, role, action, object_id=None, detail=""):
events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(trainees_api, "require", lambda *_args, **_kwargs: who)
monkeypatch.setattr(trainees_api, "audit_required", capture)
monkeypatch.setattr(trainees_api, "certificate_pdf", lambda **_kwargs: b"%PDF-test")
response = asyncio.run(trainees_api.certificate(trainee_id, object(), Database()))
assert response.body == b"%PDF-test"
assert events == [
("teacher", "instructor", "trainee.certificate.export.pdf", str(trainee_id), "")
]

View file

@ -1,7 +1,8 @@
"""WAV-запись вызова: формат, микширование и защищённая выдача."""
import os
import wave
from datetime import datetime, timezone
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
@ -25,6 +26,9 @@ def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
assert recorder.finalize() == path
assert recorder.finalize() == path
assert not path.with_suffix(".wav.tmp").exists()
assert not path.with_suffix(".wav.journal").exists()
if os.name == "posix": # Windows exposes a different permission model.
assert os.stat(path).st_mode & 0o777 == 0o600
with wave.open(str(path), "rb") as source:
assert source.getnchannels() == 1
assert source.getsampwidth() == 2
@ -34,6 +38,34 @@ def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
assert samples.max() >= 2000
def test_recorder_recovers_audio_journal_after_process_restart(tmp_path):
path = tmp_path / "interrupted.wav"
clock_value = [10.0]
clock = lambda: clock_value[0]
first_process = CallRecorder(path, clock=clock)
first_process.add_pcm((1000).to_bytes(2, "little", signed=True) * 320, sample_rate=16_000)
journal = path.with_suffix(".wav.journal")
# Simulate power loss halfway through a journal record. The next process
# must keep all complete audio and discard only the torn tail.
first_process._journal.close()
with journal.open("ab") as partial:
partial.write(b"\x40\x01\x00\x00\x00\x00\x00\x00\x40\x01\x00\x00\x02\x00")
clock_value[0] = 50.0 # monotonic origin changed across host restart
recovered = CallRecorder(path, clock=clock)
recovered.add_pcm((2000).to_bytes(2, "little", signed=True) * 320, sample_rate=16_000)
recovered.finalize()
with wave.open(str(path), "rb") as source:
samples = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
assert source.getframerate() == 16_000
assert samples.size == 640
assert np.all(samples[:320] == 1000)
assert np.all(samples[320:] == 2000)
assert not journal.exists()
def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch):
session_id = uuid4()
path = tmp_path / f"{session_id}.wav"
@ -46,11 +78,17 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
async def fake_session(db, requested):
assert requested == session_id
return SimpleNamespace(
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc),
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(UTC),
)
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
audit_events = []
async def record_access(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(sessions, "audit_required", record_access)
with TestClient(app) as client:
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401
client.post("/api/auth/dev-token")
@ -58,6 +96,9 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert response.content.startswith(b"RIFF")
assert audit_events == [
("dev", "instructor", "recording.read", str(session_id), "")
]
monkeypatch.setattr(
sessions,
@ -67,3 +108,29 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
),
)
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403
assert len(audit_events) == 1
def test_recording_is_not_returned_when_access_audit_is_unavailable(tmp_path, monkeypatch):
from fastapi import HTTPException
session_id = uuid4()
path = tmp_path / f"{session_id}.wav"
path.write_bytes(b"not returned")
async def fake_session(db, requested):
return SimpleNamespace(
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(UTC),
)
async def audit_unavailable(*_args, **_kwargs):
raise HTTPException(status_code=503, detail="audit_unavailable")
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
monkeypatch.setattr(sessions, "audit_required", audit_unavailable)
with TestClient(app) as client:
client.post("/api/auth/dev-token")
response = client.get(f"/api/sessions/{session_id}/recording.wav")
assert response.status_code == 503
assert response.json() == {"detail": "audit_unavailable"}

View file

@ -3,16 +3,17 @@
import asyncio
import csv
import io
from datetime import datetime, timezone
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.api.http import sessions
from app.config import get_settings
from app.domain.events import SessionReport
from app.domain.roles import Role
from app.main import app
@ -20,7 +21,7 @@ from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
def sample_report(*, long: bool = False) -> SessionReport:
at = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
at = datetime(2026, 9, 21, 12, 0, tzinfo=UTC)
long_text = "Заявитель сообщает о дыме в учебном помещении. " * (240 if long else 1)
return SessionReport.model_validate({
"session_id": str(uuid4()),
@ -114,18 +115,32 @@ def test_certificate_pdf_contains_saved_result(tmp_path):
@pytest.fixture
def client(monkeypatch):
report = sample_report()
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login="dev")
owner_login = "demo-instructor" if get_settings().demo_no_db else "dev"
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login=owner_login)
audit_events = []
async def capture_audit(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id))
state.audit_events = audit_events
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
monkeypatch.setattr(sessions, "audit_required", capture_audit)
with TestClient(app) as test_client:
# These endpoint tests exercise the in-memory live-report path. Durable
# report readiness is covered by the isolated PostgreSQL integration suite.
monkeypatch.setattr(sessions.hub, "journal", None)
test_client.post("/api/auth/dev-token")
yield test_client, state, report
def test_export_routes_return_downloads_with_json_report_rights(client):
browser, state, report = client
json_response = browser.get(f"/api/sessions/{report.session_id}/report")
assert json_response.status_code == 200, json_response.text
csv_response = browser.get(f"/api/sessions/{report.session_id}/report.csv")
assert csv_response.status_code == 200
assert csv_response.status_code == 200, csv_response.text
assert csv_response.headers["content-type"].startswith("text/csv")
assert csv_response.content.startswith(b"\xef\xbb\xbf")
assert "attachment" in csv_response.headers["content-disposition"]
@ -134,6 +149,11 @@ def test_export_routes_return_downloads_with_json_report_rights(client):
assert pdf_response.status_code == 200
assert pdf_response.headers["content-type"] == "application/pdf"
assert pdf_response.content.startswith(b"%PDF-")
assert state.audit_events == [
("dev", "instructor", "report.read", str(report.session_id)),
("dev", "instructor", "report.export.csv", str(report.session_id)),
("dev", "instructor", "report.export.pdf", str(report.session_id)),
]
state.score = None
assert browser.get(f"/api/sessions/{report.session_id}/report.csv").status_code == 409
@ -141,13 +161,28 @@ def test_export_routes_return_downloads_with_json_report_rights(client):
def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
browser, state, report = client
browser, _state, report = client
monkeypatch.setattr(
sessions, "require",
lambda request: Principal(login="trainee", full_name="Учебный", role=Role.TRAINEE, trainee_id=uuid4()),
)
for suffix in ("csv", "pdf"):
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
assert not client[1].audit_events
def test_report_export_fails_closed_when_access_audit_is_unavailable(client, monkeypatch):
from fastapi import HTTPException
browser, _state, report = client
async def unavailable(*_args, **_kwargs):
raise HTTPException(status_code=503, detail="audit_unavailable")
monkeypatch.setattr(sessions, "audit_required", unavailable)
response = browser.get(f"/api/sessions/{report.session_id}/report.pdf")
assert response.status_code == 503
assert response.json() == {"detail": "audit_unavailable"}
def test_archived_report_survives_missing_live_session(monkeypatch):
@ -220,6 +255,12 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
sessions, "require",
lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
)
audit_events = []
async def capture_audit(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id))
monkeypatch.setattr(sessions, "audit_required", capture_audit)
corrected = asyncio.run(sessions.override(
archived.session_id,
@ -237,7 +278,8 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
assert corrected.override_comment == "проверена запись переговоров"
audit = db.added[0]
assert audit.action == "score.override" and audit.actor == "teacher"
assert "84.5" in audit.detail and "проверена запись переговоров" in audit.detail
assert "84.5" in audit.detail and "comment_chars=" in audit.detail
assert "проверена запись переговоров" not in audit.detail
report = asyncio.run(sessions.report(archived.session_id, object(), db))
assert report.score_final == 84.5 and report.score_auto == 70.0
@ -246,3 +288,8 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
assert "проверена запись переговоров" in csv_response.body.decode("utf-8-sig")
pdf_response = asyncio.run(sessions.report_pdf(archived.session_id, object(), db))
assert pdf_response.body.startswith(b"%PDF-")
assert audit_events == [
("teacher", "instructor", "report.read", str(archived.session_id)),
("teacher", "instructor", "report.export.csv", str(archived.session_id)),
("teacher", "instructor", "report.export.pdf", str(archived.session_id)),
]

View file

@ -0,0 +1,285 @@
"""Fail closed if a new API endpoint forgets its authentication gate.
This is a structural guard, not a substitute for the per-role and owner-scope
HTTP/WebSocket integration tests. Public endpoints are kept in a small explicit
allowlist so that adding a route cannot silently make it public.
"""
import ast
from pathlib import Path
API_ROOT = Path(__file__).parents[1] / "app" / "api"
# Public by design: credential entry/session bootstrap and the non-sensitive
# incident classification dictionary. dev-token has its own fail-closed flag
# and remains hidden in production.
PUBLIC_HTTP_ROUTES = {
("auth.py", "post", "/login"),
("auth.py", "post", "/dev-token"),
("http/ekp.py", "get", "/groups"),
("http/ekp.py", "get", "/signs"),
}
# Routes that centralize ownership + authentication checks in a shared helper.
DELEGATED_HTTP_AUTH = {
("http/sessions.py", "get", "/{session_id}/report"): "_report_data",
("http/sessions.py", "get", "/{session_id}/report.csv"): "_report_data",
("http/sessions.py", "get", "/{session_id}/report.pdf"): "_report_data",
}
# Each tuple is the exact positional Role allowlist passed to a route's
# require(request, ...). An empty tuple means any authenticated principal, with
# resource ownership checked in the handler. The outer tuple preserves routes
# that intentionally apply more than one gate (e.g. authentication then role).
HTTP_ROLE_GATE_POLICY = {
("http/admin.py", "get", "/config.xml"): (("ADMIN",),),
("http/admin.py", "get", "/users"): (("ADMIN",),),
("http/admin.py", "post", "/users"): (("ADMIN",),),
("http/admin.py", "patch", "/users/{user_id}"): (("ADMIN",),),
("http/admin.py", "get", "/audit"): (("ADMIN",),),
("http/admin.py", "get", "/audit.csv"): (("ADMIN",),),
("http/admin.py", "get", "/diagnostics"): (("ADMIN",),),
("http/admin.py", "get", "/diagnostics.json"): (("ADMIN",),),
("http/admin.py", "get", "/status"): (("ADMIN",),),
("http/admin.py", "get", "/backups"): (("ADMIN",),),
("http/admin.py", "post", "/backups"): (("ADMIN",),),
("http/groups.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
("http/groups.py", "post", ""): (("ADMIN", "INSTRUCTOR"),),
("http/groups.py", "patch", "/{group_id}/owner"): (("ADMIN",),),
("http/groups.py", "put", "/{group_id}/trainees/{trainee_id}"): (("ADMIN", "INSTRUCTOR"),),
("http/groups.py", "get", "/{group_id}/analytics"): (("ADMIN", "INSTRUCTOR"),),
("http/groups.py", "post", "/{group_id}/analytics/insight"): (("INSTRUCTOR",),),
("http/materials.py", "get", ""): ((), ("ADMIN", "INSTRUCTOR")),
("http/materials.py", "post", ""): (("INSTRUCTOR",),),
("http/materials.py", "patch", "/{material_id}"): (("INSTRUCTOR",),),
("http/materials.py", "delete", "/{material_id}"): (("INSTRUCTOR",),),
("http/materials.py", "put", "/{material_id}/assign/{trainee_id}"): (("INSTRUCTOR",),),
("http/materials.py", "put", "/{material_id}/assign-group/{group_id}"): (("INSTRUCTOR",),),
("http/materials.py", "delete", "/{material_id}/assign/{trainee_id}"): (("INSTRUCTOR",),),
("http/materials.py", "post", "/{material_id}/complete"): (("TRAINEE",),),
("http/materials.py", "post", "/{material_id}/start"): (("TRAINEE",),),
("http/materials.py", "get", "/{material_id}/download"): ((),),
("http/scenario_submissions.py", "post", ""): (("TRAINEE",),),
("http/scenario_submissions.py", "get", ""): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
("http/scenario_submissions.py", "post", "/{submission_id}/review"): (("ADMIN", "INSTRUCTOR"),),
("http/scenarios.py", "post", "/drafts/from-template"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/generate"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/generate-from-description"): (("INSTRUCTOR",),),
("http/scenarios.py", "get", "/drafts/{scenario_id}"): (("INSTRUCTOR",),),
("http/scenarios.py", "patch", "/drafts/{scenario_id}"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/{scenario_id}/revise"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/{scenario_id}/validate"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/{scenario_id}/grammar-check"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/{scenario_id}/approve"): (("INSTRUCTOR",),),
("http/scenarios.py", "get", ""): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
("http/scenarios.py", "delete", "/{scenario_id}"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/{scenario_id}/restore"): (("INSTRUCTOR",),),
("http/scenarios.py", "get", "/{scenario_id}"): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
("http/sessions.py", "get", "/dds-history"): (("INSTRUCTOR", "TRAINEE"),),
("http/sessions.py", "get", "/active"): (("INSTRUCTOR",),),
("http/sessions.py", "post", ""): (("INSTRUCTOR",),),
("http/sessions.py", "get", "/{session_id}"): ((),),
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
("http/sessions.py", "get", ""): ((),),
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
("http/trainees.py", "get", "/{trainee_id}/profile"): ((),),
}
AUTH_SESSION_HTTP_ROUTES = {
("auth.py", "post", "/logout"),
("auth.py", "get", "/me"),
}
WEBSOCKET_ROLE_POLICY = {
("call.py", "/ws/call/{session_id}"): {"INSTRUCTOR", "TRAINEE"},
("control.py", "/ws/control/{session_id}"): {"INSTRUCTOR"},
("observe.py", "/ws/observe/{session_id}"): {"ADMIN", "INSTRUCTOR"},
("station.py", "/ws/station/{session_id}"): {"INSTRUCTOR", "TRAINEE"},
}
def _route_declaration(node: ast.FunctionDef | ast.AsyncFunctionDef):
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
continue
method = decorator.func.attr.lower()
if method not in {"get", "post", "put", "patch", "delete", "websocket"}:
continue
path = decorator.args[0] if decorator.args else None
if isinstance(path, ast.Constant) and isinstance(path.value, str):
return method, path.value
return None
def _called_names(node: ast.AST) -> set[str]:
return {
call.func.id if isinstance(call.func, ast.Name) else call.func.attr
for call in ast.walk(node)
if isinstance(call, ast.Call)
and (isinstance(call.func, ast.Name) or isinstance(call.func, ast.Attribute))
}
def _required_role_gates(node: ast.AST) -> tuple[tuple[str, ...], ...]:
gates = []
for call in ast.walk(node):
if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name) or call.func.id != "require":
continue
roles = tuple(sorted(
argument.attr
for argument in call.args[1:]
if isinstance(argument, ast.Attribute)
and isinstance(argument.value, ast.Name)
and argument.value.id == "Role"
))
gates.append(roles)
return tuple(sorted(gates))
def test_every_http_route_has_an_authentication_gate_or_explicit_public_reason():
discovered_public: set[tuple[str, str, str]] = set()
missing: list[str] = []
discovered_delegated: set[tuple[str, str, str]] = set()
sources = [*API_ROOT.glob("*.py"), *(API_ROOT / "http").glob("*.py")]
for source in sources:
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
relative = source.relative_to(API_ROOT).as_posix()
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
route = _route_declaration(node)
if route is None:
continue
method, path = route
key = (relative, method, path)
calls = _called_names(node)
if key in PUBLIC_HTTP_ROUTES:
discovered_public.add(key)
continue
delegated_helper = DELEGATED_HTTP_AUTH.get(key)
if delegated_helper and delegated_helper in calls:
discovered_delegated.add(key)
elif not calls.intersection({"require", "current"}):
missing.append(f"{relative}:{node.name} ({method.upper()} {path})")
assert discovered_public == PUBLIC_HTTP_ROUTES, (
"Public endpoint allowlist drifted; review each newly removed/renamed route "
f"and keep the allowlist exact. Missing: {PUBLIC_HTTP_ROUTES - discovered_public}; "
f"unexpected: {discovered_public - PUBLIC_HTTP_ROUTES}"
)
assert discovered_delegated == set(DELEGATED_HTTP_AUTH), (
"Delegated-auth routes drifted; re-check their shared guard: "
f"missing {set(DELEGATED_HTTP_AUTH) - discovered_delegated}"
)
assert not missing, "HTTP routes without require/current authentication gate: " + "; ".join(missing)
for (relative, _, _), helper_name in DELEGATED_HTTP_AUTH.items():
tree = ast.parse((API_ROOT / relative).read_text(encoding="utf-8"))
helper = next(
node for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == helper_name
)
assert "require" in _called_names(helper), (
f"delegated helper {relative}:{helper_name} must enforce authentication itself"
)
def test_every_websocket_route_checks_a_principal_before_serving():
missing: list[str] = []
for source in (API_ROOT / "ws").glob("*.py"):
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
route = _route_declaration(node)
if route is None or route[0] != "websocket":
continue
if "principal_of" not in _called_names(node):
missing.append(f"{source.name}:{node.name} ({route[1]})")
assert not missing, "WebSocket routes without principal check: " + "; ".join(missing)
def test_http_routes_match_the_reviewed_role_gate_matrix():
found: dict[tuple[str, str, str], tuple[tuple[str, ...], ...]] = {}
session_guards: set[tuple[str, str, str]] = set()
discovered_routes: set[tuple[str, str, str]] = set()
for source in [*API_ROOT.glob("*.py"), *(API_ROOT / "http").glob("*.py")]:
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
relative = source.relative_to(API_ROOT).as_posix()
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
route = _route_declaration(node)
if route is None:
continue
method, path = route
key = (relative, method, path)
discovered_routes.add(key)
if key in HTTP_ROLE_GATE_POLICY:
found[key] = _required_role_gates(node)
elif key in AUTH_SESSION_HTTP_ROUTES:
if "current" in _called_names(node):
session_guards.add(key)
reviewed_routes = (
set(HTTP_ROLE_GATE_POLICY)
| set(DELEGATED_HTTP_AUTH)
| set(PUBLIC_HTTP_ROUTES)
| AUTH_SESSION_HTTP_ROUTES
)
assert discovered_routes == reviewed_routes, (
"Every HTTP route must be categorized in the reviewed matrix; "
f"unreviewed={discovered_routes - reviewed_routes}, stale={reviewed_routes - discovered_routes}"
)
assert set(found) == set(HTTP_ROLE_GATE_POLICY), (
"The HTTP role matrix must enumerate every protected route; "
f"missing={set(HTTP_ROLE_GATE_POLICY) - set(found)}, "
f"unexpected={set(found) - set(HTTP_ROLE_GATE_POLICY)}"
)
differences = {
key: (HTTP_ROLE_GATE_POLICY[key], found[key])
for key in HTTP_ROLE_GATE_POLICY
if HTTP_ROLE_GATE_POLICY[key] != found[key]
}
assert not differences, f"HTTP route role-gate drift: {differences}"
assert session_guards == AUTH_SESSION_HTTP_ROUTES
def test_websocket_routes_match_the_reviewed_role_matrix():
found: dict[tuple[str, str], set[str]] = {}
for source in (API_ROOT / "ws").glob("*.py"):
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
route = _route_declaration(node)
if route is None or route[0] != "websocket":
continue
roles = {
item.attr for item in ast.walk(node)
if isinstance(item, ast.Attribute)
and isinstance(item.value, ast.Name)
and item.value.id == "Role"
}
found[(source.name, route[1])] = roles
assert found == WEBSOCKET_ROLE_POLICY, f"WebSocket role policy drift: {found}"
def test_dev_token_remains_runtime_gated():
source = (API_ROOT / "auth.py").read_text(encoding="utf-8")
tree = ast.parse(source)
target = next(
node for node in tree.body
if isinstance(node, ast.AsyncFunctionDef) and node.name == "dev_token"
)
calls_and_names = {node.id for node in ast.walk(target) if isinstance(node, ast.Name)}
attributes = {node.attr for node in ast.walk(target) if isinstance(node, ast.Attribute)}
assert "dev_auth_bypass" in calls_and_names | attributes
assert "demo_no_db" in calls_and_names | attributes

View file

@ -8,13 +8,19 @@ from fastapi.testclient import TestClient
from app.api.http import scenarios as scenarios_api
from app.config import get_settings
from app.dialog.llm import LlmUnavailable
from app.db.models import AuditLog
from app.main import app
from app.scenarios import store
from app.scenarios import generation, store
from app.scenarios.editor import merge_patch, template_copy, validate
from app.scenarios import generation
from app.scenarios.generation import (GenerationError, correction_target,
full_proposal_body, parse_full_proposal,
parse_proposal, proposal_body, style_fallback)
from app.scenarios.generation import (
GenerationError,
correction_target,
full_proposal_body,
parse_full_proposal,
parse_proposal,
proposal_body,
style_fallback,
)
from app.scenarios.loader import ScenarioError, load_file
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -25,23 +31,39 @@ class FakeSession:
def __init__(self):
self.rows = {}
self.audit_rows = []
self.commit_audit_counts = []
def add(self, row):
self.rows[row.id] = row
if isinstance(row, AuditLog):
self.audit_rows.append(row)
else:
self.rows[row.id] = row
async def get(self, model, key):
return self.rows.get(key)
async def commit(self):
pass
self.commit_audit_counts.append(len(self.audit_rows))
async def scalars(self, query):
expression = query.column_descriptions[0]["expr"]
if getattr(expression, "key", None) == "id":
owner_login = query.compile().params.get("owner_login_1")
owner_filter = next(
clause for clause in query.whereclause.clauses
if getattr(getattr(clause, "left", None), "key", None) == "owner_login"
and getattr(getattr(clause, "right", None), "value", None) is not None
)
owner_login = owner_filter.right.value
owner_operator = owner_filter.operator.__name__
return [
row.id for row in self.rows.values()
if row.status == "published" and row.owner_login == owner_login
if row.status == "published"
and (
row.owner_login == owner_login
if owner_operator == "eq"
else row.owner_login is not None and row.owner_login != owner_login
)
]
return [row for row in self.rows.values() if row.status == "published"]
@ -60,10 +82,18 @@ def client(monkeypatch):
monkeypatch.setattr(scenarios_api, "audit", no_audit)
monkeypatch.setattr(store, "restore_published", no_restore)
with TestClient(app) as test_client:
test_client.fake_db = db
yield test_client
app.dependency_overrides.clear()
def assert_atomic_audit(client, action: str, object_id: str) -> None:
row = client.fake_db.audit_rows[-1]
assert row.action == action
assert row.object_id == object_id
assert client.fake_db.commit_audit_counts[-1] == len(client.fake_db.audit_rows)
def test_template_copy_is_local_independent_and_valid():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
body = template_copy(source, "draft-example")
@ -87,6 +117,20 @@ def test_editor_rejects_derived_truth_and_missing_fact():
validate(broken)
def test_editor_can_save_explicit_scenario_decline_with_required_reason():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
body = template_copy(source, "draft-decline")
declined = merge_patch(body, {
"dds_decision": {
"expected": "decline",
"reason": "Повторный вызов уже отрабатывается по первой карточке.",
},
})
assert validate(declined).dds_decision.expected == "decline"
with pytest.raises(ScenarioError, match="reason обязателен"):
validate(merge_patch(body, {"dds_decision": {"expected": "decline"}}))
def test_ai_proposal_changes_only_story_and_keeps_reference():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
proposal = parse_proposal('''```json
@ -191,10 +235,10 @@ async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch
class FakeClient:
def __init__(self, **kwargs):
self.answers = iter([
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}',
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}',
('{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}'),
('{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}'),
])
async def complete(self, request, **kwargs):
@ -317,7 +361,13 @@ async def test_description_generation_retries_fact_that_is_a_question(monkeypatc
assert proposal["facts"]["f_people"].endswith("Пострадавших: 1")
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client, monkeypatch):
from app.scoring.grammar import GrammarAssessment
async def fake_assess(_text):
return GrammarAssessment(True, (), "rules")
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
assert client.post("/api/auth/dev-token").status_code == 200
response = client.post(
@ -329,6 +379,7 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
draft_id = draft["id"]
assert draft["generation"] == "template_copy"
assert draft["status"] == "draft"
assert_atomic_audit(client, "scenario.draft.create", draft_id)
assert store.get(draft_id) is None
assert client.get(f"/api/scenarios/{draft_id}").status_code == 404
@ -338,10 +389,14 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
)
assert changed.status_code == 200, changed.text
assert changed.json()["body"]["first_line"] == "Соседи! В доме дым!"
assert_atomic_audit(client, "scenario.draft.update", draft_id)
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
assert check.status_code == 200 and check.json()["valid"]
grammar = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
assert grammar.status_code == 200 and grammar.json()["valid"]
approved = client.post(f"/api/scenarios/drafts/{draft_id}/approve")
assert approved.status_code == 200, approved.text
assert_atomic_audit(client, "scenario.approve", draft_id)
assert approved.json()["status"] == "published"
assert store.get(draft_id).first_line == "Соседи! В доме дым!"
assert client.get(f"/api/scenarios/{draft_id}").status_code == 200
@ -365,6 +420,7 @@ def test_ai_draft_requires_instructor_review_before_publication(client, monkeypa
draft = response.json()
assert draft["generation"] == "ai_variant"
assert draft["id"].startswith("ai-")
assert_atomic_audit(client, "scenario.draft.ai_generate", draft["id"])
assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!"
assert store.get(draft["id"]) is None
assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404
@ -438,9 +494,73 @@ def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch):
assert body["facts"][0]["value"] == "улица Ленина, 14, квартира 47, 5-й этаж"
assert next(item["value"] for item in body["facts"] if item["id"] == "f_smoke").startswith("чёрный")
assert comments[-1] == "Сделай дым чёрным и закрой им площадку"
assert_atomic_audit(client, "scenario.draft.ai_revise", draft_id)
audit_row = client.fake_db.audit_rows[-1]
assert audit_row.detail == "instruction_chars=38"
assert "чёрным" not in audit_row.detail
assert client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
def test_manual_grammar_check_covers_caller_line_and_fact_values(client, monkeypatch):
from app.scoring.grammar import GrammarAssessment
checked = []
async def fake_assess(text):
checked.append(text)
return GrammarAssessment(True, (), "rules")
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
client.post("/api/auth/dev-token")
created = client.post("/api/scenarios/drafts/from-template", json={
"source_id": "fire-apartment-l2",
})
assert created.status_code == 201, created.text
draft_id = created.json()["id"]
changed = client.patch(
f"/api/scenarios/drafts/{draft_id}",
json={"first_line": "Помогите! Горит балкон."},
)
assert changed.status_code == 200, changed.text
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 409
response = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
assert response.status_code == 200, response.text
result = response.json()
assert result["valid"] is True
assert result["checks"][0]["field"] == "first_line"
assert len(result["checks"]) == 1 + len(changed.json()["body"]["facts"])
assert checked == [changed.json()["body"]["first_line"], *[
fact["value"] for fact in changed.json()["body"]["facts"]
]]
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200
def test_failed_grammar_check_does_not_authorize_manual_draft(client, monkeypatch):
from app.scoring.grammar import GrammarAssessment
async def fake_assess(_text):
return GrammarAssessment(False, ("тестовая языковая ошибка",), "rules")
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
client.post("/api/auth/dev-token")
created = client.post("/api/scenarios/drafts/from-template", json={
"source_id": "fire-apartment-l2",
})
draft_id = created.json()["id"]
assert client.patch(f"/api/scenarios/drafts/{draft_id}",
json={"first_line": "пожар"}).status_code == 200
result = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
assert result.status_code == 200
assert result.json()["valid"] is False
assert result.json()["checks"][0]["errors"] == ["тестовая языковая ошибка"]
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 409
def test_ai_editor_works_in_demo_lite_without_database(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
@ -517,11 +637,13 @@ def test_instructor_archives_and_restores_scenario_without_deleting_history(clie
scenario_id = created.json()["id"]
approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve")
assert approved.status_code == 200, approved.text
assert_atomic_audit(client, "scenario.approve", scenario_id)
original = store.get(scenario_id)
assert original is not None
archived = client.delete(f"/api/scenarios/{scenario_id}")
assert archived.status_code == 200, archived.text
assert_atomic_audit(client, "scenario.archive", scenario_id)
assert archived.json()["status"] == "archived"
assert store.get(scenario_id) is None
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
@ -529,6 +651,7 @@ def test_instructor_archives_and_restores_scenario_without_deleting_history(clie
restored = client.post(f"/api/scenarios/{scenario_id}/restore")
assert restored.status_code == 200, restored.text
assert_atomic_audit(client, "scenario.restore", scenario_id)
assert restored.json()["status"] == "published"
assert store.get(scenario_id).title == original.title
assert scenario_id in {item["id"] for item in client.get("/api/scenarios").json()}
@ -544,7 +667,6 @@ def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkey
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(scenarios_api, "require", instructor)
monkeypatch.setattr(scenarios_api, "current", instructor)
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Личный черновик"},
@ -570,7 +692,6 @@ def test_instructor_cannot_archive_another_instructors_published_scenario(client
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(scenarios_api, "require", instructor)
monkeypatch.setattr(scenarios_api, "current", instructor)
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Публикация автора"},
@ -582,8 +703,8 @@ def test_instructor_cannot_archive_another_instructors_published_scenario(client
assert scenario["can_manage"] is True
identity["login"] = "teacher-two"
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
assert scenario["can_manage"] is False
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404
assert client.delete(f"/api/scenarios/{scenario_id}").status_code == 404

View file

@ -0,0 +1,448 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import UUID
import pytest
from fastapi.testclient import TestClient
from app.api import auth
from app.api.http import scenario_submissions
from app.api.http.scenario_submissions import reset_demo_submissions
from app.config import get_settings
from app.db.models import AuditLog, Group, Scenario, ScenarioSubmission, Trainee
from app.domain import ekp
from app.domain.events import Exercise, SessionMode
from app.main import app
from app.scenarios import store
from app.session.dds import prepare_card
from app.session.state import SessionState
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
store.reset_demo_drafts()
reset_demo_submissions()
try:
with TestClient(app) as test_client:
yield test_client
finally:
get_settings.cache_clear()
def _login(client: TestClient, role: str) -> None:
response = client.post("/api/auth/dev-token", params={"role": role})
assert response.status_code == 200, response.text
def _student_kio(
description="В мастерской виден дым из повреждённого оборудования.",
address="Москва, учебная улица, дом 10",
):
source = store.get("t01-1-fire-container")
assert source is not None
return {
"caller_name": "Учебный заявитель",
"caller_contact": "+7 900 000-00-00",
"address": address,
"description": description,
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
"signs": source.signs,
"incident_type": "fire",
"dds": source.ground_truth.dds.value,
"victims_count": 0,
"fire": {"object_kind": "оборудование", "fire_nature": "задымление"},
}
def test_trainee_scenario_catalog_and_detail_only_expose_safe_self_practice_fields(client):
_login(client, "trainee")
listed = client.get("/api/scenarios").json()
item = next(row for row in listed if row["id"] == "t01-1-fire-container")
assert set(item) == {"id", "title", "level", "modes"}
assert "self" in item["modes"]
detail = client.get("/api/scenarios/t01-1-fire-container")
assert detail.status_code == 200
assert set(detail.json()) == {"id", "title", "level", "modes"}
assert client.get("/api/scenarios/t01-1-fire-container").json().get("ground_truth") is None
def test_student_submission_is_moderated_then_enters_dds_bank(client):
_login(client, "trainee")
created = client.post(
"/api/scenario-submissions",
json={
"title": "Пожар в мастерской",
"level": "L2",
"kio": _student_kio(
"В мастерской на первом этаже виден дым, люди вышли наружу.",
"Москва, улица Примерная, дом 12",
),
},
)
assert created.status_code == 201, created.text
submission_id = created.json()["id"]
assert created.json()["status"] == "pending"
assert created.json()["scenario_id"] is None
assert client.get("/api/scenarios").status_code == 200
assert not any("student-created" in item.get("topics", [])
for item in client.get("/api/scenarios").json())
# Курсанту разрешено видеть своё предложение, но не публиковать его.
own = client.get("/api/scenario-submissions").json()
assert [item["id"] for item in own] == [submission_id]
denied = client.post(
f"/api/scenario-submissions/{submission_id}/review",
json={"decision": "approve"},
)
assert denied.status_code == 403
client.post("/api/auth/logout")
_login(client, "instructor")
pending = client.get("/api/scenario-submissions").json()
assert pending[0]["title"] == "Пожар в мастерской"
approved = client.post(
f"/api/scenario-submissions/{submission_id}/review",
json={"decision": "approve", "comment": "Факты проверены."},
)
assert approved.status_code == 200, approved.text
body = approved.json()
assert body["status"] == "approved"
assert body["scenario_id"].startswith("student-")
scenario = store.get(body["scenario_id"])
assert scenario is not None
assert scenario.ground_truth.address == "Москва, улица Примерная, дом 12"
assert scenario.ground_truth.victims == 0
assert "student-created" in scenario.topics
listed = client.get("/api/scenarios").json()
published = next(item for item in listed if item["id"] == scenario.id)
assert published["source"] == "trainee"
assert published["outcome"] == "card"
assert published["dds"] == "01"
repeated = client.post(
f"/api/scenario-submissions/{submission_id}/review",
json={"decision": "approve"},
)
assert repeated.status_code == 409
def test_submitted_kio_is_kept_intact_and_becomes_the_dds_card_after_approval(client):
source = store.get("t01-1-fire-container")
assert source is not None
_login(client, "trainee")
created = client.post(
"/api/scenario-submissions",
json={
"title": "КИО курсанта: контейнер во дворе",
"level": "L2",
"kio": {
"caller_name": "Учебный заявитель",
"caller_contact": "+7 900 000-00-00",
"address": "Москва, учебная улица, дом 10",
"description": "Во дворе открыто горит мусорный контейнер.",
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
"signs": source.signs,
"incident_type": "fire",
"dds": source.ground_truth.dds.value,
"victims_count": 0,
"fire": {
"object_kind": "мусорный контейнер",
"fire_nature": "открытое пламя",
},
},
},
)
assert created.status_code == 201, created.text
body = created.json()
assert body["status"] == "pending"
assert body["kio"]["caller_number"] is None
assert body["kio"]["caller_name"] == "Учебный заявитель"
assert not any("student-created" in item.get("topics", [])
for item in client.get("/api/scenarios").json())
client.post("/api/auth/logout")
_login(client, "instructor")
approved = client.post(
f"/api/scenario-submissions/{body['id']}/review",
json={"decision": "approve", "comment": "КИО проверена."},
)
assert approved.status_code == 200, approved.text
scenario = store.get(approved.json()["scenario_id"])
assert scenario is not None
assert scenario.student_card is not None
assert scenario.student_card.address == "Москва, учебная улица, дом 10"
assert scenario.student_card.caller_name == "Учебный заявитель"
assert scenario.student_card.victims_count == 0
assert scenario.student_card.fire.object_kind == "мусорный контейнер"
assert scenario.student_card.signs == source.signs
assert "moderated-kio" in scenario.topics
state = SessionState(
session_id=UUID("00000000-0000-4000-8000-000000000701"),
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
)
prepare_card(state, scenario)
assert state.dispatched_card is not None
assert state.dispatched_card.address == scenario.student_card.address
assert state.dispatched_card.caller_name == "Учебный заявитель"
assert state.dispatched_card.fire.object_kind == "мусорный контейнер"
assert any(
item["id"] == scenario.id and item["source"] == "trainee"
for item in client.get("/api/scenarios").json()
)
def test_database_submission_path_persists_kio_and_publishes_on_approval(
client,
monkeypatch,
):
source = store.get("t01-1-fire-container")
assert source is not None
trainee_id = UUID("00000000-0000-4000-8000-000000000112")
group_id = UUID("00000000-0000-4000-8000-000000000113")
class FakeDb:
submission = None
scenario_row = None
def __init__(self):
self.audit_rows = []
self.commit_rows = []
async def get(self, model, _key):
if model is Trainee:
return SimpleNamespace(id=trainee_id, group_id=group_id)
if model is Group:
return SimpleNamespace(id=group_id, owner_login="demo-instructor")
raise AssertionError(f"unexpected model: {model}")
def add(self, row):
if isinstance(row, ScenarioSubmission):
self.submission = row
row.status = "pending"
row.created_at = datetime.now(UTC)
elif isinstance(row, Scenario):
self.scenario_row = row
elif isinstance(row, AuditLog):
self.audit_rows.append(row)
else:
raise AssertionError(f"unexpected row: {type(row)}")
async def commit(self):
self.commit_rows.append(tuple(self.audit_rows))
async def scalar(self, _query):
return self.submission
fake_db = FakeDb()
async def session_override():
yield fake_db
monkeypatch.setitem(
app.dependency_overrides,
scenario_submissions.submission_session,
session_override,
)
_login(client, "trainee")
created = client.post(
"/api/scenario-submissions",
json={
"title": "КИО в DB-пути",
"level": "L2",
"kio": {
"caller_name": "Учебный заявитель",
"address": "Москва, тестовая улица, дом 3",
"description": "Во дворе открыто горит мусорный контейнер.",
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
"signs": source.signs,
"incident_type": "fire",
"dds": source.ground_truth.dds.value,
"victims_count": 0,
"fire": {"object_kind": "мусорный контейнер"},
},
},
)
assert created.status_code == 201, created.text
assert fake_db.submission.kio["address"] == "Москва, тестовая улица, дом 3"
assert created.json()["status"] == "pending"
assert fake_db.commit_rows[0][0].action == "scenario.submission.create"
client.post("/api/auth/logout")
_login(client, "instructor")
approved = client.post(
f"/api/scenario-submissions/{created.json()['id']}/review",
json={"decision": "approve", "comment": "Проверено."},
)
assert approved.status_code == 200, approved.text
assert fake_db.scenario_row.owner_login == "demo-instructor"
assert fake_db.commit_rows[1][-1].action == "scenario.submission.approve"
persisted_kio = fake_db.scenario_row.body["student_card"]
assert persisted_kio["address"] == "Москва, тестовая улица, дом 3"
assert approved.json()["status"] == "approved"
def test_rejection_requires_comment_and_returns_proposal_to_student(client, monkeypatch):
audit_rows = []
async def capture_audit(*args):
audit_rows.append(args)
monkeypatch.setattr(scenario_submissions, "audit", capture_audit)
_login(client, "trainee")
response = client.post(
"/api/scenario-submissions",
json={
"title": "Обстановка на объекте",
"level": "L1",
"kio": _student_kio(
"В помещении обнаружено повреждение инженерного оборудования."
),
},
)
submission_id = response.json()["id"]
client.post("/api/auth/logout")
_login(client, "instructor")
missing_reason = client.post(
f"/api/scenario-submissions/{submission_id}/review", json={"decision": "reject"}
)
assert missing_reason.status_code == 422
rejected = client.post(
f"/api/scenario-submissions/{submission_id}/review",
json={
"decision": "reject",
"comment": "Уточните место и наблюдаемые признаки.",
},
)
assert rejected.status_code == 200
assert rejected.json()["status"] == "rejected"
assert audit_rows[-1][-1] == "comment_chars=38"
assert "Уточните место" not in audit_rows[-1][-1]
client.post("/api/auth/logout")
_login(client, "trainee")
own = client.get("/api/scenario-submissions").json()
assert own[0]["status"] == "rejected"
assert own[0]["review_comment"] == "Уточните место и наблюдаемые признаки."
assert not any("student-created" in item.get("topics", [])
for item in client.get("/api/scenarios").json())
def test_submission_validation_rejects_short_description(client):
_login(client, "trainee")
response = client.post(
"/api/scenario-submissions",
json={
"title": "Короткая заявка",
"level": "L1",
"kio": _student_kio("дым"),
},
)
assert response.status_code == 422
def test_submission_requires_structured_kio_not_legacy_free_text(client):
_login(client, "trainee")
response = client.post(
"/api/scenario-submissions",
json={
"title": "Только текст",
"level": "L1",
"incident_type": "fire",
"description": "В мастерской обнаружены дым и повреждение оборудования.",
"address": "Москва, учебная улица, дом 10",
},
)
assert response.status_code == 422
def test_submission_rejects_whitespace_only_title(client):
_login(client, "trainee")
response = client.post(
"/api/scenario-submissions",
json={"title": " ", "level": "L1", "kio": _student_kio()},
)
assert response.status_code == 422
def test_submission_uses_street_and_building_when_address_is_blank(client):
_login(client, "trainee")
kio = _student_kio()
kio.update({"address": " ", "street": "Учебная улица", "building": "12"})
response = client.post(
"/api/scenario-submissions",
json={"title": "Проверка адреса", "level": "L1", "kio": kio},
)
assert response.status_code == 201, response.text
assert response.json()["address"] == "Учебная улица 12"
@pytest.mark.parametrize(
("group_id", "group_owner", "detail"),
[
(None, None, "trainee_group_required_for_review"),
(
UUID("00000000-0000-4000-8000-000000000001"),
None,
"instructor_group_required_for_review",
),
],
)
def test_submission_requires_a_group_with_a_moderating_instructor(
client,
monkeypatch,
group_id,
group_owner,
detail,
):
trainee_id = UUID("00000000-0000-4000-8000-000000000112")
monkeypatch.setattr(
scenario_submissions,
"require",
lambda *_args, **_kwargs: auth.Principal(
login="student",
full_name="Курсант",
role=auth.Role.TRAINEE,
trainee_id=trainee_id,
),
)
class FakeDb:
async def get(self, model, _key):
if model.__name__ == "Trainee":
return SimpleNamespace(group_id=group_id)
return SimpleNamespace(owner_login=group_owner)
def add(self, _row):
raise AssertionError("proposal must not be stored without a moderator")
async def session_override():
yield FakeDb()
app.dependency_overrides[scenario_submissions.submission_session] = session_override
try:
response = client.post(
"/api/scenario-submissions",
json={
"title": "Пожар в мастерской",
"level": "L1",
"kio": _student_kio(
"В мастерской обнаружены дым и повреждение оборудования."
),
},
)
finally:
app.dependency_overrides.pop(scenario_submissions.submission_session, None)
assert response.status_code == 409
assert response.json()["detail"] == detail

View file

@ -68,12 +68,19 @@ def test_broken_yaml_names_the_file(tmp_path):
load_file(path, tmp_path)
def test_hidden_fact_without_approach_is_rejected(tmp_path):
body = VALID.replace(
@pytest.mark.parametrize("replace_text", [
(
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
' - { id: f_addr, value: "Ленина, 1", hidden: true, reveal_on: { question: q_addr } }',
)
with pytest.raises(ScenarioError, match="hidden требует"):
),
(
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr, approach: "проявил эмпатию" } }',
),
])
def test_llm_controlled_fact_disclosure_is_rejected(tmp_path, replace_text):
body = VALID.replace(*replace_text)
with pytest.raises(ScenarioError, match="Extra inputs are not permitted"):
load_file(write(tmp_path, body), tmp_path)
@ -83,6 +90,38 @@ def test_checklist_pointing_at_missing_fact_is_rejected(tmp_path):
load_file(write(tmp_path, body), tmp_path)
def test_duplicate_fact_ids_are_rejected_before_they_can_change_ground_truth(tmp_path):
fact = ' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }'
body = VALID.replace(fact, fact + '\n - { id: f_addr, value: "Ленина, 2", reveal_on: { question: q_addr } }')
with pytest.raises(ScenarioError, match="id фактов должны быть уникальны"):
load_file(write(tmp_path, body), tmp_path)
def test_duplicate_local_checklist_ids_are_not_silently_merged(tmp_path):
item = ' - { id: q_addr, question: "Адрес?", fact: f_addr }'
body = VALID.replace("checklist:\n" + item, "checklist:\n" + item + "\n" + item)
with pytest.raises(ScenarioError, match="повторяются id пунктов чек-листа"):
load_file(write(tmp_path, body), tmp_path)
@pytest.mark.parametrize(("fields", "message"), [
('[address, coordinates]', "отсутствуют в форме КИО"),
('[card_id]', "заполняются системой"),
('[registered_at]', "заполняются системой"),
('[address, address]', "повторяются поля"),
])
def test_required_fields_must_be_unique_and_fillable_in_kio_form(tmp_path, fields, message):
body = VALID + f"\nrequired_fields: {fields}\n"
with pytest.raises(ScenarioError, match=message):
load_file(write(tmp_path, body), tmp_path)
def test_non_card_outcomes_cannot_require_kio_fields(tmp_path):
body = VALID + "\noutcome: consultation\nrequired_fields: [address]\n"
with pytest.raises(ScenarioError, match="required_fields должны быть пустыми"):
load_file(write(tmp_path, body), tmp_path)
def test_typo_in_field_name_is_rejected(tmp_path):
"""Схема строгая: опечатка должна падать на старте, а не игнорироваться."""
body = VALID.replace("level: L1", "level: L1\nfirst_lines: 'опечатка'")

View file

@ -1,21 +1,52 @@
"""HTTP-ссылки на занятие не дают курсанту чужую карточку или чек-лист."""
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException, Request
from app.db import repo
from app.db.models import Session, Utterance
from app.db.repo import SessionNodeConflict, ensure_session
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import Exercise
from app.api.ws import call as call_ws
from app.api.ws import observe as observe_ws
from app.api.ws import station as station_ws
from app.domain.events import Exercise, SessionMode
from app.domain.roles import Role
from app.session.checkpoint import dump_state
from app.session.hub import SessionHub
from app.session.state import SessionState
from app.session.journal import DbJournal
def request() -> Request:
return Request({"type": "http", "method": "GET", "path": "/", "headers": []})
@pytest.mark.asyncio
async def test_journal_write_failure_does_not_log_user_text(caplog):
private_text = "private caller address and medical detail"
class FakeDb:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def fail_write(_db, text):
raise RuntimeError(text)
journal = DbJournal(lambda: FakeDb())
await journal._write(fail_write, private_text)
assert private_text not in caplog.text
assert "RuntimeError" in caplog.text
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_session(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
@ -44,6 +75,69 @@ async def test_instructor_cannot_read_foreign_session(monkeypatch):
assert error.value.status_code == 404
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", [sessions.report, sessions.report_csv, sessions.report_pdf])
async def test_instructor_cannot_read_or_export_foreign_archived_report(monkeypatch, endpoint):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request: who)
monkeypatch.setattr(sessions.hub, "get", lambda _session_id: None)
async def row(_db, _session_id):
return SimpleNamespace(owner_login="teacher-b", trainee_id=uuid4())
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await endpoint(uuid4(), request(), db=object())
assert error.value.status_code == 404
@pytest.mark.asyncio
async def test_trainee_cannot_export_foreign_archived_report(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
monkeypatch.setattr(sessions, "require", lambda _request: who)
monkeypatch.setattr(sessions.hub, "get", lambda _session_id: None)
async def row(_db, _session_id):
return SimpleNamespace(owner_login="teacher-a", trainee_id=uuid4())
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await sessions.report_pdf(uuid4(), request(), db=object())
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_instructor_cannot_download_foreign_recording(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, *_roles: who)
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(demo_no_db=False))
async def row(_db, _session_id):
return SimpleNamespace(
owner_login="teacher-b", trainee_id=uuid4(), ended_at=None,
)
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await sessions.recording(uuid4(), request(), db=object())
assert error.value.status_code == 404
@pytest.mark.asyncio
async def test_instructor_cannot_override_foreign_archived_score(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, *_roles: who)
async def row(_db, _session_id):
return SimpleNamespace(owner_login="teacher-b")
monkeypatch.setattr(sessions.repo, "get_session", row)
body = sessions.ScoreOverride(score_final=80, comment="Проверка")
with pytest.raises(HTTPException) as error:
await sessions.override(uuid4(), body, request(), db=object())
assert error.value.status_code == 404
@pytest.mark.asyncio
async def test_instructor_history_is_scoped_to_owner(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
@ -59,6 +153,251 @@ async def test_instructor_history_is_scoped_to_owner(monkeypatch):
assert seen["owner_login"] == "teacher-a"
@pytest.mark.asyncio
async def test_demo_history_lists_only_owned_sessions_and_applies_filters(monkeypatch):
owner_trainee = uuid4()
other_trainee = uuid4()
older = datetime(2026, 9, 20, tzinfo=UTC)
newer = datetime(2026, 9, 25, tzinfo=UTC)
demo_hub = SessionHub()
for session_id, owner, trainee_id, mode, started, ended in [
(uuid4(), "teacher-a", owner_trainee, SessionMode.TRAINING, older, newer),
(uuid4(), "teacher-b", owner_trainee, SessionMode.TRAINING, newer, newer),
(uuid4(), "teacher-a", other_trainee, SessionMode.EXAM, newer, newer),
]:
demo_hub.register(SimpleNamespace(
session_id=session_id, scenario_id="ticket-demo", mode=mode, attempt=1,
trainee_id=trainee_id, owner_login=owner, lease_fenced=False,
started_at=started, ended_at=ended, end_reason=None,
))
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request: who)
monkeypatch.setattr(sessions, "hub", demo_hub)
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(demo_no_db=True))
rows = await sessions.listing(
request(), trainee=owner_trainee, mode=SessionMode.TRAINING,
since=None, limit=100, db=None,
)
assert len(rows) == 1
assert rows[0].trainee_id == owner_trainee
assert rows[0].mode is SessionMode.TRAINING
assert rows[0].ended_at == newer
# Demo memory has no group membership records and must not ignore a group filter.
assert await sessions.listing(request(), group=uuid4(), since=None, limit=100, db=None) == []
trainee = Principal(
login="learner", full_name="Курсант", role=Role.TRAINEE, trainee_id=owner_trainee,
)
monkeypatch.setattr(sessions, "require", lambda _request: trainee)
trainee_rows = await sessions.listing(
request(), trainee=other_trainee, since=None, limit=100, db=None,
)
assert trainee_rows
assert {row.trainee_id for row in trainee_rows} == {owner_trainee}
unlinked = Principal(login="unlinked", full_name="Без профиля", role=Role.TRAINEE)
monkeypatch.setattr(sessions, "require", lambda _request: unlinked)
with pytest.raises(HTTPException) as error:
await sessions.listing(request(), since=None, limit=100, db=None)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_dds_history_returns_archived_cards_only_for_trainee(monkeypatch):
trainee_id = uuid4()
session_id = uuid4()
card_id = uuid4()
ended_at = datetime.now(UTC)
who = Principal(
login="trainee-a", full_name="Курсант A", role=Role.TRAINEE,
trainee_id=trainee_id,
)
monkeypatch.setattr(sessions, "require", lambda *_args, **_kwargs: who)
audit_events = []
async def capture_audit(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(sessions, "audit_required", capture_audit)
class Rows:
def all(self):
return [(
SimpleNamespace(id=session_id, ended_at=ended_at),
SimpleNamespace(score_final=82.5, report={"full_report": {
"exercise": "dds",
"card_results": [{
"card_id": str(card_id), "scenario_id": "fire-apartment",
"score_auto": 80, "reply_text": "Бригада направлена",
"title": "Пожар", "address": "улица Лесная, 4",
"incident_type": "fire", "victims_count": 1,
"managed_service": "01", "recipient_services": ["01", "03"],
}],
}}),
)]
class Database:
statement = None
async def execute(self, statement):
self.statement = statement
return Rows()
db = Database()
result = await sessions.dds_history(request(), limit=200, db=db)
sql = str(db.statement.compile(compile_kwargs={"literal_binds": True}))
assert "sessions.trainee_id" in sql
assert trainee_id.hex in sql
assert len(result) == 1
assert result[0].session_id == session_id
assert result[0].card_id == card_id
assert result[0].address == "улица Лесная, 4"
assert result[0].score_final == 82.5
assert audit_events == [("trainee-a", "trainee", "dds.history.read", None, "cards=1")]
@pytest.mark.asyncio
async def test_dds_history_rejects_admin_role(monkeypatch):
who = Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
def require(*_args, **_kwargs):
raise HTTPException(status_code=403, detail="forbidden")
monkeypatch.setattr(sessions, "require", require)
with pytest.raises(HTTPException) as error:
await sessions.dds_history(request(), limit=200, db=None)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_live_registry_is_scoped_to_current_instructor(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
seen = {}
def active_sessions(owner_login):
seen["owner_login"] = owner_login
return []
monkeypatch.setattr(sessions.hub, "active_sessions", active_sessions)
assert await sessions.active(request(), db=None) == []
assert seen["owner_login"] == "teacher-a"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("ws_module", "handler_name"),
[
(observe_ws, "observe"),
(call_ws, "call"),
(station_ws, "station"),
],
)
async def test_instructor_cannot_join_foreign_live_session(monkeypatch, ws_module, handler_name):
session_id = uuid4()
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
state = SimpleNamespace(owner_login="teacher-b")
monkeypatch.setattr(ws_module, "websocket_origin_allowed", lambda _ws: True)
monkeypatch.setattr(ws_module, "principal_of", lambda _ws: who)
monkeypatch.setattr(ws_module.hub, "get", lambda _session_id: state)
class Socket:
def __init__(self):
self.accepted = False
self.closed = False
self.messages = []
async def accept(self):
self.accepted = True
async def send_text(self, message):
self.messages.append(message)
async def close(self):
self.closed = True
socket = Socket()
await getattr(ws_module, handler_name)(socket, session_id)
assert socket.accepted and socket.closed
assert len(socket.messages) == 1
assert '"code":"session_not_found"' in socket.messages[0]
assert "teacher-b" not in socket.messages[0]
@pytest.mark.asyncio
async def test_live_registry_includes_owned_checkpoints_from_other_nodes(monkeypatch):
owner = "teacher-a"
who = Principal(login=owner, full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
monkeypatch.setattr(sessions.hub, "active_sessions", lambda _owner: [])
state = SessionState(
session_id=uuid4(),
scenario_id="remote-case",
scenario_title="Удалённое занятие",
level="L2",
mode=SessionMode.TRAINING,
owner_login=owner,
exercise=Exercise.DDS,
trainee_name="Курсант",
)
state.started_at = datetime.now(UTC)
row = SimpleNamespace(
id=state.session_id,
owner_login=owner,
ended_at=None,
live_state=dump_state(state),
checkpoint_at=datetime.now(UTC),
)
class Rows:
def all(self):
return [row]
class FakeDb:
async def scalars(self, _query):
return Rows()
result = await sessions.active(request(), db=FakeDb())
assert len(result) == 1
assert result[0].session_id == state.session_id
assert result[0].trainee_name == "Курсант"
assert result[0].scenario_id == "remote-case"
@pytest.mark.asyncio
async def test_new_http_session_is_assigned_to_backend_node_at_creation(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(backend_node_id="node-a"))
monkeypatch.setattr(sessions.repo, "ensure_group", lambda *_args, **_kwargs: None)
seen = {}
async def create_session(_db, **kwargs):
seen.update(kwargs)
now = datetime.now(UTC)
return SimpleNamespace(
id=uuid4(), scenario_id=kwargs["scenario_id"], mode=kwargs["mode"],
attempt=1, trainee_id=None, group_id=None,
started_at=None, ended_at=None, end_reason=None, created_at=now,
)
async def audit(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions.repo, "create_session", create_session)
monkeypatch.setattr(sessions, "audit", audit)
result = await sessions.create(
sessions.SessionCreate(scenario_id="case", mode=SessionMode.TRAINING),
request(), db=object(),
)
assert result.scenario_id == "case"
assert seen["backend_node_id"] == "node-a"
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
@ -91,3 +430,140 @@ async def test_trainee_without_profile_cannot_list_everyones_sessions(monkeypatc
with pytest.raises(HTTPException) as error:
await sessions.listing(request(), db=object())
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_existing_session_keeps_its_backend_owner():
existing = SimpleNamespace(
owner_login="teacher-a", backend_node_id="node-a"
)
class FakeDb:
async def scalar(self, _query):
return existing
with pytest.raises(SessionNodeConflict):
await ensure_session(
FakeDb(),
session_id=uuid4(),
scenario_id="case",
mode="training",
owner_login="teacher-a",
backend_node_id="node-b",
)
@pytest.mark.asyncio
async def test_unassigned_existing_session_is_claimed_once():
existing = SimpleNamespace(owner_login="teacher-a", backend_node_id=None)
class FakeDb:
commits = 0
async def scalar(self, _query):
return existing
async def commit(self):
self.commits += 1
db = FakeDb()
result = await ensure_session(
db,
session_id=uuid4(),
scenario_id="case",
mode="training",
owner_login="teacher-a",
backend_node_id="node-a",
)
assert result.backend_node_id == "node-a"
assert db.commits == 1
@pytest.mark.asyncio
async def test_journal_assigns_new_lesson_to_its_backend_node(monkeypatch):
seen = {}
trainee_id = uuid4()
row = SimpleNamespace(attempt=3, trainee_id=trainee_id)
async def ensure(_db, **kwargs):
seen.update(kwargs)
return row
monkeypatch.setattr(repo, "ensure_session", ensure)
class FakeDb:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def scalar(self, _query):
return "01"
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
result = await journal.start_lesson(
uuid4(), "case", "training", "Курсант", trainee_id,
owner_login="teacher-a",
)
assert result == (3, trainee_id, "01", 1)
assert seen["backend_node_id"] == "node-a"
assert callable(seen["before_commit"]), "session creation must carry its audit into commit"
@pytest.mark.asyncio
async def test_journal_restores_checkpoint_owned_by_this_node():
owner = "teacher-a"
state = SessionState(
session_id=uuid4(),
scenario_id="case",
scenario_title="Удалённая сессия",
level="L1",
mode=SessionMode.TRAINING,
owner_login=owner,
exercise=Exercise.DDS,
)
state.started_at = datetime.now(UTC)
row = SimpleNamespace(
id=state.session_id,
owner_login=owner,
backend_node_id="node-a",
backend_fencing_epoch=0,
backend_lease_until=None,
live_state=dump_state(state),
checkpoint_at=datetime.now(UTC),
)
class Rows:
def __init__(self, values):
self.values = values
def all(self):
return self.values
class FakeDb:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def execute(self, _statement):
return None
async def commit(self):
return None
async def scalars(self, statement):
entity = statement.column_descriptions[0]["entity"]
if entity is Utterance:
return Rows([])
if "backend_node_id IS NULL" in str(statement):
return Rows([])
return Rows([row])
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
restored = await journal.restore_active()
assert len(restored) == 1
assert restored[0].session_id == state.session_id
assert restored[0].owner_login == owner

View file

@ -1,18 +1,20 @@
"""Промежуточное состояние занятия переживает смену backend-процесса."""
import asyncio
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from app.domain.events import Exercise, LessonCriteria, SessionMode
from app.domain.events import CommandAck, CallStarted, Exercise, LessonCriteria, SessionMode
from app.domain.kio import KIO
from app.domain.statuses import PhoneCallPending, ServiceStatus
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.scoring.grammar import basic_check
from app.session.checkpoint import dump_state, load_state
from app.session.dds import deliver_due_cards, prepare_queue
from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
from app.session.state import SessionState, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -41,7 +43,7 @@ def dds_state() -> SessionState:
state.timers.limits[TimerCode.DDS_ACK] = 45_000
prepare_queue(state, state.dds_scenarios)
service = state.notified_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED, author="диспетчер")
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер")
state.crew_selected = state.crew_options()[0]
state.crew_assignments[service] = state.crew_selected
state.phone_pending = PhoneCallPending(
@ -56,6 +58,7 @@ def dds_state() -> SessionState:
def test_active_dds_session_round_trips_without_losing_work():
before = dds_state()
before.processed_station_commands = ["2a831a63-dbb0-4d9f-af5b-21a617520001"]
payload = dump_state(before)
restored = load_state(
payload,
@ -72,6 +75,7 @@ def test_active_dds_session_round_trips_without_losing_work():
assert restored.phone_pending == before.phone_pending
assert restored.reply_text == before.reply_text
assert restored.reply_grammar == before.reply_grammar
assert restored.processed_station_commands == before.processed_station_commands
assert restored.dds_scenarios[0].id == before.scenario_id
# Время простоя backend входит в норматив, а не обнуляет таймер.
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
@ -104,7 +108,7 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
prepare_queue(state, state.dds_scenarios)
first_id = state.dispatched_card.card_id
first_service = state.managed_services()[0]
state.set_service_status(first_service, ServiceStatus.ACCEPTED)
state.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
state.on_event("card.ack")
second_id = state.dds_live_cards[1].card_id
assert state.activate_dds_card(second_id)
@ -149,7 +153,7 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state():
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
first_id = state.dds_live_cards[0].card_id
service = state.managed_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED)
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
state.capture_active_dds()
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
@ -165,3 +169,92 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state():
assert len(restored.dds_live_cards) == 2
assert restored.dds_next_scenario_index == 2
assert restored.dds_next_arrival_at is not None
def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
second = load_file(LIBRARY / "tickets" / "t01-1-fire-container.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING,
exercise=Exercise.CARD, handoff_to_dds=True, scenario=first,
pending_dds_scenarios=[second],
)
state.kio = KIO(address="улица Ленина, 14", description="горит балкон")
state.dispatch()
prepare_handoff_queue(state, state.pending_dds_scenarios)
restored = load_state(dump_state(state), now_utc())
assert restored.operator_kio.address == "улица Ленина, 14"
assert restored.operator_scenario.id == first.id
assert restored.dds_scenarios[0].id == first.id
assert restored.dds_scenarios[1].id == second.id
assert len(restored.dds_live_cards) == 2
assert restored.dds_active_card_id == state.dds_active_card_id
def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels():
class BrokenJournal:
async def checkpoint(self, _state):
raise OSError("simulated database partition")
local_hub = SessionHub(journal=BrokenJournal())
state = dds_state()
local_hub.register(state)
with local_hub.observer(state.session_id) as observers, \
local_hub.trainee(state.session_id) as trainee, \
local_hub.station(state.session_id) as station:
async def failing_transition():
async with local_hub.durable_transition(state.session_id):
local_hub.to_trainee(
state.session_id, CallStarted(started_at=now_utc())
)
assert trainee.empty(), "success event escaped before durable checkpoint"
with pytest.raises(OSError, match="partition"):
asyncio.run(failing_transition())
assert state.lease_fenced
assert local_hub.get(state.session_id) is None
for queue in (observers, trainee, station):
event = queue.get_nowait()
assert event.message == LEASE_FENCED_MESSAGE
assert queue.empty(), "uncommitted success event leaked during fencing"
def test_durable_transition_publishes_event_only_after_checkpoint_commit():
class CommitJournal:
committed = False
async def checkpoint(self, _state):
await asyncio.sleep(0)
self.committed = True
journal = CommitJournal()
local_hub = SessionHub(journal=journal)
state = dds_state()
local_hub.register(state)
command_id = uuid4()
with local_hub.trainee(state.session_id) as trainee, \
local_hub.station(state.session_id) as station:
async def transition():
async with local_hub.durable_transition(state.session_id):
local_hub.to_trainee(
state.session_id, CallStarted(started_at=now_utc())
)
local_hub.to_station(
state.session_id, CommandAck(command_id=command_id)
)
assert trainee.empty()
assert station.empty()
assert journal.committed
asyncio.run(transition())
event = trainee.get_nowait()
assert isinstance(event, CallStarted)
ack = station.get_nowait()
assert isinstance(ack, CommandAck)
assert ack.command_id == command_id

View file

@ -0,0 +1,59 @@
import subprocess
import sys
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[2] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from sip_recording_cleanup import remove_smoke_recordings # noqa: E402
def test_cleanup_removes_only_new_wav_basenames_from_compose_volume(tmp_path):
calls = []
def run(command, **kwargs):
calls.append((command, kwargs))
return subprocess.CompletedProcess(command, 0, stdout="")
assert remove_smoke_recordings(
tmp_path,
{"20260926-120000-6101-6102-abc.wav", "../keep.wav", "old.txt"},
runner=run,
)
command, options = calls[0]
assert command[-4:] == [
"rm", "-f", "--", "/recordings/20260926-120000-6101-6102-abc.wav",
]
assert "../keep.wav" not in command
assert "old.txt" not in command
assert options["cwd"] == tmp_path
assert len(calls) == 2
assert calls[1][0][-8:] == [
"find", "/recordings", "-maxdepth", "1", "-type", "f", "-name", "*.wav",
]
def test_cleanup_does_not_run_compose_for_empty_or_unsafe_names(tmp_path):
def unexpected_run(*_args, **_kwargs):
raise AssertionError("no deletion command should be needed")
assert remove_smoke_recordings(tmp_path, {"../outside.wav", "not-a-recording.txt"}, runner=unexpected_run)
def test_cleanup_reports_compose_failure(tmp_path):
def fail(command, **_kwargs):
return subprocess.CompletedProcess(command, 1)
assert not remove_smoke_recordings(tmp_path, {"new.wav"}, runner=fail)
def test_cleanup_fails_if_exact_smoke_file_still_exists(tmp_path):
calls = []
def run(command, **_kwargs):
calls.append(command)
output = "/recordings/new.wav\n" if len(calls) == 2 else ""
return subprocess.CompletedProcess(command, 0, stdout=output)
assert not remove_smoke_recordings(tmp_path, {"new.wav"}, runner=run)

View file

@ -56,8 +56,7 @@ SCENARIO = Scenario.model_validate(
{
"id": "f_secret",
"value": "муж курил на балконе",
"hidden": True,
"reveal_on": {"approach": "объяснил, что вину никто не ищет"},
"reveal_on": {"question": "q_cause"},
},
],
"checklist": [
@ -100,14 +99,13 @@ def test_two_questions_in_one_line_both_count(slots):
assert set(turn.revealed) == {"f_address", "f_people"}
def test_hidden_fact_is_not_given_for_a_direct_question(slots):
"""Скрывающий звонящий уклоняется от прямого вопроса: факт раскрывается
только подходом, иначе механика L3 превращается в обычный чек-лист."""
turn = slots.hear("Из-за чего начался пожар?")
assert "f_secret" not in turn.revealed
assert "q_cause" in slots.asked, "вопрос задан — это должно быть видно в разборе"
def test_fact_is_revealed_only_by_its_matching_question(slots):
unrelated = slots.hear("Где находится квартира?")
assert "f_secret" not in unrelated.revealed
assert slots.reveal_by_approach("f_secret")
cause = slots.hear("Из-за чего начался пожар?")
assert cause.revealed == ["f_secret"]
assert "q_cause" in slots.asked
assert "f_secret" in [fact.id for fact in slots.revealed_facts()]

View file

@ -6,12 +6,17 @@ from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
def client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
@ -97,7 +102,7 @@ def test_acknowledgement_stops_the_four_second_norm(client):
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
station.send_json({"type": "card.ack"})
station.send_json({"type": "card.ack", "comment": "Подтверждение приёма зафиксировано по докладу старшего группы."})
measured = wait_for(lambda: state.timers.measured_ms(TimerCode.DDS_ACK) is not None)
assert measured
finally:
@ -173,8 +178,25 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client):
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
error = read_until(station, "error")
assert "комментар" in error["message"]
assert hub.get(session_id).status_log == []
command_id = str(uuid4())
accepted = {
"type": "card.status", "service": "Служба 101", "status": "accepted",
"comment": "Старший группы подтвердил приём карточки.",
"_command_id": command_id,
}
station.send_json(accepted)
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
assert read_until(station, "command.ack")["command_id"] == command_id
# Simulate a retry after the client lost the ACK: the committed ID
# returns another ACK but does not append a second status action.
station.send_json(accepted)
assert read_until(station, "command.ack")["command_id"] == command_id
runtime = hub.get(session_id)
assert sum(item.service == "Служба 101" for item in runtime.status_log) == 1
assert "responding" in state["snapshot"]["available"]["Служба 101"]
assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
finally:
@ -186,7 +208,8 @@ def test_out_of_order_status_is_rejected_with_a_reason(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived"})
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived",
"comment": "Бригада доложила старшему о прибытии."})
error = read_until(station, "error")
assert "Принята" in error["message"]
assert hub.get(session_id).status_log == []
@ -211,7 +234,7 @@ def test_ack_button_still_works_and_counts_as_accepted(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.ack"})
station.send_json({"type": "card.ack", "comment": "Старший группы подтвердил приём карточки."})
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
finally:

View file

@ -34,22 +34,22 @@ def mark(service: str, status: ServiceStatus, comment: str = "", minute: int = 0
def test_only_primary_statuses_are_available_at_first():
check([], "Служба 101", ServiceStatus.ACCEPTED, "")
check([], "Служба 101", ServiceStatus.ACCEPTED, "Принято в работу")
check([], "Служба 101", ServiceStatus.DECLINED, "не обслуживаем, передано в УК")
with pytest.raises(StatusError):
check([], "Служба 101", ServiceStatus.ARRIVED, "")
def test_accepted_opens_the_rest():
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
check(log, "Служба 101", ServiceStatus.COMPLETED, "")
log = [mark("Служба 101", ServiceStatus.ACCEPTED, "Принято в работу")]
check(log, "Служба 101", ServiceStatus.RESPONDING, "Бригада выехала")
check(log, "Служба 101", ServiceStatus.COMPLETED, "Работы завершены")
def test_declined_leads_only_back_to_accepted():
"""Служба может передумать, но не может отказаться дважды по-разному."""
log = [mark("Служба 101", ServiceStatus.DECLINED, "не наш адрес")]
check(log, "Служба 101", ServiceStatus.ACCEPTED, "")
check(log, "Служба 101", ServiceStatus.ACCEPTED, "Повторно принято")
with pytest.raises(StatusError):
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
@ -113,9 +113,10 @@ def test_alarming_statuses_are_the_three_from_the_memo():
# ── ошибки диспетчера ──
def _codes(entries, services=SERVICES, elapsed=45_000):
def _codes(entries, services=SERVICES, elapsed=45_000, crew_assignments=None):
findings = evaluate_dispatcher(
entries=entries, services=services, deadline_ms=30_000, elapsed_ms=elapsed
entries=entries, services=services, crew_assignments=crew_assignments,
deadline_ms=30_000, elapsed_ms=elapsed
)
return [finding.code for finding in findings]
@ -156,13 +157,17 @@ def test_clean_work_has_no_findings():
log = []
for service in SERVICES:
log += [
mark(service, ServiceStatus.ACCEPTED),
mark(service, ServiceStatus.RESPONDING, minute=1),
mark(service, ServiceStatus.ARRIVED, minute=4),
mark(service, ServiceStatus.WORKING, minute=5),
mark(service, ServiceStatus.COMPLETED, minute=20),
mark(service, ServiceStatus.ACCEPTED, "Основание: доклад бригады.\nСведения: принято."),
mark(service, ServiceStatus.RESPONDING, "Основание: доклад бригады.\nСведения: выезд.", minute=1),
mark(service, ServiceStatus.ARRIVED, "Основание: доклад бригады.\nСведения: прибытие.", minute=4),
mark(service, ServiceStatus.WORKING, "Основание: доклад бригады.\nСведения: начало работ.", minute=5),
mark(service, ServiceStatus.COMPLETED, "Основание: доклад бригады.\nСведения: завершение работ.", minute=20),
]
assert _codes(log) == []
assert _codes(
log,
elapsed=10_000,
crew_assignments={service: "дежурная бригада" for service in SERVICES},
) == []
def test_every_finding_carries_its_reason():

View file

@ -0,0 +1,25 @@
import pytest
from app.domain.events import Metric
from app.scoring.competency import radar
from app.scoring.gost import GostResult
from app.scoring.timing import time_credit
@pytest.mark.parametrize(
("elapsed_ms", "expected"),
[(0, 1.0), (90_000, 0.75), (180_000, 0.5), (360_000, 0.0), (400_000, 0.0),
(None, 0.0)],
)
def test_time_credit_reduces_linearly_against_the_norm(elapsed_ms, expected):
assert time_credit(elapsed_ms, 180_000) == expected
def test_fractional_timing_credit_affects_total_and_competency_scores():
metric = Metric(
key="card_fill_time", title="Время заполнения карточки", fact="90 с",
norm="180 с", passed=True, weight=2.0, credit=0.75,
)
result = GostResult(metrics=[metric])
assert result.score == 75.0
assert radar([metric])[0].value == 0.75

View file

@ -4,21 +4,28 @@
Проверяются пункты приёмки карточки lct-05, а не отдельные функции.
"""
import asyncio
import contextlib
import time
import wave
from uuid import uuid4
import numpy as np
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.main import app
from app.scenarios import store
from app.session.hub import hub
@pytest.fixture
def client():
def client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
@ -225,6 +232,37 @@ def test_instructor_cannot_touch_the_card(client):
assert "fields" not in fields and "kio" not in fields
def test_control_contract_contains_only_handled_commands():
"""Не рекламировать команду WebSocket, у которой нет ветки обработчика."""
import ast
from pathlib import Path
from app.domain.events import InstructorToServer
from typing import get_args
union = get_args(get_args(InstructorToServer)[0])
commands = {model.model_fields["type"].default for model in union}
source = Path(__file__).parents[1] / "app" / "api" / "ws" / "control.py"
tree = ast.parse(source.read_text(encoding="utf-8"))
control_matches = [
node for node in ast.walk(tree)
if isinstance(node, ast.Match)
and isinstance(node.subject, ast.Attribute)
and isinstance(node.subject.value, ast.Name)
and node.subject.value.id == "event"
and node.subject.attr == "type"
]
assert len(control_matches) == 1, "не удалось однозначно найти dispatch control-событий"
handled = {
case.pattern.value.value
for case in control_matches[0].cases
if isinstance(case.pattern, ast.MatchValue)
and isinstance(case.pattern.value, ast.Constant)
and isinstance(case.pattern.value.value, str)
}
assert commands == handled
def test_call_socket_refuses_session_that_was_not_started(client):
with client.websocket_connect(f"/ws/call/{uuid4()}") as trainee:
message = trainee.receive_json()
@ -261,10 +299,31 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
from app.api.ws import call as call_api
from app.voice.recording import CallRecorder
# Exercise the production audio callback without loading models: caller
# audio uses the same send_audio path whether it came from TTS or a test.
caller_pcm = (2000).to_bytes(2, "little", signed=True) * 480
class FakeVoice:
def __init__(self, *, send_audio, **_kwargs):
self.send_audio = send_audio
self.speak_count = 0
def speak(self, *_args):
self.speak_count += 1
asyncio.get_running_loop().call_later(0.01, self.send_audio, caller_pcm)
def feed(self, _pcm):
return None
async def close(self):
return None
monkeypatch.setattr(
call_api, "start_recording",
lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"),
)
monkeypatch.setattr(call_api, "get_voice_models", lambda: object())
monkeypatch.setattr(call_api, "VoiceSession", FakeVoice)
with lesson(client) as (session_id, _):
state = hub.get(session_id)
path = tmp_path / f"{session_id}.wav"
@ -272,7 +331,34 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
read_until(trainee, "call.incoming")
trainee.send_json({"type": "call.answer"})
read_until(trainee, "call.started")
wait_for(lambda: state.recorder and len(state.recorder._segments) >= 1)
for _ in range(10):
outgoing = trainee.receive()
if outgoing.get("bytes") is not None:
assert outgoing["bytes"] == caller_pcm
break
else:
raise AssertionError("TTS-реплика звонящего не пришла по WebSocket")
first_started_at = state.started_at
trainee.send_json({"type": "call.answer"})
read_until(trainee, "call.started")
assert state.started_at == first_started_at, "повторное подключение перезапустило таймер"
assert state.voice.speak_count == 1, "повторное подключение заново проиграло вводную"
trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320)
wait_for(lambda: len(state.recorder._segments) >= 2)
# Drop process-local runtime objects but leave the durable call journal,
# as if the backend process had been killed and restored from PostgreSQL.
state.recorder._journal.close()
state.recorder = None
state.voice = None
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "call.incoming")
read_until(trainee, "call.started")
assert len(state.recorder._segments) == 2
assert state.voice.speak_count == 0, "после восстановления повторилась первая реплика"
trainee.send_bytes((3000).to_bytes(2, "little", signed=True) * 320)
wait_for(lambda: len(state.recorder._segments) >= 3)
trainee.send_json({"type": "call.hangup"})
read_until(trainee, "call.ended")
wait_for(path.is_file)
@ -280,7 +366,12 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
assert state.recording_path == str(path)
with wave.open(str(path), "rb") as source:
assert source.getframerate() == 16_000
assert source.getnframes() >= 320
samples = source.readframes(source.getnframes())
assert source.getnframes() >= 640
decoded = np.frombuffer(samples, dtype="<i2")
assert 1000 in decoded, "в записи потерян микрофонный звук оператора"
assert 2000 in decoded, "в записи потерян звук звонящего"
assert 3000 in decoded, "после восстановления потерян микрофонный звук оператора"
def test_score_waits_for_self_assessment(client):
@ -378,18 +469,42 @@ def test_report_shows_missed_questions_and_self_assessment_gap(client):
assert report["hints_used"], "использованные подсказки попадают в разбор"
def test_instructor_correction_keeps_the_automatic_score(client):
with lesson(client) as (session_id, _):
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: hub.get(session_id).score is not None)
def test_instructor_correction_keeps_the_automatic_score(client, postgres_access):
from app.db.base import get_sessionmaker
from app.session.journal import DbJournal
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
corrected = client.patch(
f"/api/sessions/{session_id}/report",
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
).json()
# Unlike the websocket-only cases above, this regression exercises the
# durable HTTP correction endpoint against a real PostgreSQL score row.
journal = DbJournal(get_sessionmaker())
try:
with lesson(client) as (session_id, _):
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: hub.get(session_id).score is not None)
# The websocket-only fixture deliberately disables journalling;
# persist its computed result before testing the HTTP override.
state = hub.get(session_id)
client.portal.call(
journal.start_lesson,
session_id,
state.scenario_id,
state.mode.value,
state.trainee_name,
state.trainee_id,
"dev",
)
client.portal.call(journal.score, session_id, state.score["score_auto"], state.score)
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
response = client.patch(
f"/api/sessions/{session_id}/report",
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
)
assert response.status_code == 200, response.text
corrected = response.json()
finally:
hub.journal = None
assert corrected["score_final"] == 80.0
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
@ -423,6 +538,32 @@ def test_ws_score_override_rejects_other_session_and_invalid_value(client):
assert state.score["overridden_by"] == "dev"
def test_ws_score_override_is_not_applied_when_atomic_persistence_fails(client):
class FailedJournal:
async def score_override(self, *_args):
return False
async def checkpoint(self, *_args):
return None
with lesson(client) as (session_id, control):
state = hub.get(session_id)
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
auto = state.score["score_auto"]
hub.journal = FailedJournal()
control.send_json({
"type": "score.override", "session_id": str(session_id),
"verdict": "85", "comment": "manual review",
})
time.sleep(0.1)
assert state.score["score_auto"] == auto
assert "score_final" not in state.score
def test_soft_directive_changes_how_the_caller_sounds(client):
from app.domain.events import Mood

View file

@ -1,6 +1,8 @@
"""Знание URL занятия не даёт курсанту доступ к чужому АРМ."""
import asyncio
import importlib
import json
import time
from uuid import uuid4
@ -10,7 +12,9 @@ from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.domain.roles import Role
from app.main import app
from app.scenarios import store
from app.session.hub import hub
from app.session.state import SessionState
@pytest.fixture
@ -61,3 +65,133 @@ def test_trainee_cannot_open_foreign_or_unassigned_call_or_station(client, monke
break
else:
raise AssertionError("владелец занятия не допущен")
def test_instructor_cannot_start_or_queue_another_instructors_scenario(client, monkeypatch):
source = store.get("fire-apartment-l2")
assert source is not None
private = source.model_copy(update={"id": "private-ws-owner-test"}, deep=True)
store.register_owned_scenario(private, "another-instructor")
emitted = []
monkeypatch.setattr(hub, "to_observers", lambda _session_id, event: emitted.append(event))
public_id = "fire-apartment-l2"
cases = [
{"scenario_id": private.id},
{"scenario_id": public_id, "exercise": "dds",
"scenario_ids": [public_id, private.id]},
{"scenario_id": public_id, "exercise": "dds",
"random_scenario_ids": [public_id, private.id]},
]
sessions = []
try:
for fields in cases:
session_id = uuid4()
sessions.append(session_id)
with client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json({
"type": "scenario.start", "trainee": "Курсант", "mode": "training",
**fields,
})
deadline = time.monotonic() + 3
while len(emitted) < len(sessions) and time.monotonic() < deadline:
time.sleep(0.02)
assert emitted[-1].code.value == "scenario_invalid"
assert hub.get(session_id) is None
finally:
for session_id in sessions:
hub.drop(session_id)
store._library.pop(private.id, None)
store._demo_scenario_owners.pop(private.id, None)
def test_idle_control_socket_closes_when_backend_lease_becomes_uncertain(monkeypatch):
control_module = importlib.import_module("app.api.ws.control")
session_id = uuid4()
state = SessionState(
session_id=session_id, scenario_id="test", scenario_title="Тест", level="L1",
mode="training",
)
hub.register(state)
who = Principal(login="lease-owner", full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(control_module, "websocket_origin_allowed", lambda _ws: True)
monkeypatch.setattr(control_module, "principal_of", lambda _ws: who)
class IdleSocket:
accepted = False
closed = None
sent = []
async def accept(self):
self.accepted = True
async def receive_json(self):
await asyncio.sleep(0.02)
await hub.fence(state)
await asyncio.sleep(5)
async def send_text(self, message):
self.sent.append(json.loads(message))
async def close(self, code=None):
self.closed = code
socket = IdleSocket()
try:
asyncio.run(control_module.control(socket, session_id))
assert socket.accepted
assert socket.closed == 1012
assert socket.sent[-1]["message"].endswith("переподключитесь.")
finally:
hub.drop(session_id)
def test_control_command_checkpoint_failure_returns_fencing_error(monkeypatch):
control_module = importlib.import_module("app.api.ws.control")
session_id = uuid4()
state = SessionState(
session_id=session_id, scenario_id="test", scenario_title="Тест", level="L1",
mode="training", owner_login="lease-owner",
)
class BrokenJournal:
async def checkpoint(self, _state):
raise OSError("simulated database partition")
class OneCommandSocket:
accepted = False
closed = None
sent = []
async def accept(self):
self.accepted = True
async def receive_json(self):
return {"type": "reference.play"}
async def send_text(self, message):
self.sent.append(json.loads(message))
async def close(self, code=None):
self.closed = code
who = Principal(login="lease-owner", full_name="Преподаватель", role=Role.INSTRUCTOR)
socket = OneCommandSocket()
old_journal = hub.journal
hub.journal = BrokenJournal()
hub.register(state)
monkeypatch.setattr(control_module, "websocket_origin_allowed", lambda _ws: True)
monkeypatch.setattr(control_module, "principal_of", lambda _ws: who)
try:
with hub.observer(session_id) as observer_queue:
asyncio.run(control_module.control(socket, session_id))
assert socket.accepted
assert state.lease_fenced
assert socket.closed == 1012
assert socket.sent[-1]["type"] == "error"
assert "переподключитесь" in socket.sent[-1]["message"]
event = observer_queue.get_nowait()
assert "переподключитесь" in event.message
assert observer_queue.empty(), "uncommitted controller event leaked to observers"
finally:
hub.journal = old_journal
hub.drop(session_id)