@@ -43,6 +43,32 @@ def mock_client():
4343 return client
4444
4545
46+ @pytest .fixture
47+ def non_addon_install ():
48+ """Force `is_running_in_addon()` False so `get_addon_logs` takes the
49+ HA-Core-proxy fallback path. Required for tests that mock
50+ `httpx_client.request` — the Supervisor-direct branch uses a fresh
51+ `httpx.AsyncClient` and would bypass that mock entirely.
52+ """
53+ with patch (
54+ "ha_mcp.client.rest_client.is_running_in_addon" , return_value = False
55+ ):
56+ yield
57+
58+
59+ @pytest .fixture
60+ def addon_install ():
61+ """Force `is_running_in_addon()` True and stub `SUPERVISOR_TOKEN` so
62+ `get_addon_logs` takes the direct Supervisor REST API path."""
63+ with (
64+ patch (
65+ "ha_mcp.client.rest_client.is_running_in_addon" , return_value = True
66+ ),
67+ patch .dict ("os.environ" , {"SUPERVISOR_TOKEN" : "supervisor-token-test" }),
68+ ):
69+ yield
70+
71+
4672def _register_and_collect (client : Any ) -> dict [str , Any ]:
4773 """Register utility tools on a collector mcp and return the registered tools.
4874
@@ -69,7 +95,19 @@ def _parse_tool_error(exc_info: pytest.ExceptionInfo[ToolError]) -> dict[str, An
6995
7096
7197class TestGetAddonLogs :
72- """Tests for the REST-client `get_addon_logs` method (the core fix)."""
98+ """Tests for the REST-client `get_addon_logs` method on non-addon installs.
99+
100+ These exercise the HA-Core-proxy fallback branch (`/hassio/addons/{slug}/logs`)
101+ via `httpx_client.request`. The `non_addon_install` fixture forces
102+ `is_running_in_addon()` False so `get_addon_logs` doesn't take the
103+ Supervisor-direct branch — that path opens a fresh `httpx.AsyncClient`
104+ and would bypass the `mock_client.httpx_client` mock entirely.
105+ """
106+
107+ @pytest .fixture (autouse = True )
108+ def _force_non_addon (self , non_addon_install ):
109+ """Apply `non_addon_install` to every test in this class."""
110+ yield
73111
74112 @pytest .mark .asyncio
75113 async def test_returns_text_on_200 (self , mock_client ):
@@ -167,6 +205,202 @@ async def test_does_not_parse_json(self, mock_client):
167205 mock_response .json .assert_not_called ()
168206
169207
208+ class TestGetAddonLogsViaSupervisor :
209+ """Tests for the Supervisor-direct branch of `get_addon_logs`.
210+
211+ Regression coverage for #1116: on add-on installs, the HA-Core-proxy path
212+ `/api/hassio/addons/{slug}/logs` returns 403 for every slug because HA Core
213+ rejects the Supervisor-token+route combination. The fix routes around HA
214+ Core entirely on add-on installs by hitting the documented Supervisor REST
215+ API at `http://supervisor/addons/{slug}/logs` with the Supervisor token.
216+ """
217+
218+ @pytest .fixture
219+ def mock_async_client_class (self ):
220+ """Patch `httpx.AsyncClient` (the class) inside `rest_client` so the
221+ ``async with httpx.AsyncClient(...) as client:`` block returns a
222+ controllable mock client. Yields the inner client mock so each test
223+ can configure ``.get`` directly.
224+ """
225+ inner_client = MagicMock ()
226+ inner_client .get = AsyncMock ()
227+
228+ cm = MagicMock ()
229+ cm .__aenter__ = AsyncMock (return_value = inner_client )
230+ cm .__aexit__ = AsyncMock (return_value = None )
231+
232+ client_class = MagicMock (return_value = cm )
233+ with patch (
234+ "ha_mcp.client.rest_client.httpx.AsyncClient" , client_class
235+ ):
236+ yield inner_client , client_class
237+
238+ @pytest .mark .asyncio
239+ async def test_uses_direct_supervisor_url_and_supervisor_token (
240+ self , mock_client , addon_install , mock_async_client_class
241+ ):
242+ """The fix's primary contract: on add-on installs, the request must go
243+ to `http://supervisor/addons/{slug}/logs` with `Authorization: Bearer
244+ ${SUPERVISOR_TOKEN}` — NOT through HA Core's `/api/hassio/...` proxy
245+ with the user's HA token.
246+ """
247+ inner_client , _ = mock_async_client_class
248+ mock_response = MagicMock ()
249+ mock_response .status_code = 200
250+ mock_response .text = "addon log line 1\n ready\n "
251+ inner_client .get .return_value = mock_response
252+
253+ result = await mock_client .get_addon_logs ("81f33d0f_ha_mcp" )
254+
255+ assert "addon log line 1" in result
256+ inner_client .get .assert_awaited_once ()
257+ args , kwargs = inner_client .get .call_args
258+ assert args [0 ] == "http://supervisor/addons/81f33d0f_ha_mcp/logs"
259+ assert (
260+ kwargs ["headers" ]["Authorization" ] == "Bearer supervisor-token-test"
261+ )
262+ assert kwargs ["headers" ]["Accept" ] == "text/plain"
263+ # Critically: the HA-Core-proxy path must NOT have been touched.
264+ mock_client .httpx_client .request .assert_not_called ()
265+
266+ @pytest .mark .asyncio
267+ async def test_raises_auth_error_on_401 (
268+ self , mock_client , addon_install , mock_async_client_class
269+ ):
270+ """401 from Supervisor (bad/expired token) maps to HomeAssistantAuthError
271+ the same way the HA-Core-proxy path does — so error mapping in the
272+ wrapper layer stays uniform regardless of which branch ran.
273+ """
274+ inner_client , _ = mock_async_client_class
275+ mock_response = MagicMock ()
276+ mock_response .status_code = 401
277+ mock_response .text = "unauthorized"
278+ inner_client .get .return_value = mock_response
279+
280+ with pytest .raises (HomeAssistantAuthError ):
281+ await mock_client .get_addon_logs ("core_mosquitto" )
282+
283+ @pytest .mark .asyncio
284+ async def test_raises_api_error_on_404 (
285+ self , mock_client , addon_install , mock_async_client_class
286+ ):
287+ """404 (unknown slug) propagates as HomeAssistantAPIError with the
288+ Supervisor's plain-text body in the message — preserves the same
289+ error shape callers expect from the HA-Core-proxy branch."""
290+ inner_client , _ = mock_async_client_class
291+ mock_response = MagicMock ()
292+ mock_response .status_code = 404
293+ mock_response .text = "Addon is not installed"
294+ mock_response .reason_phrase = "Not Found"
295+ inner_client .get .return_value = mock_response
296+
297+ with pytest .raises (HomeAssistantAPIError ) as exc_info :
298+ await mock_client .get_addon_logs ("nonexistent_slug" )
299+
300+ assert exc_info .value .status_code == 404
301+ assert "Addon is not installed" in str (exc_info .value )
302+
303+ @pytest .mark .asyncio
304+ async def test_empty_body_falls_back_to_reason_phrase (
305+ self , mock_client , addon_install , mock_async_client_class
306+ ):
307+ """If Supervisor returns a non-2xx with empty body, the error tail
308+ falls back to the HTTP reason phrase rather than landing as
309+ ``"API error: 5xx - "`` with a blank tail."""
310+ inner_client , _ = mock_async_client_class
311+ mock_response = MagicMock ()
312+ mock_response .status_code = 502
313+ mock_response .text = ""
314+ mock_response .reason_phrase = "Bad Gateway"
315+ inner_client .get .return_value = mock_response
316+
317+ with pytest .raises (HomeAssistantAPIError ) as exc_info :
318+ await mock_client .get_addon_logs ("core_mosquitto" )
319+
320+ assert exc_info .value .status_code == 502
321+ assert "Bad Gateway" in str (exc_info .value )
322+ assert not str (exc_info .value ).endswith (" - " )
323+
324+ @pytest .mark .asyncio
325+ async def test_raises_connection_error_on_timeout (
326+ self , mock_client , addon_install , mock_async_client_class
327+ ):
328+ inner_client , _ = mock_async_client_class
329+ inner_client .get .side_effect = httpx .TimeoutException ("supervisor timeout" )
330+
331+ with pytest .raises (HomeAssistantConnectionError ):
332+ await mock_client .get_addon_logs ("core_mosquitto" )
333+
334+ @pytest .mark .asyncio
335+ async def test_raises_connection_error_on_network_failure (
336+ self , mock_client , addon_install , mock_async_client_class
337+ ):
338+ inner_client , _ = mock_async_client_class
339+ inner_client .get .side_effect = httpx .ConnectError ("supervisor unreachable" )
340+
341+ with pytest .raises (HomeAssistantConnectionError ):
342+ await mock_client .get_addon_logs ("core_mosquitto" )
343+
344+
345+ class TestGetAddonLogsBranchSelection :
346+ """The branch decision is made via `is_running_in_addon()`. Pin both
347+ directions so a future refactor of the gate (e.g. inlining the env-var
348+ check) doesn't silently regress one branch.
349+ """
350+
351+ @pytest .mark .asyncio
352+ async def test_non_addon_install_uses_ha_core_proxy (self , mock_client ):
353+ """`is_running_in_addon()` False → HA-Core-proxy path, no Supervisor URL."""
354+ mock_response = MagicMock ()
355+ mock_response .status_code = 200
356+ mock_response .text = "via proxy\n "
357+ mock_client .httpx_client .request = AsyncMock (return_value = mock_response )
358+
359+ with patch (
360+ "ha_mcp.client.rest_client.is_running_in_addon" , return_value = False
361+ ):
362+ result = await mock_client .get_addon_logs ("core_mosquitto" )
363+
364+ assert "via proxy" in result
365+ mock_client .httpx_client .request .assert_called_once ()
366+ args , _ = mock_client .httpx_client .request .call_args
367+ assert args [1 ] == "/hassio/addons/core_mosquitto/logs"
368+
369+ @pytest .mark .asyncio
370+ async def test_addon_install_uses_supervisor_direct (self , mock_client ):
371+ """`is_running_in_addon()` True → Supervisor-direct path, no HA-Core call."""
372+ inner_client = MagicMock ()
373+ inner_client .get = AsyncMock ()
374+ mock_response = MagicMock ()
375+ mock_response .status_code = 200
376+ mock_response .text = "via supervisor\n "
377+ inner_client .get .return_value = mock_response
378+
379+ cm = MagicMock ()
380+ cm .__aenter__ = AsyncMock (return_value = inner_client )
381+ cm .__aexit__ = AsyncMock (return_value = None )
382+
383+ with (
384+ patch (
385+ "ha_mcp.client.rest_client.is_running_in_addon" ,
386+ return_value = True ,
387+ ),
388+ patch .dict (
389+ "os.environ" , {"SUPERVISOR_TOKEN" : "supervisor-token-branch" }
390+ ),
391+ patch (
392+ "ha_mcp.client.rest_client.httpx.AsyncClient" ,
393+ return_value = cm ,
394+ ),
395+ ):
396+ result = await mock_client .get_addon_logs ("core_mosquitto" )
397+
398+ assert "via supervisor" in result
399+ # HA-Core-proxy must NOT have been called.
400+ mock_client .httpx_client .request .assert_not_called ()
401+ inner_client .get .assert_awaited_once ()
402+
403+
170404class TestRawRequestEmptyBodyFallback :
171405 """Error message must stay actionable even when the 4xx body is empty.
172406
0 commit comments