- inject_message now uses session's event_bus (BOSS_TURN reaches WebSocket/SSE) - asyncio.get_running_loop() replaces deprecated get_event_loop() - API keys masked in /api/providers response - ServerSession (renamed from Session) with cleanup() for handler teardown - Path traversal protection in _resolve_scenario_path - XSS fix: escapeHtml(d.name) in web UI - Duplicate imports removed, os/json moved to module level - WebSocket handler restructured for reliable cleanup - New tests: cleanup, provider masking, path traversal Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
204 lines
6.9 KiB
Python
204 lines
6.9 KiB
Python
"""Tests for meeting_room.server — FastAPI app, REST API, WebSocket/SSE."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
from meeting_room.events import AsyncEventBridge, EventEmitter
|
|
from meeting_room.server import ServerSession, SessionStore, app, store
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_store():
|
|
"""Clear the global session store before each test."""
|
|
store._sessions.clear()
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""FastAPI test client."""
|
|
return TestClient(app)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SessionStore
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSessionStore:
|
|
def test_create_session(self) -> None:
|
|
store = SessionStore()
|
|
session = store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
|
assert session.id
|
|
assert session.status == "created"
|
|
|
|
def test_get_session(self) -> None:
|
|
store = SessionStore()
|
|
session = store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
|
found = store.get(session.id)
|
|
assert found is session
|
|
|
|
def test_get_nonexistent_session(self) -> None:
|
|
store = SessionStore()
|
|
assert store.get("nonexistent") is None
|
|
|
|
def test_list_sessions(self) -> None:
|
|
store = SessionStore()
|
|
store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
|
store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
|
assert len(store.list_sessions()) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REST API endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateSession:
|
|
def test_create_session_returns_id_and_status(self, client) -> None:
|
|
response = client.post("/api/sessions")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "id" in data
|
|
assert data["status"] == "created"
|
|
|
|
|
|
class TestListSessions:
|
|
def test_list_empty(self, client) -> None:
|
|
response = client.get("/api/sessions")
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
def test_list_after_create(self, client) -> None:
|
|
client.post("/api/sessions")
|
|
client.post("/api/sessions")
|
|
response = client.get("/api/sessions")
|
|
assert response.status_code == 200
|
|
assert len(response.json()) == 2
|
|
|
|
|
|
class TestGetSession:
|
|
def test_get_existing_session(self, client) -> None:
|
|
create_resp = client.post("/api/sessions")
|
|
session_id = create_resp.json()["id"]
|
|
response = client.get(f"/api/sessions/{session_id}")
|
|
assert response.status_code == 200
|
|
assert response.json()["id"] == session_id
|
|
|
|
def test_get_nonexistent_session(self, client) -> None:
|
|
response = client.get("/api/sessions/nonexistent")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestRolesAndProviders:
|
|
def test_list_roles(self, client) -> None:
|
|
response = client.get("/api/roles")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "moderator" in data or isinstance(data, dict)
|
|
|
|
def test_list_providers(self, client) -> None:
|
|
response = client.get("/api/providers")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, dict)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestServerSession:
|
|
def test_build_engine(self) -> None:
|
|
config = _minimal_config()
|
|
session = ServerSession("test-id", config, _minimal_discussion_config())
|
|
engine = session.build_engine()
|
|
assert engine is not None
|
|
|
|
def test_cleanup_removes_collect_handler(self) -> None:
|
|
from meeting_room.events import AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR
|
|
config = _minimal_config()
|
|
session = ServerSession("test-id", config, _minimal_discussion_config())
|
|
# Simulate what start_discussion does
|
|
def collect_message(event):
|
|
pass
|
|
session._collect_handler = collect_message
|
|
session.event_bus.on(AGENT_MESSAGE, collect_message)
|
|
session.event_bus.on(BOSS_TURN, collect_message)
|
|
session.event_bus.on(AGENT_ERROR, collect_message)
|
|
assert len(session.event_bus.handlers(AGENT_MESSAGE)) == 1
|
|
session.cleanup()
|
|
assert len(session.event_bus.handlers(AGENT_MESSAGE)) == 0
|
|
|
|
|
|
class TestProviderMasking:
|
|
def test_api_keys_masked(self) -> None:
|
|
from meeting_room.server import _mask_api_keys
|
|
providers = {
|
|
"routerai": {"base_url": "https://api.example.com", "api_key": "sk-1234567890abcdef"},
|
|
}
|
|
masked = _mask_api_keys(providers)
|
|
assert masked["routerai"]["api_key"] == "sk-1****"
|
|
assert masked["routerai"]["base_url"] == "https://api.example.com"
|
|
|
|
def test_empty_key(self) -> None:
|
|
from meeting_room.server import _mask_api_keys
|
|
providers = {"test": {"base_url": "https://x.com", "api_key": ""}}
|
|
masked = _mask_api_keys(providers)
|
|
assert masked["test"]["api_key"] == ""
|
|
|
|
|
|
class TestPathTraversal:
|
|
def test_reject_parent_dir(self) -> None:
|
|
from meeting_room.server import _resolve_scenario_path
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_resolve_scenario_path("../../etc/passwd")
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _minimal_config() -> dict:
|
|
return {
|
|
"providers": {
|
|
"test_provider": {
|
|
"base_url": "https://api.example.com/v1",
|
|
"api_key": "test-key",
|
|
}
|
|
},
|
|
"roles": {
|
|
"moderator": {
|
|
"name": "Moderator",
|
|
"provider": "test_provider",
|
|
"model": "gpt-4o-mini",
|
|
"temperature": 0.7,
|
|
"tools": "none",
|
|
"system_prompt": "You are a moderator.",
|
|
}
|
|
},
|
|
"defaults": {
|
|
"max_rounds": 3,
|
|
"language": "en",
|
|
"framework": "custom",
|
|
"workdir": ".",
|
|
},
|
|
}
|
|
|
|
|
|
def _minimal_discussion_config():
|
|
from meeting_room.models import DefaultsConfig, DiscussionConfig
|
|
|
|
return DiscussionConfig(
|
|
providers={"test_provider": {"base_url": "https://api.example.com/v1", "api_key": "test-key"}},
|
|
roles={"moderator": {"name": "Moderator", "provider": "test_provider", "model": "gpt-4o-mini"}},
|
|
defaults=DefaultsConfig(max_rounds=3),
|
|
) |