2026-06-29 05:38:21 +02:00
|
|
|
"""WebSocket streams (/ws/*, operator-only — the panel is the sole WS client)
|
|
|
|
|
enforce the HMAC panel/CEO token gate when ROBOCO_AGENT_AUTH_REQUIRED=true:
|
|
|
|
|
each per-agent WS upgrade requires + verifies the CEO token in strict mode
|
|
|
|
|
and rejects a forged token even in dev mode (same contract as the HTTP role
|
|
|
|
|
gates).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi import WebSocketDisconnect, status
|
|
|
|
|
from roboco.agents_config import CEO_AGENT_ID, issue_agent_token
|
|
|
|
|
from roboco.api.websocket import (
|
|
|
|
|
agent_stream,
|
|
|
|
|
notification_stream,
|
2026-06-30 08:08:35 +02:00
|
|
|
system_stream,
|
2026-06-29 05:38:21 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
import pytest as _pytest # noqa: F401
|
|
|
|
|
|
|
|
|
|
_SECRET = "test-secret-for-ws-auth"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mock_ws(headers: dict[str, str] | None, query: dict[str, str] | None) -> MagicMock:
|
|
|
|
|
ws = MagicMock()
|
|
|
|
|
ws.accept = AsyncMock()
|
|
|
|
|
ws.close = AsyncMock()
|
|
|
|
|
ws.send_json = AsyncMock()
|
|
|
|
|
ws.send_text = AsyncMock()
|
|
|
|
|
# One pong then disconnect so the receive loop exits after a successful gate.
|
|
|
|
|
ws.receive_text = AsyncMock(side_effect=["ping", WebSocketDisconnect()])
|
|
|
|
|
ws.headers = headers or {}
|
|
|
|
|
ws.query_params = query or {}
|
|
|
|
|
return ws
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_notification_stream_rejects_missing_token_when_required(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Strict mode + no X-Agent-Token => policy-violation close, never accepted."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
ws = _mock_ws(headers={}, query={})
|
|
|
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
|
|
|
mp.setattr(
|
|
|
|
|
"roboco.api.websocket.validate_agent_exists",
|
|
|
|
|
AsyncMock(return_value=True),
|
|
|
|
|
)
|
|
|
|
|
await notification_stream(ws, agent_id)
|
|
|
|
|
ws.close.assert_awaited_once()
|
|
|
|
|
assert ws.close.await_args.kwargs["code"] == status.WS_1008_POLICY_VIOLATION
|
|
|
|
|
ws.accept.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_notification_stream_rejects_forged_token_even_in_dev(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Even in dev mode a presented-but-forged token is rejected."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
ws = _mock_ws(headers={"x-agent-token": "forged-not-a-real-hmac"}, query={})
|
|
|
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
|
|
|
mp.setattr(
|
|
|
|
|
"roboco.api.websocket.validate_agent_exists",
|
|
|
|
|
AsyncMock(return_value=True),
|
|
|
|
|
)
|
|
|
|
|
await notification_stream(ws, agent_id)
|
|
|
|
|
ws.close.assert_awaited_once()
|
|
|
|
|
assert ws.close.await_args.kwargs["code"] == status.WS_1008_POLICY_VIOLATION
|
|
|
|
|
ws.accept.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_notification_stream_accepts_valid_panel_token(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A valid CEO panel token passes the gate and the socket is accepted."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
token = issue_agent_token(CEO_AGENT_ID, "ceo", "")
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
ws = _mock_ws(headers={"x-agent-token": token}, query={})
|
|
|
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
|
|
|
mp.setattr(
|
|
|
|
|
"roboco.api.websocket.validate_agent_exists",
|
|
|
|
|
AsyncMock(return_value=True),
|
|
|
|
|
)
|
|
|
|
|
await notification_stream(ws, agent_id)
|
|
|
|
|
ws.accept.assert_awaited_once()
|
|
|
|
|
ws.close.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_agent_stream_rejects_missing_token_when_required(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""The gate is wired into agent_stream too (viewer_id query param path)."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
target_id = uuid4()
|
|
|
|
|
viewer_id = uuid4()
|
|
|
|
|
ws = _mock_ws(headers={}, query={"viewer_id": str(viewer_id)})
|
|
|
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
|
|
|
mp.setattr(
|
|
|
|
|
"roboco.api.websocket.validate_agent_exists",
|
|
|
|
|
AsyncMock(return_value=True),
|
|
|
|
|
)
|
|
|
|
|
await agent_stream(ws, target_id)
|
|
|
|
|
ws.close.assert_awaited_once()
|
|
|
|
|
assert ws.close.await_args.kwargs["code"] == status.WS_1008_POLICY_VIOLATION
|
|
|
|
|
ws.accept.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 08:08:35 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_system_stream_rejects_missing_token_when_required(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""``/ws/system`` was the only /ws/* stream that was ungated (#24). In strict
|
|
|
|
|
mode a missing CEO token must close it with policy-violation, never accept —
|
|
|
|
|
matching every sibling /ws/* handler."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
ws = _mock_ws(headers={}, query={})
|
|
|
|
|
await system_stream(ws)
|
|
|
|
|
ws.close.assert_awaited_once()
|
|
|
|
|
assert ws.close.await_args.kwargs["code"] == status.WS_1008_POLICY_VIOLATION
|
|
|
|
|
ws.accept.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_system_stream_rejects_forged_token_even_in_dev(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A presented-but-forged token is rejected even in dev mode."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
|
|
|
|
ws = _mock_ws(headers={"x-agent-token": "forged-not-a-real-hmac"}, query={})
|
|
|
|
|
await system_stream(ws)
|
|
|
|
|
ws.close.assert_awaited_once()
|
|
|
|
|
assert ws.close.await_args.kwargs["code"] == status.WS_1008_POLICY_VIOLATION
|
|
|
|
|
ws.accept.assert_not_awaited()
|