Files
supervisor/tests/homeassistant/test_websocket.py
7fb621234e Add Unix socket support for Core communication with feature flag (#6742)
* Use Unix socket for Supervisor to Core communication

Reintroduce Unix socket support for Supervisor-to-Core communication
(reverted in #6735) with the addition of a feature flag gate. The
feature is now controlled by the `core_unix_socket` feature flag and
disabled by default.

When enabled and Core version supports it, Supervisor communicates with
Core via a Unix socket at /run/os/core.sock instead of TCP. This
eliminates the need for access token authentication on the socket path,
as Core authenticates the peer by the socket connection itself.

Key changes:
- Add FeatureFlag.CORE_UNIX_SOCKET to gate the feature
- HomeAssistantAPI: transport-aware session/url/websocket management
- WSClient: separate connect() (Unix, no auth) and connect_with_auth()
  (TCP) class methods with proper error handling
- APIProxy delegates websocket setup to api.connect_websocket()
- Container state tracking for Unix session lifecycle
- CI builder mounts /run/supervisor for integration tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Sort feature flags alphabetically

* Drop per-call max_msg_size from WSClient

Hardcode the WebSocket message size cap to 64 MB in WSClient and remove
the parameter from WSClient.connect, connect_with_auth, _ws_connect,
and HomeAssistantAPI.connect_websocket. This was only ever overridden
by APIProxy, so threading it through four layers was unnecessary.

max_msg_size is a cap, not a pre-allocation; aiohttp only grows buffers
to the size of actual incoming messages. Supervisor's own control
channel never approaches 64 MB, so unifying the limit has no runtime
cost.

Addresses review feedback on #6742.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 15:03:05 +02:00

261 lines
8.8 KiB
Python

"""Test websocket."""
# pylint: disable=import-error
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.exceptions import HomeAssistantAPIError, HomeAssistantWSConnectionError
from supervisor.homeassistant.const import WSEvent, WSType
from supervisor.homeassistant.websocket import WSClient
async def test_send_command(coresys: CoreSys, ha_ws_client: AsyncMock):
"""Test sending a command returns a response."""
await coresys.homeassistant.websocket.async_send_command({"type": "test"})
ha_ws_client.async_send_command.assert_called_with({"type": "test"})
await coresys.homeassistant.websocket.async_supervisor_update_event(
"test", {"lorem": "ipsum"}
)
ha_ws_client.async_send_command.assert_called_with(
{
"type": WSType.SUPERVISOR_EVENT,
"data": {
"event": WSEvent.SUPERVISOR_UPDATE,
"update_key": "test",
"data": {"lorem": "ipsum"},
},
}
)
async def test_fire_and_forget_during_startup(
coresys: CoreSys, ha_ws_client: AsyncMock
):
"""Test fire-and-forget commands queue during startup and replay when running."""
await coresys.homeassistant.websocket.load()
await coresys.core.set_state(CoreState.SETUP)
await coresys.homeassistant.websocket.async_supervisor_update_event(
"test", {"lorem": "ipsum"}
)
ha_ws_client.async_send_command.assert_not_called()
await coresys.core.set_state(CoreState.RUNNING)
await asyncio.sleep(0)
assert ha_ws_client.async_send_command.call_count == 2
assert ha_ws_client.async_send_command.call_args_list[0][0][0] == {
"type": WSType.SUPERVISOR_EVENT,
"data": {
"event": WSEvent.SUPERVISOR_UPDATE,
"update_key": "test",
"data": {"lorem": "ipsum"},
},
}
assert ha_ws_client.async_send_command.call_args_list[1][0][0] == {
"type": WSType.SUPERVISOR_EVENT,
"data": {
"event": WSEvent.SUPERVISOR_UPDATE,
"update_key": "info",
"data": {"state": "running"},
},
}
ha_ws_client.reset_mock()
await coresys.core.set_state(CoreState.SHUTDOWN)
await coresys.homeassistant.websocket.async_supervisor_update_event(
"test", {"lorem": "ipsum"}
)
ha_ws_client.async_send_command.assert_not_called()
async def test_send_command_core_not_reachable(
coresys: CoreSys, ha_ws_client: AsyncMock
):
"""Test async_send_command raises when Core API is not reachable."""
ha_ws_client.connected = False
with (
patch.object(coresys.homeassistant.api, "check_api_state", return_value=False),
pytest.raises(HomeAssistantWSConnectionError, match="not reachable"),
):
await coresys.homeassistant.websocket.async_send_command({"type": "test"})
ha_ws_client.async_send_command.assert_not_called()
async def test_fire_and_forget_core_not_reachable(
coresys: CoreSys, ha_ws_client: AsyncMock
):
"""Test fire-and-forget command silently skips when Core API is not reachable."""
ha_ws_client.connected = False
with patch.object(coresys.homeassistant.api, "check_api_state", return_value=False):
await coresys.homeassistant.websocket._async_send_command({"type": "test"})
ha_ws_client.async_send_command.assert_not_called()
async def test_send_command_during_shutdown(coresys: CoreSys, ha_ws_client: AsyncMock):
"""Test async_send_command raises during shutdown."""
await coresys.core.set_state(CoreState.SHUTDOWN)
with pytest.raises(HomeAssistantWSConnectionError, match="shutting down"):
await coresys.homeassistant.websocket.async_send_command({"type": "test"})
ha_ws_client.async_send_command.assert_not_called()
# --- WSClient ---
def _mock_ws_client(messages: list[dict]) -> MagicMock:
"""Create a mock aiohttp WebSocket client that returns messages in sequence."""
client = AsyncMock(spec=aiohttp.ClientWebSocketResponse)
client.receive_json = AsyncMock(side_effect=messages)
client.send_json = AsyncMock()
client.close = AsyncMock()
client.closed = False
return client
async def test_ws_connect_error():
"""Test _ws_connect wraps ClientConnectorError."""
session = AsyncMock()
session.ws_connect = AsyncMock(
side_effect=aiohttp.ClientConnectorError(
MagicMock(), OSError("Connection refused")
)
)
with pytest.raises(HomeAssistantWSConnectionError, match="Can't connect"):
await WSClient._ws_connect(session, "ws://localhost/api/websocket")
async def test_connect_unix_success():
"""Test WSClient.connect succeeds with auth_ok."""
session = AsyncMock()
ws = _mock_ws_client([{"type": "auth_ok", "ha_version": "2026.4.0"}])
session.ws_connect = AsyncMock(return_value=ws)
client = await WSClient.connect(session, "ws://localhost/api/websocket")
assert client.ha_version == "2026.4.0"
assert client.connected is True
ws.close.assert_not_called()
async def test_connect_unix_unexpected_message():
"""Test WSClient.connect raises and closes on unexpected message."""
session = AsyncMock()
ws = _mock_ws_client([{"type": "auth_required", "ha_version": "2026.4.0"}])
session.ws_connect = AsyncMock(return_value=ws)
with pytest.raises(HomeAssistantAPIError, match="Expected auth_ok"):
await WSClient.connect(session, "ws://localhost/api/websocket")
ws.close.assert_called_once()
async def test_connect_unix_bad_json():
"""Test WSClient.connect wraps ValueError from bad JSON."""
session = AsyncMock()
ws = AsyncMock(spec=aiohttp.ClientWebSocketResponse)
ws.receive_json = AsyncMock(side_effect=ValueError("bad json"))
ws.close = AsyncMock()
session.ws_connect = AsyncMock(return_value=ws)
with pytest.raises(HomeAssistantAPIError, match="Unexpected error"):
await WSClient.connect(session, "ws://localhost/api/websocket")
ws.close.assert_called_once()
async def test_connect_with_auth_success():
"""Test WSClient.connect_with_auth succeeds with auth handshake."""
session = AsyncMock()
ws = _mock_ws_client(
[
{"type": "auth_required", "ha_version": "2026.4.0"},
{"type": "auth_ok", "ha_version": "2026.4.0"},
]
)
session.ws_connect = AsyncMock(return_value=ws)
client = await WSClient.connect_with_auth(
session, "ws://localhost/api/websocket", "test_token"
)
assert client.ha_version == "2026.4.0"
ws.send_json.assert_called_once()
ws.close.assert_not_called()
async def test_connect_with_auth_unexpected_first_message():
"""Test connect_with_auth raises on unexpected first message."""
session = AsyncMock()
ws = _mock_ws_client([{"type": "auth_ok", "ha_version": "2026.4.0"}])
session.ws_connect = AsyncMock(return_value=ws)
with pytest.raises(HomeAssistantAPIError, match="Expected auth_required"):
await WSClient.connect_with_auth(
session, "ws://localhost/api/websocket", "test_token"
)
ws.close.assert_called_once()
async def test_connect_with_auth_rejected():
"""Test connect_with_auth raises on auth rejection."""
session = AsyncMock()
ws = _mock_ws_client(
[
{"type": "auth_required", "ha_version": "2026.4.0"},
{"type": "auth_invalid", "message": "Invalid password"},
]
)
session.ws_connect = AsyncMock(return_value=ws)
with pytest.raises(HomeAssistantAPIError, match="AUTH NOT OK"):
await WSClient.connect_with_auth(
session, "ws://localhost/api/websocket", "bad_token"
)
ws.close.assert_called_once()
async def test_connect_with_auth_missing_key():
"""Test connect_with_auth wraps KeyError from missing keys."""
session = AsyncMock()
ws = _mock_ws_client([{"no_type_key": "oops"}])
session.ws_connect = AsyncMock(return_value=ws)
with pytest.raises(HomeAssistantAPIError, match="Unexpected error"):
await WSClient.connect_with_auth(
session, "ws://localhost/api/websocket", "token"
)
ws.close.assert_called_once()
async def test_ws_client_close():
"""Test WSClient.close cancels pending futures and closes connection."""
ws = AsyncMock(spec=aiohttp.ClientWebSocketResponse)
ws.closed = False
ws.close = AsyncMock()
client = WSClient.__new__(WSClient)
client.ha_version = "2026.4.0"
client.client = ws
client._message_id = 0
client._futures = {}
# Add a pending future
loop = asyncio.get_running_loop()
future = loop.create_future()
client._futures[1] = future
await client.close()
assert future.done()
with pytest.raises(HomeAssistantWSConnectionError):
future.result()
ws.close.assert_called_once()