Files
hermes-webui/tests/test_client_event_diagnostics.py
T
ai-ag2026 8a2f11c770 fix(chat): log sanitized client sse diagnostics
(cherry picked from commit 749ca6e18c5e307fbf7e7fb5fffce97249545017)
2026-05-25 02:06:42 +02:00

159 lines
6.0 KiB
Python

"""Regression coverage for browser-side SSE disconnect diagnostics.
When an EventSource fails in the browser, the server normally only sees a dead
socket or follow-up probe. Persisting a small, sanitized client event makes the
next incident diagnosable without logging prompt text or credentials.
"""
from pathlib import Path
from io import BytesIO
from types import SimpleNamespace
import api.routes as routes
REPO = Path(__file__).resolve().parents[1]
WORKSPACE_JS = (REPO / "static" / "workspace.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
def test_client_event_log_sanitizes_and_whitelists_fields():
payload = {
"event": "sse_error",
"source": "gateway-sessions",
"session_id": "abc123",
"stream_id": "stream456",
"ready_state": 2,
"visibility_state": "hidden",
"online": True,
"url_path": "/chat/abc123?debug=1",
"reason": "onerror",
"password": "must-not-survive",
"content": "prompt text must not survive",
"cookie": "session=secret",
}
sanitized = routes._sanitize_client_event_payload(payload)
assert sanitized == {
"event": "sse_error",
"source": "gateway-sessions",
"session_id": "abc123",
"stream_id": "stream456",
"ready_state": 2,
"visibility_state": "hidden",
"online": True,
"url_path": "/chat/abc123",
"reason": "onerror",
}
assert "secret" not in repr(sanitized)
assert "prompt text" not in repr(sanitized)
def test_client_event_log_bounds_untrusted_values():
payload = {
"event": "x" * 200,
"source": "chat" * 100,
"session_id": "s" * 200,
"stream_id": "t" * 200,
"ready_state": "not-a-number",
"visibility_state": "visible" * 40,
"online": "yes",
"url_path": "https://example.invalid/path?debug=1",
"reason": "network" * 80,
}
sanitized = routes._sanitize_client_event_payload(payload)
assert sanitized["event"] == "x" * 64
assert len(sanitized["source"]) == 80
assert len(sanitized["session_id"]) == 128
assert len(sanitized["stream_id"]) == 128
assert "ready_state" not in sanitized
assert sanitized["visibility_state"] == ("visible" * 40)[:32]
assert sanitized["online"] is True
assert sanitized["url_path"] == "/path"
assert len(sanitized["reason"]) == 160
def test_client_event_log_route_is_wired(monkeypatch):
captured = {}
body_bytes = b'{"event":"sse_error","source":"chat-response"}'
def fake_handle(handler, body):
captured["body"] = body
return True
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(routes, "_handle_client_event_log", fake_handle)
handler = SimpleNamespace(headers={"Content-Length": str(len(body_bytes))}, rfile=BytesIO(body_bytes))
handled = routes.handle_post(handler, SimpleNamespace(path="/api/client-events/log"))
assert handled is True
assert captured["body"] == {"event": "sse_error", "source": "chat-response"}
def test_client_event_log_has_endpoint_specific_body_cap():
too_large = b"{" + b"x" * (routes._CLIENT_EVENT_MAX_BODY_BYTES + 32) + b"}"
handler = SimpleNamespace(
headers={"Content-Length": str(len(too_large))},
rfile=BytesIO(too_large),
close_connection=False,
)
payload = routes._read_client_event_payload(handler)
assert payload == {"event": "discarded", "reason": "body_too_large"}
assert handler.rfile.tell() == routes._CLIENT_EVENT_MAX_BODY_BYTES
assert handler.close_connection is True
def test_client_event_log_rate_limits_per_client(monkeypatch):
routes._CLIENT_EVENT_RATE_LIMIT.clear()
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200: payload.update({"status": status}) or True)
handler = SimpleNamespace(client_address=("203.0.113.10", 1234))
for i in range(routes._CLIENT_EVENT_RATE_LIMIT_MAX):
assert routes._handle_client_event_log(handler, {"event": f"sse_error_{i}"}) is True
limited = {}
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200: limited.update(payload, status=status) or True)
assert routes._handle_client_event_log(handler, {"event": "sse_error_over_limit"}) is True
assert limited == {"ok": False, "error": "rate_limited", "status": 429}
routes._CLIENT_EVENT_RATE_LIMIT.clear()
def test_workspace_js_defines_sanitized_client_sse_error_reporter():
helper_start = WORKSPACE_JS.index("function recordClientSSEError")
helper_block = WORKSPACE_JS[helper_start:helper_start + 900]
assert "api/client-events/log" in helper_block
assert "document.visibilityState" in helper_block
assert "navigator.onLine" in helper_block
assert "location.pathname" in helper_block
assert "location.search" not in helper_block
def test_sessions_js_reports_gateway_sse_errors_with_browser_context():
gateway_block_start = SESSIONS_JS.index("_gatewaySSE.onerror = () =>")
gateway_block = SESSIONS_JS[gateway_block_start:gateway_block_start + 400]
assert "recordClientSSEError('gateway-sessions'" in gateway_block
assert "probeGatewaySSEStatus" in gateway_block
def test_messages_js_reports_chat_sse_errors_with_stream_identity():
error_block_start = MESSAGES_JS.index("source.addEventListener('error',async e=>")
error_block = MESSAGES_JS[error_block_start:error_block_start + 900]
assert "recordClientSSEError('chat-response'" in error_block
assert "session_id:activeSid" in error_block
assert "stream_id:streamId" in error_block
def test_messages_js_keeps_finalized_stream_guard_before_diagnostic_report():
error_block_start = MESSAGES_JS.index("source.addEventListener('error',async e=>")
error_block = MESSAGES_JS[error_block_start:error_block_start + 900]
assert "_streamFinalized" in error_block
assert error_block.index("_streamFinalized") < error_block.index("recordClientSSEError")