1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-05-19 14:18:53 +01:00
Files
supervisor/tests/api/test_discovery.py
T
Stefan Agner 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

157 lines
5.1 KiB
Python

"""Test discovery API."""
import logging
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from aiohttp.test_utils import TestClient
import pytest
from supervisor.addons.addon import App
from supervisor.const import AppState
from supervisor.coresys import CoreSys
from supervisor.discovery import Message
from tests.common import load_json_fixture
from tests.const import TEST_ADDON_SLUG
@pytest.mark.parametrize("api_client", ["local_ssh"], indirect=True)
async def test_api_discovery_forbidden(
api_client: TestClient, caplog: pytest.LogCaptureFixture, install_app_ssh
):
"""Test app sending discovery message for an unregistered service."""
caplog.clear()
with caplog.at_level(logging.ERROR):
resp = await api_client.post(
"/discovery", json={"service": "mqtt", "config": {}}
)
assert resp.status == 403
result = await resp.json()
assert result["result"] == "error"
assert (
result["message"]
== "Apps must list services they provide via discovery in their config!"
)
assert "Please report this to the maintainer of the app" in caplog.text
@pytest.mark.parametrize(
"skip_state", [AppState.ERROR, AppState.STOPPED, AppState.STARTUP]
)
async def test_api_list_discovery(
api_client: TestClient,
coresys: CoreSys,
install_app_ssh: App,
skip_state: AppState,
):
"""Test listing discovery messages only returns ones for healthy services."""
with (
patch(
"supervisor.utils.common.read_json_or_yaml_file",
return_value=load_json_fixture("discovery.json"),
),
patch("supervisor.utils.common.Path.is_file", return_value=True),
):
await coresys.discovery.read_data()
await coresys.discovery.load()
assert coresys.discovery.list_messages == [
Message(addon="core_mosquitto", service="mqtt", config=ANY, uuid=ANY),
Message(addon="local_ssh", service="adguard", config=ANY, uuid=ANY),
]
install_app_ssh.state = AppState.STARTED
resp = await api_client.get("/discovery")
assert resp.status == 200
result = await resp.json()
assert result["data"]["discovery"] == [
{
"addon": "local_ssh",
"service": "adguard",
"config": ANY,
"uuid": ANY,
}
]
install_app_ssh.state = skip_state
resp = await api_client.get("/discovery")
assert resp.status == 200
result = await resp.json()
assert result["data"]["discovery"] == []
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_api_send_del_discovery(
api_client: TestClient,
coresys: CoreSys,
install_app_ssh: App,
websession: MagicMock,
):
"""Test adding and removing discovery."""
install_app_ssh.data["discovery"] = ["test"]
coresys.homeassistant.api._ensure_access_token = AsyncMock() # pylint: disable=protected-access
resp = await api_client.post("/discovery", json={"service": "test", "config": {}})
assert resp.status == 200
result = await resp.json()
uuid = result["data"]["uuid"]
coresys.websession.request.assert_called_once()
assert coresys.websession.request.call_args.args[0] == "post"
assert (
coresys.websession.request.call_args.args[1]
== f"http://172.30.32.1:8123/api/hassio_push/discovery/{uuid}"
)
assert coresys.websession.request.call_args.kwargs["json"] == {
"addon": TEST_ADDON_SLUG,
"service": "test",
"uuid": uuid,
}
message = coresys.discovery.get(uuid)
assert message.addon == TEST_ADDON_SLUG
assert message.service == "test"
assert message.config == {}
coresys.websession.request.reset_mock()
resp = await api_client.delete(f"/discovery/{uuid}")
assert resp.status == 200
coresys.websession.request.assert_called_once()
assert coresys.websession.request.call_args.args[0] == "delete"
assert (
coresys.websession.request.call_args.args[1]
== f"http://172.30.32.1:8123/api/hassio_push/discovery/{uuid}"
)
assert coresys.websession.request.call_args.kwargs["json"] == {
"addon": TEST_ADDON_SLUG,
"service": "test",
"uuid": uuid,
}
assert coresys.discovery.get(uuid) is None
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_api_invalid_discovery(api_client: TestClient, install_app_ssh: App):
"""Test invalid discovery messages."""
install_app_ssh.data["discovery"] = ["test"]
resp = await api_client.post("/discovery", json={"service": "test"})
assert resp.status == 400
resp = await api_client.post("/discovery", json={"service": "test", "config": None})
assert resp.status == 400
@pytest.mark.parametrize(
("method", "url"),
[("get", "/discovery/bad"), ("delete", "/discovery/bad")],
)
async def test_discovery_not_found(api_client: TestClient, method: str, url: str):
"""Test discovery not found error."""
resp = await api_client.request(method, url)
assert resp.status == 404
resp = await resp.json()
assert resp["message"] == "Discovery message not found"