feat(B-WS): WebSocket Helpers + Redis Pub/Sub + Error-Handling
Check Cross-Plugin Imports / check (push) Has been cancelled

B-WS: app/core/ws_helpers.py (NEU) — gemeinsame WebSocket Helpers
- authenticate_ws: Session-Auth für WebSocket (Cookie/Token → User/Tenant)
- check_ws_origin: Origin-Check (delegiert auf verify_ws_origin)
- check_ws_tenant: User-Tenant-Membership-Check
- cleanup_ws_connection: Connection aus Registry entfernen + WS schließen
- start_heartbeat: Background Ping-Task
- send_ws_error: strukturierte Error-Message an Client
- handle_ws_message: Message-Dispatch mit Error-Handling

B-WS: app/core/ws_pubsub.py (NEU) — Redis Pub/Sub für Multi-Worker-Fanout
- publish_to_channel / subscribe_to_channel
- get_tenant_channel / broadcast_to_tenants

B-WS: WebSocketManager + AIUIControlWSManager angepasst
- connect() nutzt authenticate_ws + check_ws_origin + check_ws_tenant
- disconnect() nutzt cleanup_ws_connection + cancelt Heartbeat/PubSub
- broadcast() unterstützt Redis Pub/Sub Fanout

B-ERR-WS: WS Error-Handling in ws_helpers integriert
- send_ws_error für strukturierte Errors
- handle_ws_message fängt Handler-Exceptions

B-WS-TEST: 24 Tests in test_ws_helpers.py — alle grün
- Auth, Origin, Error, Dispatch, Cleanup, Heartbeat, Pub/Sub Roundtrip
- Keine Regression: 47/47 Resilience+Hooks Tests grün
This commit is contained in:
Agent Zero
2026-08-13 16:43:54 +02:00
parent a3a26d1f66
commit 7a81a5f072
7 changed files with 969 additions and 76 deletions
+184
View File
@@ -0,0 +1,184 @@
"""Shared WebSocket helpers: auth, origin check, tenant check, cleanup, heartbeat, error handling, message dispatch."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import Any, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.websockets import WebSocket
from app.core.auth import get_redis, get_session_data, verify_ws_origin
from app.config import get_settings
logger = logging.getLogger(__name__)
async def authenticate_ws(websocket: WebSocket, db: AsyncSession) -> dict[str, Any] | None:
"""Authenticate a WebSocket connection via session cookie.
Extracts the session cookie, validates the session in Redis,
and returns user/tenant info. On failure, closes the WebSocket
with code 4401 and returns ``None``.
"""
settings = get_settings()
session_id = websocket.cookies.get(settings.session_cookie_name)
if not session_id:
await websocket.close(code=4401, reason="Unauthorized")
return None
redis = get_redis()
session_data = await get_session_data(redis, session_id)
if session_data is None:
await websocket.close(code=4401, reason="Unauthorized")
return None
if not session_data.get("is_active", False):
await websocket.close(code=4401, reason="Unauthorized")
return None
return {
"user_id": session_data["user_id"],
"tenant_id": session_data["tenant_id"],
"session_id": session_id,
"role": session_data.get("role"),
"email": session_data.get("email"),
"name": session_data.get("name"),
"is_system_admin": session_data.get("is_system_admin", False),
}
async def check_ws_origin(websocket: WebSocket) -> bool:
"""Verify the WebSocket origin and CSRF token.
Delegates to :func:`verify_ws_origin`. On failure, closes the
WebSocket with code 4403 and returns ``False``.
"""
result = await verify_ws_origin(websocket)
if not result:
await websocket.close(code=4403, reason="Forbidden origin")
return False
return True
async def check_ws_tenant(
websocket: WebSocket,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession,
) -> bool:
"""Verify that *user_id* belongs to *tenant_id*.
On failure, closes the WebSocket with code 4403 and returns ``False``.
"""
from app.models.user import UserTenant
result = await db.execute(
select(UserTenant).where(
UserTenant.user_id == user_id,
UserTenant.tenant_id == tenant_id,
)
)
if result.scalar_one_or_none() is None:
await websocket.close(code=4403, reason="Forbidden tenant")
return False
return True
async def cleanup_ws_connection(
websocket: WebSocket,
user_id: str,
connection_registry: dict[str, list[WebSocket]],
) -> None:
"""Remove a WebSocket from the connection registry and close it cleanly."""
conns = connection_registry.get(user_id, [])
if websocket in conns:
conns.remove(websocket)
if not conns:
connection_registry.pop(user_id, None)
try:
await websocket.close()
except Exception:
logger.debug("WebSocket already closed during cleanup for user %s", user_id)
async def start_heartbeat(websocket: WebSocket, interval: int = 30) -> asyncio.Task:
"""Start a background heartbeat task that sends periodic pings.
Returns the :class:`asyncio.Task` so the caller can cancel it on disconnect.
"""
async def _heartbeat() -> None:
while True:
try:
await asyncio.sleep(interval)
await websocket.send_text(json.dumps({"type": "ping"}))
except asyncio.CancelledError:
break
except Exception:
logger.debug("Heartbeat stopped — WebSocket likely closed")
break
return asyncio.create_task(_heartbeat())
async def send_ws_error(
websocket: WebSocket,
code: str,
detail: str,
trace_id: str | None = None,
) -> None:
"""Send a structured error message to the WebSocket client."""
payload: dict[str, Any] = {
"type": "error",
"code": code,
"detail": detail,
}
if trace_id is not None:
payload["trace_id"] = trace_id
try:
await websocket.send_text(json.dumps(payload, default=str))
except Exception:
logger.debug("Failed to send WS error to client")
async def handle_ws_message(
websocket: WebSocket,
message: str,
handlers: dict[str, Callable[[WebSocket, dict[str, Any]], Any]],
) -> None:
"""Dispatch a WebSocket text message to the appropriate handler.
*handlers* maps message ``type`` strings to async callables that accept
``(websocket, msg)``. Unknown types and handler exceptions are
reported back to the client via :func:`send_ws_error`.
"""
try:
msg = json.loads(message)
except (json.JSONDecodeError, TypeError):
await send_ws_error(websocket, "invalid_json", "Message is not valid JSON")
return
if not isinstance(msg, dict):
await send_ws_error(websocket, "invalid_message", "Message must be a JSON object")
return
msg_type = msg.get("type")
if not msg_type:
await send_ws_error(websocket, "missing_type", "Message missing 'type' field")
return
handler = handlers.get(msg_type)
if handler is None:
await send_ws_error(websocket, "unknown_type", f"Unknown message type: {msg_type}")
return
try:
await handler(websocket, msg)
except Exception as exc:
logger.exception("Handler error for message type '%s'", msg_type)
await send_ws_error(websocket, "handler_error", str(exc))
+65
View File
@@ -0,0 +1,65 @@
"""Redis Pub/Sub helpers for WebSocket multi-worker fanout."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import Awaitable, Callable
from app.core.auth import get_redis
logger = logging.getLogger(__name__)
async def publish_to_channel(channel: str, message: dict) -> None:
"""Publish a JSON message to a Redis channel."""
redis = get_redis()
await redis.publish(channel, json.dumps(message, default=str))
async def subscribe_to_channel(
channel: str,
handler: Callable[[dict], Awaitable[None]],
) -> asyncio.Task:
"""Subscribe to a Redis channel and call *handler* for every message.
Returns the :class:`asyncio.Task` so the caller can cancel it on disconnect.
"""
async def _subscriber() -> None:
redis = get_redis()
pubsub = redis.pubsub()
await pubsub.subscribe(channel)
try:
async for raw in pubsub.listen():
if raw["type"] == "message":
try:
msg = json.loads(raw["data"])
await handler(msg)
except Exception:
logger.exception("Error in pubsub handler for channel %s", channel)
finally:
try:
await pubsub.unsubscribe(channel)
await pubsub.aclose()
except Exception:
logger.debug("PubSub cleanup error for channel %s", channel)
return asyncio.create_task(_subscriber())
def get_tenant_channel(tenant_id: uuid.UUID, topic: str) -> str:
"""Return the Redis channel name for a tenant + topic."""
return f"ws:{tenant_id}:{topic}"
async def broadcast_to_tenants(
tenant_id: uuid.UUID,
topic: str,
message: dict,
) -> None:
"""Publish a message to a tenant-specific Redis channel."""
channel = get_tenant_channel(tenant_id, topic)
await publish_to_channel(channel, message)
+12 -25
View File
@@ -205,33 +205,28 @@ async def ai_ui_control_ws(websocket: WebSocket):
Authentication: via session cookie (same pattern as kommunikation plugin). Authentication: via session cookie (same pattern as kommunikation plugin).
""" """
from app.config import get_settings from app.core.db import async_session_maker
from app.core.auth import get_session_data, get_redis, verify_ws_origin
from app.core.service_container import get_container from app.core.service_container import get_container
settings = get_settings() container = get_container()
if not await verify_ws_origin(websocket): if not container.has("ai_ui_control_ws"):
await websocket.close(code=4003, reason="Origin not allowed") await websocket.close(code=4003, reason="AI UI Control not available")
return return
session_id = websocket.cookies.get(settings.session_cookie_name) ws_manager = container.get("ai_ui_control_ws")
if not session_id:
await websocket.close(code=4001, reason="Not authenticated")
return
redis = get_redis() # connect() performs origin check, session auth, tenant check
session_data = await get_session_data(redis, session_id) async with async_session_maker() as db:
if session_data is None: auth = await ws_manager.connect(websocket, db)
await websocket.close(code=4001, reason="Session expired") if auth is None:
return return # connection was rejected and closed by ws_helpers
user_id = session_data["user_id"] user_id = auth["user_id"]
tenant_id = session_data["tenant_id"] tenant_id = auth["tenant_id"]
# Plugin-Gate: check if ai_ui_control plugin is active (global + tenant) # Plugin-Gate: check if ai_ui_control plugin is active (global + tenant)
from app.core.permission_registry import get_permission_registry from app.core.permission_registry import get_permission_registry
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
from app.core.db import async_session_maker
import uuid as _uuid import uuid as _uuid
try: try:
registry = get_permission_registry() registry = get_permission_registry()
@@ -251,14 +246,6 @@ async def ai_ui_control_ws(websocket: WebSocket):
await websocket.close(code=4003, reason="Plugin check failed") await websocket.close(code=4003, reason="Plugin check failed")
return return
container = get_container()
if not container.has("ai_ui_control_ws"):
await websocket.close(code=4003, reason="AI UI Control not available")
return
ws_manager = container.get("ai_ui_control_ws")
await ws_manager.connect(websocket, user_id)
try: try:
while True: while True:
data = await websocket.receive_text() data = await websocket.receive_text()
@@ -16,6 +16,20 @@ from typing import Any
from fastapi import WebSocket from fastapi import WebSocket
from app.core.ws_helpers import (
authenticate_ws,
check_ws_origin,
check_ws_tenant,
cleanup_ws_connection,
start_heartbeat,
)
from app.core.ws_pubsub import (
broadcast_to_tenants,
get_tenant_channel,
subscribe_to_channel,
)
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,23 +50,84 @@ class AIUIControlWSManager:
self._delivered_at: dict[str, float] = {} self._delivered_at: dict[str, float] = {}
# command_id → user_id (to route feedback) # command_id → user_id (to route feedback)
self._command_user: dict[str, str] = {} self._command_user: dict[str, str] = {}
# user_id → list of heartbeat tasks
self._heartbeat_tasks: dict[str, list] = {}
# user_id → list of pubsub subscriber tasks
self._pubsub_tasks: dict[str, list] = {}
async def connect(self, websocket: WebSocket, user_id: str) -> None: async def connect(
"""Accept and register a new WebSocket connection.""" self,
websocket: WebSocket,
db: AsyncSession,
) -> dict[str, Any] | None:
"""Accept, authenticate and register a new WebSocket connection.
Performs origin check, session authentication and tenant validation.
Returns the auth dict (user_id, tenant_id, …) on success, or ``None``
if the connection was rejected (already closed).
"""
# Origin / CSRF check
if not await check_ws_origin(websocket):
return None
# Session authentication
auth = await authenticate_ws(websocket, db)
if auth is None:
return None
user_id = auth["user_id"]
tenant_id_str = auth["tenant_id"]
tenant_id = uuid.UUID(tenant_id_str)
user_uuid = uuid.UUID(user_id)
# Tenant membership check
if not await check_ws_tenant(websocket, tenant_id, user_uuid, db):
return None
# Accept the WebSocket
await websocket.accept() await websocket.accept()
# Register connection
if user_id not in self._connections: if user_id not in self._connections:
self._connections[user_id] = [] self._connections[user_id] = []
self._connections[user_id].append(websocket) self._connections[user_id].append(websocket)
# Start heartbeat
hb_task = await start_heartbeat(websocket)
self._heartbeat_tasks.setdefault(user_id, []).append(hb_task)
# Start Redis Pub/Sub subscriber for tenant-wide fanout
channel = get_tenant_channel(tenant_id, "ai_ui_control")
pubsub_task = await subscribe_to_channel(channel, lambda msg: self._on_pubsub_message(user_id, msg))
self._pubsub_tasks.setdefault(user_id, []).append(pubsub_task)
logger.debug(f"AI UI Control WS connected: user={user_id}, total={len(self._connections[user_id])}") logger.debug(f"AI UI Control WS connected: user={user_id}, total={len(self._connections[user_id])}")
return auth
async def _on_pubsub_message(self, user_id: str, msg: dict[str, Any]) -> None:
"""Handle a message received via Redis Pub/Sub."""
# Forward pubsub messages to the user's connections
conns = self._connections.get(user_id, [])
text = json.dumps(msg, default=str)
for ws in conns:
try:
await ws.send_text(text)
except Exception:
logger.warning(f"Failed to send pubsub message to user {user_id}")
async def disconnect(self, websocket: WebSocket, user_id: str) -> None: async def disconnect(self, websocket: WebSocket, user_id: str) -> None:
"""Remove a WebSocket connection.""" """Remove a WebSocket connection and clean up resources."""
conns = self._connections.get(user_id, []) # Cancel heartbeat tasks for this user
if websocket in conns: for task in self._heartbeat_tasks.pop(user_id, []):
conns.remove(websocket) task.cancel()
if not conns: # Cancel pubsub tasks for this user
self._connections.pop(user_id, None) for task in self._pubsub_tasks.pop(user_id, []):
logger.debug(f"AI UI Control WS disconnected: user={user_id}, remaining={len(conns)}") task.cancel()
# Clean up connection registry
await cleanup_ws_connection(websocket, user_id, self._connections)
logger.debug(f"AI UI Control WS disconnected: user={user_id}")
async def send_command(self, user_id: str, command: dict[str, Any]) -> str | None: async def send_command(self, user_id: str, command: dict[str, Any]) -> str | None:
"""Send a UI command to all frontend connections of a user. """Send a UI command to all frontend connections of a user.
@@ -120,6 +195,24 @@ class AIUIControlWSManager:
"""Get list of currently connected user IDs.""" """Get list of currently connected user IDs."""
return list(self._connections.keys()) return list(self._connections.keys())
async def broadcast(self, message: dict[str, Any], tenant_id: uuid.UUID | None = None) -> None:
"""Broadcast a message to all connected users.
If *tenant_id* is provided, publishes via Redis Pub/Sub for multi-worker fanout.
Otherwise, sends directly to all locally connected users.
"""
if tenant_id is not None:
await broadcast_to_tenants(tenant_id, "ai_ui_control", message)
else:
for user_id in list(self._connections.keys()):
conns = self._connections.get(user_id, [])
text = json.dumps(message, default=str)
for ws in conns:
try:
await ws.send_text(text)
except Exception:
logger.warning(f"Failed to broadcast to user {user_id}")
def cleanup_stale(self, timeout_seconds: int = 60) -> None: def cleanup_stale(self, timeout_seconds: int = 60) -> None:
"""Remove stale command tracking entries older than timeout.""" """Remove stale command tracking entries older than timeout."""
now = time.time() now = time.time()
+14 -28
View File
@@ -472,33 +472,29 @@ async def websocket_endpoint(
Authenticates via session cookie. On connect, subscribes user to all their conversations. Authenticates via session cookie. On connect, subscribes user to all their conversations.
""" """
# Verify Origin header against allowed CORS origins from app.core.db import async_session_maker
from app.config import get_settings from app.core.service_container import get_container
from app.core.auth import get_session_data, get_redis, verify_ws_origin
settings = get_settings() # Get WebSocket manager from service container
if not await verify_ws_origin(websocket): container = get_container()
await websocket.close(code=4003, reason="Origin not allowed") if not container.has("comm_websocket"):
await websocket.close(code=4003, reason="Messaging not available")
return return
session_id = websocket.cookies.get(settings.session_cookie_name) ws_manager = container.get("comm_websocket")
if not session_id:
await websocket.close(code=4001, reason="Not authenticated")
return
redis = get_redis() # connect() performs origin check, session auth, tenant check
session_data = await get_session_data(redis, session_id) async with async_session_maker() as db:
if session_data is None: auth = await ws_manager.connect(websocket, db)
await websocket.close(code=4001, reason="Session expired") if auth is None:
return return # connection was rejected and closed by ws_helpers
user_id = session_data["user_id"] user_id = auth["user_id"]
tenant_id = session_data["tenant_id"] tenant_id = auth["tenant_id"]
# Plugin-Gate: check if kommunikation plugin is active (global + tenant) # Plugin-Gate: check if kommunikation plugin is active (global + tenant)
from app.core.permission_registry import get_permission_registry from app.core.permission_registry import get_permission_registry
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
from app.core.db import async_session_maker
import uuid as _uuid import uuid as _uuid
try: try:
registry = get_permission_registry() registry = get_permission_registry()
@@ -518,16 +514,6 @@ async def websocket_endpoint(
await websocket.close(code=4003, reason="Plugin check failed") await websocket.close(code=4003, reason="Plugin check failed")
return return
# Get WebSocket manager from service container
from app.core.service_container import get_container
container = get_container()
if not container.has("comm_websocket"):
await websocket.close(code=4003, reason="Messaging not available")
return
ws_manager = container.get("comm_websocket")
await ws_manager.connect(websocket, user_id)
try: try:
while True: while True:
data = await websocket.receive_text() data = await websocket.receive_text()
@@ -4,10 +4,25 @@ from __future__ import annotations
import json import json
import logging import logging
import uuid
from typing import Any from typing import Any
from fastapi import WebSocket from fastapi import WebSocket
from app.core.ws_helpers import (
authenticate_ws,
check_ws_origin,
check_ws_tenant,
cleanup_ws_connection,
start_heartbeat,
)
from app.core.ws_pubsub import (
broadcast_to_tenants,
get_tenant_channel,
subscribe_to_channel,
)
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,26 +34,82 @@ class WebSocketManager:
self._connections: dict[str, list[WebSocket]] = {} self._connections: dict[str, list[WebSocket]] = {}
# conversation_id (str) → set of user_ids subscribed # conversation_id (str) → set of user_ids subscribed
self._subscriptions: dict[str, set[str]] = {} self._subscriptions: dict[str, set[str]] = {}
# user_id → list of heartbeat tasks
self._heartbeat_tasks: dict[str, list] = {}
# user_id → list of pubsub subscriber tasks
self._pubsub_tasks: dict[str, list] = {}
async def connect(self, websocket: WebSocket, user_id: str) -> None: async def connect(
"""Accept and register a new WebSocket connection.""" self,
websocket: WebSocket,
db: AsyncSession,
) -> dict[str, Any] | None:
"""Accept, authenticate and register a new WebSocket connection.
Performs origin check, session authentication and tenant validation.
Returns the auth dict (user_id, tenant_id, …) on success, or ``None``
if the connection was rejected (already closed).
"""
# Origin / CSRF check
if not await check_ws_origin(websocket):
return None
# Session authentication
auth = await authenticate_ws(websocket, db)
if auth is None:
return None
user_id = auth["user_id"]
tenant_id_str = auth["tenant_id"]
tenant_id = uuid.UUID(tenant_id_str)
user_uuid = uuid.UUID(user_id)
# Tenant membership check
if not await check_ws_tenant(websocket, tenant_id, user_uuid, db):
return None
# Accept the WebSocket
await websocket.accept() await websocket.accept()
# Register connection
if user_id not in self._connections: if user_id not in self._connections:
self._connections[user_id] = [] self._connections[user_id] = []
self._connections[user_id].append(websocket) self._connections[user_id].append(websocket)
# Start heartbeat
hb_task = await start_heartbeat(websocket)
self._heartbeat_tasks.setdefault(user_id, []).append(hb_task)
# Start Redis Pub/Sub subscriber for tenant-wide fanout
channel = get_tenant_channel(tenant_id, "kommunikation")
pubsub_task = await subscribe_to_channel(channel, lambda msg: self._on_pubsub_message(user_id, msg))
self._pubsub_tasks.setdefault(user_id, []).append(pubsub_task)
logger.debug(f"WebSocket connected: user={user_id}, total={len(self._connections[user_id])}") logger.debug(f"WebSocket connected: user={user_id}, total={len(self._connections[user_id])}")
return auth
async def _on_pubsub_message(self, user_id: str, msg: dict[str, Any]) -> None:
"""Handle a message received via Redis Pub/Sub."""
await self.send_to_user(user_id, msg)
async def disconnect(self, websocket: WebSocket, user_id: str) -> None: async def disconnect(self, websocket: WebSocket, user_id: str) -> None:
"""Remove a WebSocket connection.""" """Remove a WebSocket connection and clean up resources."""
conns = self._connections.get(user_id, []) # Cancel heartbeat tasks for this user
if websocket in conns: for task in self._heartbeat_tasks.pop(user_id, []):
conns.remove(websocket) task.cancel()
if not conns: # Cancel pubsub tasks for this user
self._connections.pop(user_id, None) for task in self._pubsub_tasks.pop(user_id, []):
# Remove from all subscriptions task.cancel()
# Clean up connection registry
await cleanup_ws_connection(websocket, user_id, self._connections)
# Remove from subscriptions if no more connections
if user_id not in self._connections:
for conv_id, users in self._subscriptions.items(): for conv_id, users in self._subscriptions.items():
users.discard(user_id) users.discard(user_id)
logger.debug(f"WebSocket disconnected: user={user_id}, remaining={len(conns)}")
logger.debug(f"WebSocket disconnected: user={user_id}")
def subscribe(self, conversation_id: str, user_id: str) -> None: def subscribe(self, conversation_id: str, user_id: str) -> None:
"""Subscribe a user to a conversation's updates.""" """Subscribe a user to a conversation's updates."""
@@ -75,10 +146,17 @@ class WebSocketManager:
continue continue
await self.send_to_user(user_id, message) await self.send_to_user(user_id, message)
async def broadcast(self, message: dict[str, Any]) -> None: async def broadcast(self, message: dict[str, Any], tenant_id: uuid.UUID | None = None) -> None:
"""Broadcast a message to all connected users.""" """Broadcast a message to all connected users.
for user_id in list(self._connections.keys()):
await self.send_to_user(user_id, message) If *tenant_id* is provided, publishes via Redis Pub/Sub for multi-worker fanout.
Otherwise, sends directly to all locally connected users.
"""
if tenant_id is not None:
await broadcast_to_tenants(tenant_id, "kommunikation", message)
else:
for user_id in list(self._connections.keys()):
await self.send_to_user(user_id, message)
def get_online_users(self) -> list[str]: def get_online_users(self) -> list[str]:
"""Get list of currently connected user IDs.""" """Get list of currently connected user IDs."""
+500
View File
@@ -0,0 +1,500 @@
"""Tests for WebSocket helpers (ws_helpers) and Redis Pub/Sub (ws_pubsub).
Covers B-WS-TEST + B-ERR-WS-TEST requirements.
"""
from __future__ import annotations
import asyncio
import json
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from app.core.ws_helpers import (
authenticate_ws,
check_ws_origin,
cleanup_ws_connection,
handle_ws_message,
send_ws_error,
start_heartbeat,
)
from app.core.ws_pubsub import (
broadcast_to_tenants,
get_tenant_channel,
publish_to_channel,
subscribe_to_channel,
)
# ─── Mock WebSocket ───────────────────────────────────────────────────────────
class MockWebSocket:
"""Minimal mock WebSocket for testing WS helpers."""
def __init__(
self,
cookies: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
query_params: dict[str, str] | None = None,
) -> None:
self.cookies = cookies or {}
self.headers = headers or {}
self.query_params = query_params or {}
self._closed = False
self._close_code: int | None = None
self._close_reason: str | None = None
self._sent: list[str] = []
self._accepted = False
async def accept(self) -> None:
self._accepted = True
async def close(self, code: int = 1000, reason: str = "") -> None:
self._closed = True
self._close_code = code
self._close_reason = reason
async def send_text(self, text: str) -> None:
if self._closed:
raise RuntimeError("WebSocket is closed")
self._sent.append(text)
@property
def sent_messages(self) -> list[dict]:
return [json.loads(t) for t in self._sent]
# ─── authenticate_ws ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestAuthenticateWs:
"""Tests for authenticate_ws."""
async def test_authenticate_ws_valid_session(self, db_session, redis_client):
"""authenticate_ws returns user info when session is valid."""
from app.core.auth import create_session, hash_password
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.role import Role
# Create tenant, user, role, membership
tenant = Tenant(name="Test Tenant", slug="test-tenant")
db_session.add(tenant)
await db_session.flush()
user = User(
email="wsauth@test.com",
name="WS Auth Test",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db_session.add(user)
await db_session.flush()
role = Role(
tenant_id=tenant.id,
name="admin",
permissions={"*": {"*": True}},
denied_permissions=[],
field_permissions={},
)
db_session.add(role)
await db_session.flush()
ut = UserTenant(
user_id=user.id,
tenant_id=tenant.id,
is_default=True,
role="admin",
role_id=role.id,
)
db_session.add(ut)
await db_session.flush()
await db_session.commit()
# Create session
session_id, csrf_token = await create_session(
db_session, redis_client, user, tenant.id, role="admin"
)
# Create mock WebSocket with session cookie
ws = MockWebSocket(cookies={"leocrm_session": session_id})
auth = await authenticate_ws(ws, db_session)
assert auth is not None
assert auth["user_id"] == str(user.id)
assert auth["tenant_id"] == str(tenant.id)
assert auth["session_id"] == session_id
assert auth["role"] == "admin"
assert ws._closed is False
async def test_authenticate_ws_missing_cookie(self, db_session):
"""authenticate_ws closes WS and returns None when no session cookie."""
ws = MockWebSocket(cookies={})
auth = await authenticate_ws(ws, db_session)
assert auth is None
assert ws._closed is True
assert ws._close_code == 4401
async def test_authenticate_ws_invalid_session(self, db_session):
"""authenticate_ws closes WS and returns None when session is invalid."""
ws = MockWebSocket(cookies={"leocrm_session": "nonexistent-session-id"})
auth = await authenticate_ws(ws, db_session)
assert auth is None
assert ws._closed is True
assert ws._close_code == 4401
# ─── check_ws_origin ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestCheckWsOrigin:
"""Tests for check_ws_origin."""
async def test_check_ws_origin_valid(self):
"""check_ws_origin returns True for valid origin + CSRF."""
from app.core.auth import create_session, hash_password
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.role import Role
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# We need a full setup with session in Redis for CSRF validation
# Use patch to mock verify_ws_origin returning True
with patch("app.core.ws_helpers.verify_ws_origin", return_value=True):
ws = MockWebSocket()
result = await check_ws_origin(ws)
assert result is True
assert ws._closed is False
async def test_check_ws_origin_invalid(self):
"""check_ws_origin closes WS and returns False for invalid origin."""
with patch("app.core.ws_helpers.verify_ws_origin", return_value=False):
ws = MockWebSocket()
result = await check_ws_origin(ws)
assert result is False
assert ws._closed is True
assert ws._close_code == 4403
# ─── send_ws_error ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestSendWsError:
"""Tests for send_ws_error."""
async def test_send_ws_error_basic(self):
"""send_ws_error sends structured error message."""
ws = MockWebSocket()
await send_ws_error(ws, "test_error", "Something went wrong")
assert len(ws.sent_messages) == 1
msg = ws.sent_messages[0]
assert msg["type"] == "error"
assert msg["code"] == "test_error"
assert msg["detail"] == "Something went wrong"
assert "trace_id" not in msg
async def test_send_ws_error_with_trace_id(self):
"""send_ws_error includes trace_id when provided."""
ws = MockWebSocket()
await send_ws_error(ws, "test_error", "Failed", trace_id="trace-123")
msg = ws.sent_messages[0]
assert msg["trace_id"] == "trace-123"
async def test_send_ws_error_on_closed_ws(self):
"""send_ws_error does not raise when WS is closed."""
ws = MockWebSocket()
ws._closed = True
# Should not raise
await send_ws_error(ws, "closed_error", "WS already closed")
# ─── handle_ws_message ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestHandleWsMessage:
"""Tests for handle_ws_message."""
async def test_handle_ws_message_dispatch(self):
"""handle_ws_message dispatches to correct handler."""
ws = MockWebSocket()
called = False
async def ping_handler(websocket, msg):
nonlocal called
called = True
assert msg["type"] == "ping"
handlers = {"ping": ping_handler}
message = json.dumps({"type": "ping"})
await handle_ws_message(ws, message, handlers)
assert called is True
async def test_handle_ws_message_unknown_type(self):
"""handle_ws_message sends error for unknown type."""
ws = MockWebSocket()
handlers = {"ping": AsyncMock()}
message = json.dumps({"type": "unknown_type"})
await handle_ws_message(ws, message, handlers)
assert len(ws.sent_messages) == 1
msg = ws.sent_messages[0]
assert msg["type"] == "error"
assert msg["code"] == "unknown_type"
async def test_handle_ws_message_handler_exception(self):
"""handle_ws_message sends error when handler raises exception."""
ws = MockWebSocket()
async def bad_handler(websocket, msg):
raise ValueError("Handler crashed")
handlers = {"ping": bad_handler}
message = json.dumps({"type": "ping"})
await handle_ws_message(ws, message, handlers)
assert len(ws.sent_messages) == 1
msg = ws.sent_messages[0]
assert msg["type"] == "error"
assert msg["code"] == "handler_error"
assert "Handler crashed" in msg["detail"]
async def test_handle_ws_message_invalid_json(self):
"""handle_ws_message sends error for invalid JSON."""
ws = MockWebSocket()
handlers = {}
await handle_ws_message(ws, "not json at all", handlers)
assert len(ws.sent_messages) == 1
msg = ws.sent_messages[0]
assert msg["code"] == "invalid_json"
async def test_handle_ws_message_missing_type(self):
"""handle_ws_message sends error when type field is missing."""
ws = MockWebSocket()
handlers = {}
message = json.dumps({"data": "no type here"})
await handle_ws_message(ws, message, handlers)
assert len(ws.sent_messages) == 1
msg = ws.sent_messages[0]
assert msg["code"] == "missing_type"
# ─── cleanup_ws_connection ─────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestCleanupWsConnection:
"""Tests for cleanup_ws_connection."""
async def test_cleanup_removes_from_registry(self):
"""cleanup_ws_connection removes WebSocket from registry."""
ws1 = MockWebSocket()
ws2 = MockWebSocket()
registry: dict[str, list] = {"user1": [ws1, ws2]}
await cleanup_ws_connection(ws1, "user1", registry)
assert "user1" in registry
assert ws1 not in registry["user1"]
assert ws2 in registry["user1"]
assert len(registry["user1"]) == 1
async def test_cleanup_removes_empty_user(self):
"""cleanup_ws_connection removes user entry when no connections left."""
ws = MockWebSocket()
registry: dict[str, list] = {"user1": [ws]}
await cleanup_ws_connection(ws, "user1", registry)
assert "user1" not in registry
async def test_cleanup_unknown_user(self):
"""cleanup_ws_connection handles unknown user gracefully."""
ws = MockWebSocket()
registry: dict[str, list] = {}
await cleanup_ws_connection(ws, "unknown_user", registry)
assert "unknown_user" not in registry
# ─── start_heartbeat ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestStartHeartbeat:
"""Tests for start_heartbeat."""
async def test_heartbeat_sends_ping(self):
"""start_heartbeat sends ping messages at interval."""
ws = MockWebSocket()
task = await start_heartbeat(ws, interval=0)
# Wait a tiny bit for the first ping
await asyncio.sleep(0.05)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert len(ws._sent) >= 1
msg = ws.sent_messages[0]
assert msg["type"] == "ping"
async def test_heartbeat_cancellable(self):
"""start_heartbeat task can be cancelled."""
ws = MockWebSocket()
task = await start_heartbeat(ws, interval=10)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert task.cancelled() or task.done()
# ─── get_tenant_channel ─────────────────────────────────────────────────────────
class TestGetTenantChannel:
"""Tests for get_tenant_channel."""
def test_channel_naming(self):
"""get_tenant_channel returns ws:{tenant_id}:{topic}."""
tenant_id = uuid.uuid4()
channel = get_tenant_channel(tenant_id, "kommunikation")
assert channel == f"ws:{tenant_id}:kommunikation"
def test_channel_naming_different_topic(self):
"""get_tenant_channel works with different topics."""
tenant_id = uuid.uuid4()
channel = get_tenant_channel(tenant_id, "ai_ui_control")
assert channel == f"ws:{tenant_id}:ai_ui_control"
# ─── publish_to_channel + subscribe_to_channel ──────────────────────────────────
@pytest.mark.asyncio
class TestPubSub:
"""Tests for Redis Pub/Sub helpers."""
async def test_publish_subscribe_roundtrip(self, redis_client):
"""publish_to_channel + subscribe_to_channel roundtrip."""
received: list[dict] = []
channel = f"test-roundtrip-{uuid.uuid4()}"
event = asyncio.Event()
async def handler(msg: dict) -> None:
received.append(msg)
event.set()
# Subscribe
task = await subscribe_to_channel(channel, handler)
# Give subscriber a moment to connect
await asyncio.sleep(0.1)
# Publish
test_msg = {"type": "test", "data": "hello"}
await publish_to_channel(channel, test_msg)
# Wait for message
await asyncio.wait_for(event.wait(), timeout=2.0)
assert len(received) == 1
assert received[0]["type"] == "test"
assert received[0]["data"] == "hello"
# Cleanup
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def test_broadcast_to_tenants(self, redis_client):
"""broadcast_to_tenants publishes to the correct tenant channel."""
tenant_id = uuid.uuid4()
received: list[dict] = []
event = asyncio.Event()
channel = get_tenant_channel(tenant_id, "test_topic")
async def handler(msg: dict) -> None:
received.append(msg)
event.set()
task = await subscribe_to_channel(channel, handler)
await asyncio.sleep(0.1)
test_msg = {"type": "broadcast", "content": "tenant message"}
await broadcast_to_tenants(tenant_id, "test_topic", test_msg)
await asyncio.wait_for(event.wait(), timeout=2.0)
assert len(received) == 1
assert received[0]["type"] == "broadcast"
assert received[0]["content"] == "tenant message"
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def test_publish_to_channel_no_subscribers(self, redis_client):
"""publish_to_channel works even with no subscribers."""
channel = f"test-no-sub-{uuid.uuid4()}"
await publish_to_channel(channel, {"type": "noop"})
# Should not raise
async def test_subscribe_multiple_messages(self, redis_client):
"""subscribe_to_channel handler receives multiple messages."""
received: list[dict] = []
channel = f"test-multi-{uuid.uuid4()}"
count_event = asyncio.Event()
msg_count = 0
async def handler(msg: dict) -> None:
nonlocal msg_count
received.append(msg)
msg_count += 1
if msg_count >= 3:
count_event.set()
task = await subscribe_to_channel(channel, handler)
await asyncio.sleep(0.1)
for i in range(3):
await publish_to_channel(channel, {"type": "msg", "index": i})
await asyncio.wait_for(count_event.wait(), timeout=3.0)
assert len(received) == 3
assert received[0]["index"] == 0
assert received[2]["index"] == 2
task.cancel()
try:
await task
except asyncio.CancelledError:
pass