2026-07-11 11:23:05 +02:00
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-07-11 11:23:40 +02:00
|
|
|
from starlette.websockets import WebSocketDisconnect
|
2026-07-11 11:23:05 +02:00
|
|
|
|
|
|
|
|
from app.api.ws_manager import WebSocketManager
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeWebSocket:
|
|
|
|
|
"""Minimal stand-in for starlette WebSocket."""
|
|
|
|
|
|
2026-07-11 11:23:40 +02:00
|
|
|
def __init__(self, incoming=None, raise_on_send=False, disconnect_on_receive=False):
|
2026-07-11 11:23:05 +02:00
|
|
|
self.accepted = False
|
|
|
|
|
self.closed_code = None
|
|
|
|
|
self.sent = []
|
|
|
|
|
self._incoming = list(incoming or [])
|
2026-07-11 11:23:40 +02:00
|
|
|
self.raise_on_send = raise_on_send
|
|
|
|
|
self.disconnect_on_receive = disconnect_on_receive
|
2026-07-11 11:23:05 +02:00
|
|
|
|
|
|
|
|
async def accept(self):
|
|
|
|
|
self.accepted = True
|
|
|
|
|
|
|
|
|
|
async def receive_text(self):
|
2026-07-11 11:23:40 +02:00
|
|
|
if self.disconnect_on_receive:
|
|
|
|
|
raise WebSocketDisconnect(code=1000)
|
2026-07-11 11:23:05 +02:00
|
|
|
if not self._incoming:
|
|
|
|
|
await asyncio.sleep(3600) # never sends -> block until cancelled
|
|
|
|
|
return self._incoming.pop(0)
|
|
|
|
|
|
|
|
|
|
async def send_text(self, text):
|
2026-07-11 11:23:40 +02:00
|
|
|
if self.raise_on_send:
|
|
|
|
|
raise RuntimeError("connection closed")
|
2026-07-11 11:23:05 +02:00
|
|
|
self.sent.append(text)
|
|
|
|
|
|
|
|
|
|
async def close(self, code=1000):
|
|
|
|
|
self.closed_code = code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_valid_auth_registers_and_can_receive(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"app.api.ws_manager.JWTBearer",
|
|
|
|
|
lambda: type("B", (), {"verify_jwt": lambda self, jwtoken: True})(),
|
|
|
|
|
)
|
|
|
|
|
mgr = WebSocketManager()
|
|
|
|
|
ws = FakeWebSocket([json.dumps({"type": "auth", "token": "good"})])
|
|
|
|
|
|
|
|
|
|
id_, authed = await mgr.connect(ws)
|
|
|
|
|
|
|
|
|
|
assert authed is True
|
|
|
|
|
assert id_ is not None
|
|
|
|
|
assert ws.accepted is True
|
|
|
|
|
await mgr.send_to_single(id_, "btc_info", {"blocks": 1})
|
|
|
|
|
assert json.loads(ws.sent[0]) == {"event": "btc_info", "data": {"blocks": 1}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_invalid_token_closes_4401(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"app.api.ws_manager.JWTBearer",
|
|
|
|
|
lambda: type("B", (), {"verify_jwt": lambda self, jwtoken: False})(),
|
|
|
|
|
)
|
|
|
|
|
mgr = WebSocketManager()
|
|
|
|
|
ws = FakeWebSocket([json.dumps({"type": "auth", "token": "bad"})])
|
|
|
|
|
|
|
|
|
|
id_, authed = await mgr.connect(ws)
|
|
|
|
|
|
|
|
|
|
assert authed is False
|
|
|
|
|
assert id_ is None
|
|
|
|
|
assert ws.closed_code == 4401
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_auth_timeout_closes_4408(monkeypatch):
|
|
|
|
|
monkeypatch.setattr("app.api.ws_manager.AUTH_TIMEOUT_SECONDS", 0.05)
|
|
|
|
|
mgr = WebSocketManager()
|
|
|
|
|
ws = FakeWebSocket([]) # never sends
|
|
|
|
|
|
|
|
|
|
id_, authed = await mgr.connect(ws)
|
|
|
|
|
|
|
|
|
|
assert authed is False
|
|
|
|
|
assert ws.closed_code == 4408
|
2026-07-11 11:23:40 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_non_dict_json_first_frame_closes_4401(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"app.api.ws_manager.JWTBearer",
|
|
|
|
|
lambda: type("B", (), {"verify_jwt": lambda self, jwtoken: True})(),
|
|
|
|
|
)
|
|
|
|
|
mgr = WebSocketManager()
|
|
|
|
|
ws = FakeWebSocket([json.dumps("42")]) # valid JSON, not a dict
|
|
|
|
|
|
|
|
|
|
id_, authed = await mgr.connect(ws)
|
|
|
|
|
|
|
|
|
|
assert authed is False
|
|
|
|
|
assert id_ is None
|
|
|
|
|
assert ws.closed_code == 4401
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_client_disconnect_during_auth_returns_unauthenticated(monkeypatch):
|
|
|
|
|
mgr = WebSocketManager()
|
|
|
|
|
ws = FakeWebSocket(disconnect_on_receive=True)
|
|
|
|
|
|
|
|
|
|
id_, authed = await mgr.connect(ws)
|
|
|
|
|
|
|
|
|
|
assert authed is False
|
|
|
|
|
assert id_ is None
|
|
|
|
|
# socket is already gone; connect() must not attempt to close() it
|
|
|
|
|
assert ws.closed_code is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_broadcast_to_all_delivers_to_multiple_connections(monkeypatch):
|
|
|
|
|
mgr = WebSocketManager()
|
|
|
|
|
ws1 = FakeWebSocket()
|
|
|
|
|
ws2 = FakeWebSocket()
|
|
|
|
|
mgr._connections[1] = ws1
|
|
|
|
|
mgr._connections[2] = ws2
|
|
|
|
|
|
|
|
|
|
await mgr.broadcast_to_all("btc_info", {"blocks": 1})
|
|
|
|
|
|
|
|
|
|
assert json.loads(ws1.sent[0]) == {"event": "btc_info", "data": {"blocks": 1}}
|
|
|
|
|
assert json.loads(ws2.sent[0]) == {"event": "btc_info", "data": {"blocks": 1}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_send_to_single_drops_connection_on_send_failure(monkeypatch):
|
|
|
|
|
mgr = WebSocketManager()
|
|
|
|
|
ws = FakeWebSocket(raise_on_send=True)
|
|
|
|
|
mgr._connections[1] = ws
|
|
|
|
|
|
|
|
|
|
await mgr.send_to_single(1, "btc_info", {"blocks": 1})
|
|
|
|
|
|
|
|
|
|
assert 1 not in mgr._connections
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_broadcast_to_all_drops_failing_connection_but_reaches_others(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
mgr = WebSocketManager()
|
|
|
|
|
bad_ws = FakeWebSocket(raise_on_send=True)
|
|
|
|
|
good_ws = FakeWebSocket()
|
|
|
|
|
mgr._connections[1] = bad_ws
|
|
|
|
|
mgr._connections[2] = good_ws
|
|
|
|
|
|
|
|
|
|
await mgr.broadcast_to_all("btc_info", {"blocks": 1})
|
|
|
|
|
|
|
|
|
|
assert 1 not in mgr._connections
|
|
|
|
|
assert 2 in mgr._connections
|
|
|
|
|
assert json.loads(good_ws.sent[0]) == {"event": "btc_info", "data": {"blocks": 1}}
|