Files
roboco/tests/unit/api/test_websocket_auth.py
T

199 lines
7.7 KiB
Python
Raw Normal View History

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 (
ConnectionManager,
agent_stream,
channel_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()
# ---------------------------------------------------------------------------
# The channel stream must be usable by a panel-token holder. It previously
# called validate_channel_access, which HTTP-loopbacked to a non-existent
# /api/permissions/check endpoint — every connection 404'd → False → the stream
# closed with WS_1008_POLICY_VIOLATION for every client (the channel live-stream
# was dead). Post-F004 the panel-token gate IS the channel-stream authorization
# (the CEO panel is the sole WS client and may view every channel), so the
# broken loopback check is removed rather than replaced with theater.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_channel_stream_accepts_panel_token_holder(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A panel-token holder supplying an agent_id query param is accepted and
registered on the channel stream — not fail-closed by a dead permission
check that 404s against a non-existent endpoint."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
token = issue_agent_token(CEO_AGENT_ID, "ceo", "")
channel_id = uuid4()
viewer_id = uuid4()
mgr = ConnectionManager()
ws = _mock_ws(
headers={"x-agent-token": token},
query={"agent_id": str(viewer_id)},
)
monkeypatch.setattr("roboco.api.websocket.manager", mgr)
await channel_stream(ws, channel_id)
ws.accept.assert_awaited_once()
# Not fail-closed by a dead permission check.
ws.close.assert_not_awaited()
# The "connected" confirmation is sent immediately after connect_channel
# registers the socket, and its subscriber_count proves the socket was in
# the channel's subscription set at confirmation time (the mock then raises
# WebSocketDisconnect so the finally disconnects it — the normal clean
# exit, not a fail-close).
confirmation = ws.send_json.await_args.args[0]
assert confirmation["type"] == "connected"
assert confirmation["channel_id"] == str(channel_id)
assert confirmation["subscriber_count"] == 1
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()