fix: code review — critical and important fixes [v0.2.1]
- 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>
This commit is contained in:
@@ -3,7 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -16,7 +18,7 @@ from pydantic import BaseModel
|
|||||||
from meeting_room.api_client import APIClient
|
from meeting_room.api_client import APIClient
|
||||||
from meeting_room.config import load_config, load_discussion_config, find_workspace_config
|
from meeting_room.config import load_config, load_discussion_config, find_workspace_config
|
||||||
from meeting_room.engine import DiscussionEngine
|
from meeting_room.engine import DiscussionEngine
|
||||||
from meeting_room.events import AsyncEventBridge, EventEmitter
|
from meeting_room.events import AGENT_ERROR, AGENT_MESSAGE, BOSS_TURN, AsyncEventBridge, EventEmitter
|
||||||
from meeting_room.models import DiscussionConfig, ProviderConfig
|
from meeting_room.models import DiscussionConfig, ProviderConfig
|
||||||
from meeting_room.scenario_loader import load_scenario
|
from meeting_room.scenario_loader import load_scenario
|
||||||
from meeting_room.tools import ToolRegistry
|
from meeting_room.tools import ToolRegistry
|
||||||
@@ -60,11 +62,11 @@ class SessionDetailResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session store
|
# Session state (renamed from Session to avoid collision with models.Session)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class Session:
|
class ServerSession:
|
||||||
"""Holds state for a single discussion session."""
|
"""Holds state for a single discussion session."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -82,8 +84,10 @@ class Session:
|
|||||||
self.event_bus = EventEmitter()
|
self.event_bus = EventEmitter()
|
||||||
self.bridge = AsyncEventBridge(self.event_bus)
|
self.bridge = AsyncEventBridge(self.event_bus)
|
||||||
self._engine_task: asyncio.Task | None = None
|
self._engine_task: asyncio.Task | None = None
|
||||||
|
self._collect_handler = None # stored for cleanup
|
||||||
|
|
||||||
def build_engine(self) -> DiscussionEngine:
|
def build_engine(self) -> DiscussionEngine:
|
||||||
|
"""Build a DiscussionEngine using this session's event_bus."""
|
||||||
providers = {
|
providers = {
|
||||||
name: ProviderConfig(**vals)
|
name: ProviderConfig(**vals)
|
||||||
for name, vals in self.config.get("providers", {}).items()
|
for name, vals in self.config.get("providers", {}).items()
|
||||||
@@ -97,23 +101,31 @@ class Session:
|
|||||||
events=self.event_bus,
|
events=self.event_bus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Stop bridge and remove event handlers."""
|
||||||
|
self.bridge.stop()
|
||||||
|
if self._collect_handler is not None:
|
||||||
|
for event_type in (AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR):
|
||||||
|
self.event_bus.off(event_type, self._collect_handler)
|
||||||
|
self._collect_handler = None
|
||||||
|
|
||||||
|
|
||||||
class SessionStore:
|
class SessionStore:
|
||||||
"""In-memory session store."""
|
"""In-memory session store."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._sessions: dict[str, Session] = {}
|
self._sessions: dict[str, ServerSession] = {}
|
||||||
|
|
||||||
def create(self, config: dict, discussion_config: DiscussionConfig) -> Session:
|
def create(self, config: dict, discussion_config: DiscussionConfig) -> ServerSession:
|
||||||
session_id = uuid.uuid4().hex[:12]
|
session_id = uuid.uuid4().hex[:12]
|
||||||
session = Session(session_id, config, discussion_config)
|
session = ServerSession(session_id, config, discussion_config)
|
||||||
self._sessions[session_id] = session
|
self._sessions[session_id] = session
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def get(self, session_id: str) -> Session | None:
|
def get(self, session_id: str) -> ServerSession | None:
|
||||||
return self._sessions.get(session_id)
|
return self._sessions.get(session_id)
|
||||||
|
|
||||||
def list_sessions(self) -> list[Session]:
|
def list_sessions(self) -> list[ServerSession]:
|
||||||
return list(self._sessions.values())
|
return list(self._sessions.values())
|
||||||
|
|
||||||
|
|
||||||
@@ -129,22 +141,22 @@ async def lifespan(app: FastAPI):
|
|||||||
"""Startup/shutdown lifecycle."""
|
"""Startup/shutdown lifecycle."""
|
||||||
logger.info("Meeting Room server started")
|
logger.info("Meeting Room server started")
|
||||||
yield
|
yield
|
||||||
# Cleanup: cancel any running engine tasks
|
# Cleanup: cancel running engine tasks and stop bridges
|
||||||
for session in store.list_sessions():
|
for session in store.list_sessions():
|
||||||
if session._engine_task and not session._engine_task.done():
|
if session._engine_task and not session._engine_task.done():
|
||||||
session._engine_task.cancel()
|
session._engine_task.cancel()
|
||||||
|
session.cleanup()
|
||||||
logger.info("Meeting Room server stopped")
|
logger.info("Meeting Room server stopped")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Meeting Room",
|
title="Meeting Room",
|
||||||
version="0.2.0",
|
version="0.2.1",
|
||||||
description="Multi-agent discussion framework — REST API and WebSocket",
|
description="Multi-agent discussion framework — REST API and WebSocket",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Serve static files (web UI)
|
# Serve static files (web UI)
|
||||||
import os
|
|
||||||
_static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
_static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||||
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
||||||
|
|
||||||
@@ -152,7 +164,6 @@ app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
"""Redirect to the web UI."""
|
"""Redirect to the web UI."""
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
return RedirectResponse(url="/static/index.html")
|
return RedirectResponse(url="/static/index.html")
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +182,44 @@ def _load_config_or_default(config_path: str | None) -> dict:
|
|||||||
return load_config()
|
return load_config()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scenario_path(scenario: str) -> str:
|
||||||
|
"""Resolve scenario path, preventing path traversal.
|
||||||
|
|
||||||
|
Only allows scenarios from CWD or the package scenarios directory.
|
||||||
|
"""
|
||||||
|
# Reject path traversal
|
||||||
|
if ".." in scenario:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid scenario path: path traversal not allowed")
|
||||||
|
|
||||||
|
if os.path.isabs(scenario_path := scenario):
|
||||||
|
if not os.path.exists(scenario_path):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario}")
|
||||||
|
return scenario_path
|
||||||
|
|
||||||
|
# Try relative to CWD first
|
||||||
|
cwd_path = os.path.join(os.getcwd(), scenario)
|
||||||
|
if os.path.exists(cwd_path):
|
||||||
|
return cwd_path
|
||||||
|
|
||||||
|
# Try package scenarios directory
|
||||||
|
pkg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios", scenario)
|
||||||
|
if os.path.exists(pkg_path):
|
||||||
|
return pkg_path
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario}")
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_api_keys(providers: dict) -> dict:
|
||||||
|
"""Mask API keys in provider config for safe display."""
|
||||||
|
masked = {}
|
||||||
|
for name, cfg in providers.items():
|
||||||
|
masked[name] = {
|
||||||
|
"base_url": cfg.get("base_url", ""),
|
||||||
|
"api_key": cfg.get("api_key", "")[:4] + "****" if cfg.get("api_key") else "",
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# REST API (mounted at /api)
|
# REST API (mounted at /api)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -225,18 +274,8 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
|
|||||||
if session.status == "running":
|
if session.status == "running":
|
||||||
raise HTTPException(status_code=409, detail="Discussion already running")
|
raise HTTPException(status_code=409, detail="Discussion already running")
|
||||||
|
|
||||||
# Load scenario
|
# Resolve and validate scenario path
|
||||||
import os
|
scenario_path = _resolve_scenario_path(body.scenario)
|
||||||
scenario_path = body.scenario
|
|
||||||
if not os.path.isabs(scenario_path):
|
|
||||||
scenario_path = os.path.join(os.getcwd(), scenario_path)
|
|
||||||
if not os.path.exists(scenario_path):
|
|
||||||
scenario_path = os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)), "scenarios", body.scenario
|
|
||||||
)
|
|
||||||
if not os.path.exists(scenario_path):
|
|
||||||
raise HTTPException(status_code=404, detail=f"Scenario not found: {body.scenario}")
|
|
||||||
|
|
||||||
scenario_data = load_scenario(scenario_path)
|
scenario_data = load_scenario(scenario_path)
|
||||||
session.scenario_name = scenario_data.get("name", "Untitled")
|
session.scenario_name = scenario_data.get("name", "Untitled")
|
||||||
|
|
||||||
@@ -247,13 +286,11 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
|
|||||||
session.config = raw_config
|
session.config = raw_config
|
||||||
session.discussion_config = discussion_config
|
session.discussion_config = discussion_config
|
||||||
|
|
||||||
# Start the bridge
|
# Start the bridge on the running event loop
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
session.bridge.start(loop)
|
session.bridge.start(loop)
|
||||||
|
|
||||||
# Subscribe a handler to collect messages
|
# Subscribe a handler to collect messages (stored for cleanup)
|
||||||
from meeting_room.events import AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR
|
|
||||||
|
|
||||||
def collect_message(event):
|
def collect_message(event):
|
||||||
session.messages.append({
|
session.messages.append({
|
||||||
"role_id": event.data.get("role_id", ""),
|
"role_id": event.data.get("role_id", ""),
|
||||||
@@ -261,6 +298,7 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
|
|||||||
"content": event.data.get("content", event.data.get("error", "")),
|
"content": event.data.get("content", event.data.get("error", "")),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
session._collect_handler = collect_message
|
||||||
session.event_bus.on(AGENT_MESSAGE, collect_message)
|
session.event_bus.on(AGENT_MESSAGE, collect_message)
|
||||||
session.event_bus.on(BOSS_TURN, collect_message)
|
session.event_bus.on(BOSS_TURN, collect_message)
|
||||||
session.event_bus.on(AGENT_ERROR, collect_message)
|
session.event_bus.on(AGENT_ERROR, collect_message)
|
||||||
@@ -277,7 +315,7 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
|
|||||||
logger.exception("Engine error in session %s", session.id)
|
logger.exception("Engine error in session %s", session.id)
|
||||||
session.status = "error"
|
session.status = "error"
|
||||||
finally:
|
finally:
|
||||||
session.bridge.stop()
|
session.cleanup()
|
||||||
|
|
||||||
session._engine_task = asyncio.create_task(run_engine())
|
session._engine_task = asyncio.create_task(run_engine())
|
||||||
|
|
||||||
@@ -291,20 +329,20 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
|
|||||||
|
|
||||||
@api.post("/sessions/{session_id}/inject", response_model=dict)
|
@api.post("/sessions/{session_id}/inject", response_model=dict)
|
||||||
async def inject_message(session_id: str, body: InjectMessageRequest):
|
async def inject_message(session_id: str, body: InjectMessageRequest):
|
||||||
"""Inject a message from a Boss/CSO role."""
|
"""Inject a message from a Boss/CSO role.
|
||||||
|
|
||||||
|
Uses the session's event_bus so WebSocket/SSE clients receive the event.
|
||||||
|
"""
|
||||||
session = store.get(session_id)
|
session = store.get(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
if session.status != "running":
|
if session.status != "running":
|
||||||
raise HTTPException(status_code=409, detail="Session is not running")
|
raise HTTPException(status_code=409, detail="Session is not running")
|
||||||
|
|
||||||
|
# Use the session's event_bus — inject_message emits BOSS_TURN on it,
|
||||||
|
# which the bridge forwards to all subscribers (WebSocket/SSE).
|
||||||
engine = session.build_engine()
|
engine = session.build_engine()
|
||||||
msg = engine.inject_message(body.role_id, body.content)
|
msg = engine.inject_message(body.role_id, body.content)
|
||||||
session.messages.append({
|
|
||||||
"role_id": msg.role_id,
|
|
||||||
"name": msg.name,
|
|
||||||
"content": msg.content,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"role_id": msg.role_id, "name": msg.name, "content": msg.content}
|
return {"role_id": msg.role_id, "name": msg.name, "content": msg.content}
|
||||||
|
|
||||||
@@ -327,9 +365,9 @@ async def list_roles(config: str | None = None):
|
|||||||
|
|
||||||
@api.get("/providers")
|
@api.get("/providers")
|
||||||
async def list_providers(config: str | None = None):
|
async def list_providers(config: str | None = None):
|
||||||
"""List configured providers."""
|
"""List configured providers (API keys masked)."""
|
||||||
raw_config = _load_config_or_default(config)
|
raw_config = _load_config_or_default(config)
|
||||||
return raw_config.get("providers", {})
|
return _mask_api_keys(raw_config.get("providers", {}))
|
||||||
|
|
||||||
|
|
||||||
# Mount API router
|
# Mount API router
|
||||||
@@ -351,40 +389,32 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str):
|
|||||||
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
queue = session.bridge.subscribe()
|
queue = session.bridge.subscribe()
|
||||||
|
send_task = asyncio.create_task(_ws_send_loop(websocket, queue))
|
||||||
|
|
||||||
try:
|
|
||||||
# Task: forward events from queue to WebSocket
|
|
||||||
async def send_events():
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
event = await queue.get()
|
|
||||||
await websocket.send_json(event)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
send_task = asyncio.create_task(send_events())
|
|
||||||
|
|
||||||
# Listen for incoming messages (inject)
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_json()
|
data = await websocket.receive_json()
|
||||||
if data.get("type") == "inject":
|
if data.get("type") == "inject":
|
||||||
role_id = data.get("role_id", "boss")
|
role_id = data.get("role_id", "boss")
|
||||||
content = data.get("content", "")
|
content = data.get("content", "")
|
||||||
|
# Use session's event_bus so events reach all subscribers
|
||||||
engine = session.build_engine()
|
engine = session.build_engine()
|
||||||
msg = engine.inject_message(role_id, content)
|
engine.inject_message(role_id, content)
|
||||||
session.messages.append({
|
|
||||||
"role_id": msg.role_id,
|
|
||||||
"name": msg.name,
|
|
||||||
"content": msg.content,
|
|
||||||
})
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
send_task.cancel()
|
send_task.cancel()
|
||||||
session.bridge.unsubscribe(queue)
|
session.bridge.unsubscribe(queue)
|
||||||
except Exception:
|
|
||||||
session.bridge.unsubscribe(queue)
|
|
||||||
|
async def _ws_send_loop(websocket: WebSocket, queue: asyncio.Queue[dict]) -> None:
|
||||||
|
"""Forward events from bridge queue to WebSocket."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
event = await queue.get()
|
||||||
|
await websocket.send_json(event)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -404,8 +434,6 @@ async def sse_endpoint(session_id: str):
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
event = await queue.get()
|
event = await queue.get()
|
||||||
# SSE format
|
|
||||||
import json
|
|
||||||
yield f"data: {json.dumps(event)}\n\n"
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -255,12 +255,12 @@
|
|||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'msg';
|
el.className = 'msg';
|
||||||
const color = ROLE_COLORS[d.role_id] || '#999';
|
const color = ROLE_COLORS[d.role_id] || '#999';
|
||||||
el.innerHTML = `<div class="name" style="color:${color}">${d.name}</div><div class="content">${escapeHtml(d.content || '')}</div>`;
|
el.innerHTML = `<div class="name" style="color:${color}">${escapeHtml(d.name)}</div><div class="content">${escapeHtml(d.content || '')}</div>`;
|
||||||
container.appendChild(el);
|
container.appendChild(el);
|
||||||
} else if (type === 'agent_error') {
|
} else if (type === 'agent_error') {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'msg error';
|
el.className = 'msg error';
|
||||||
el.innerHTML = `<div class="name">${d.name}</div><div class="content">Error: ${escapeHtml(d.error || '')}</div>`;
|
el.innerHTML = `<div class="name">${escapeHtml(d.name)}</div><div class="content">Error: ${escapeHtml(d.error || '')}</div>`;
|
||||||
container.appendChild(el);
|
container.appendChild(el);
|
||||||
} else if (type === 'tool_call') {
|
} else if (type === 'tool_call') {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from meeting_room.events import AsyncEventBridge, EventEmitter
|
from meeting_room.events import AsyncEventBridge, EventEmitter
|
||||||
from meeting_room.server import Session, SessionStore, app, store
|
from meeting_room.server import ServerSession, SessionStore, app, store
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -113,13 +114,53 @@ class TestRolesAndProviders:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestSession:
|
class TestServerSession:
|
||||||
def test_build_engine(self) -> None:
|
def test_build_engine(self) -> None:
|
||||||
config = _minimal_config()
|
config = _minimal_config()
|
||||||
session = Session("test-id", config, _minimal_discussion_config())
|
session = ServerSession("test-id", config, _minimal_discussion_config())
|
||||||
engine = session.build_engine()
|
engine = session.build_engine()
|
||||||
assert engine is not None
|
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
|
# Helper
|
||||||
|
|||||||
Reference in New Issue
Block a user