mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
8a2f11c770
(cherry picked from commit 749ca6e18c5e307fbf7e7fb5fffce97249545017)
159 lines
6.0 KiB
Python
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")
|