Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue