diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index 873c1e440..daba82cd2 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -175,7 +175,10 @@ class HomeAssistantAPI(CoreSysAttributes): async def get_config(self) -> dict[str, Any]: """Return Home Assistant config.""" - return await self._get_json("api/config") + config = await self._get_json("api/config") + if config is None or not isinstance(config, dict): + raise HomeAssistantAPIError("No config received from Home Assistant API") + return config async def get_core_state(self) -> dict[str, Any]: """Return Home Assistant core state.""" @@ -219,3 +222,32 @@ class HomeAssistantAPI(CoreSysAttributes): if state := await self.get_api_state(): return state.core_state == "RUNNING" or state.offline_db_migration return False + + async def check_frontend_available(self) -> bool: + """Check if the frontend is accessible by fetching the root path. + + Caller should make sure that Home Assistant Core is running before + calling this method. + + Returns: + True if the frontend responds successfully, False otherwise. + + """ + try: + async with self.make_request("get", "", timeout=30) as resp: + # Frontend should return HTML content + if resp.status == 200: + content_type = resp.headers.get(hdrs.CONTENT_TYPE, "") + if "text/html" in content_type: + _LOGGER.debug("Frontend is accessible and serving HTML") + return True + _LOGGER.warning( + "Frontend responded but with unexpected content type: %s", + content_type, + ) + return False + _LOGGER.warning("Frontend returned status %s", resp.status) + return False + except HomeAssistantAPIError as err: + _LOGGER.debug("Cannot reach frontend: %s", err) + return False diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index c905e7b4c..b1df3ea09 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -304,12 +304,18 @@ class HomeAssistantCore(JobGroup): except HomeAssistantError: # The API stoped responding between the up checks an now self._error_state = True - data = None + return # Verify that the frontend is loaded - if data and "frontend" not in data.get("components", []): + if "frontend" not in data.get("components", []): _LOGGER.error("API responds but frontend is not loaded") self._error_state = True + # Check that the frontend is actually accessible + elif not await self.sys_homeassistant.api.check_frontend_available(): + _LOGGER.error( + "Frontend component loaded but frontend is not accessible" + ) + self._error_state = True else: return @@ -322,12 +328,12 @@ class HomeAssistantCore(JobGroup): # Make a copy of the current log file if it exists logfile = self.sys_config.path_homeassistant / "home-assistant.log" - if logfile.exists(): + if await self.sys_run_in_executor(logfile.exists): rollback_log = ( self.sys_config.path_homeassistant / "home-assistant-rollback.log" ) - shutil.copy(logfile, rollback_log) + await self.sys_run_in_executor(shutil.copy, logfile, rollback_log) _LOGGER.info( "A backup of the logfile is stored in /config/home-assistant-rollback.log" ) diff --git a/tests/api/test_homeassistant.py b/tests/api/test_homeassistant.py index dbc48faf7..0ce240008 100644 --- a/tests/api/test_homeassistant.py +++ b/tests/api/test_homeassistant.py @@ -18,6 +18,8 @@ from supervisor.homeassistant.api import APIState, HomeAssistantAPI from supervisor.homeassistant.const import WSEvent from supervisor.homeassistant.core import HomeAssistantCore from supervisor.homeassistant.module import HomeAssistant +from supervisor.resolution.const import ContextType, IssueType +from supervisor.resolution.data import Issue from tests.common import AsyncIterator, load_json_fixture @@ -287,6 +289,7 @@ async def test_api_progress_updates_home_assistant_update( patch.object( HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]} ), + patch.object(HomeAssistantAPI, "check_frontend_available", return_value=True), ): resp = await api_client.post("/core/update", json={"version": "2025.8.3"}) @@ -436,3 +439,73 @@ async def test_config_check_error(api_client: TestClient, container: DockerConta assert result.status == 400 resp = await result.json() assert resp["message"] == "Test logs 1\nTest logs 2" + + +async def test_update_frontend_check_success(api_client: TestClient, coresys: CoreSys): + """Test that update succeeds when frontend check passes.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + with ( + patch.object( + DockerHomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2025.8.0")), + ), + patch.object( + HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]} + ), + patch.object(HomeAssistantAPI, "check_frontend_available", return_value=True), + ): + resp = await api_client.post("/core/update", json={"version": "2025.8.3"}) + + assert resp.status == 200 + + +async def test_update_frontend_check_fails_triggers_rollback( + api_client: TestClient, + coresys: CoreSys, + caplog: pytest.LogCaptureFixture, + tmp_supervisor_data: Path, +): + """Test that update triggers rollback when frontend check fails.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + # Mock successful first update, failed frontend check, then successful rollback + update_call_count = 0 + + async def mock_update(*args, **kwargs): + nonlocal update_call_count + update_call_count += 1 + if update_call_count == 1: + # First update succeeds + coresys.homeassistant.version = AwesomeVersion("2025.8.3") + elif update_call_count == 2: + # Rollback succeeds + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + with ( + patch.object(DockerInterface, "update", new=mock_update), + patch.object( + DockerHomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2025.8.0")), + ), + patch.object( + HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]} + ), + patch.object(HomeAssistantAPI, "check_frontend_available", return_value=False), + ): + resp = await api_client.post("/core/update", json={"version": "2025.8.3"}) + + # Update should trigger rollback, which succeeds and returns 200 + assert resp.status == 200 + assert "Frontend component loaded but frontend is not accessible" in caplog.text + assert "HomeAssistant update failed -> rollback!" in caplog.text + # Should have called update twice (once for update, once for rollback) + assert update_call_count == 2 + # An update_rollback issue should be created + assert ( + Issue(IssueType.UPDATE_ROLLBACK, ContextType.CORE) in coresys.resolution.issues + ) diff --git a/tests/homeassistant/test_api.py b/tests/homeassistant/test_api.py new file mode 100644 index 000000000..35125ced3 --- /dev/null +++ b/tests/homeassistant/test_api.py @@ -0,0 +1,207 @@ +"""Test Home Assistant API.""" + +from contextlib import asynccontextmanager +from unittest.mock import MagicMock, patch + +from aiohttp import hdrs +from awesomeversion import AwesomeVersion +import pytest + +from supervisor.coresys import CoreSys +from supervisor.exceptions import HomeAssistantAPIError + + +async def test_check_frontend_available_success(coresys: CoreSys): + """Test frontend availability check succeeds with valid HTML response.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.headers = {hdrs.CONTENT_TYPE: "text/html; charset=utf-8"} + + @asynccontextmanager + async def mock_make_request(*args, **kwargs): + yield mock_response + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.check_frontend_available() + + assert result is True + + +async def test_check_frontend_available_wrong_status(coresys: CoreSys): + """Test frontend availability check fails with non-200 status.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 404 + mock_response.headers = {hdrs.CONTENT_TYPE: "text/html"} + + @asynccontextmanager + async def mock_make_request(*args, **kwargs): + yield mock_response + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.check_frontend_available() + + assert result is False + + +async def test_check_frontend_available_wrong_content_type( + coresys: CoreSys, caplog: pytest.LogCaptureFixture +): + """Test frontend availability check fails with wrong content type.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.headers = {hdrs.CONTENT_TYPE: "application/json"} + + @asynccontextmanager + async def mock_make_request(*args, **kwargs): + yield mock_response + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.check_frontend_available() + + assert result is False + assert "unexpected content type" in caplog.text + + +async def test_check_frontend_available_api_error(coresys: CoreSys): + """Test frontend availability check handles API errors gracefully.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + @asynccontextmanager + async def mock_make_request(*args, **kwargs): + raise HomeAssistantAPIError("Connection failed") + yield # pragma: no cover + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.check_frontend_available() + + assert result is False + + +async def test_get_config_success(coresys: CoreSys): + """Test get_config returns valid config dictionary.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + expected_config = { + "latitude": 32.87336, + "longitude": -117.22743, + "elevation": 0, + "unit_system": { + "length": "km", + "mass": "g", + "temperature": "°C", + "volume": "L", + }, + "location_name": "Home", + "time_zone": "America/Los_Angeles", + "components": ["frontend", "config"], + "version": "2025.8.0", + } + + mock_response = MagicMock() + mock_response.status = 200 + + async def mock_json(): + return expected_config + + mock_response.json = mock_json + + @asynccontextmanager + async def mock_make_request(*_args, **_kwargs): + yield mock_response + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.get_config() + + assert result == expected_config + + +async def test_get_config_returns_none(coresys: CoreSys): + """Test get_config raises error when None is returned.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 200 + + async def mock_json(): + return None + + mock_response.json = mock_json + + @asynccontextmanager + async def mock_make_request(*_args, **_kwargs): + yield mock_response + + with ( + patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ), + pytest.raises( + HomeAssistantAPIError, match="No config received from Home Assistant API" + ), + ): + await coresys.homeassistant.api.get_config() + + +async def test_get_config_returns_non_dict(coresys: CoreSys): + """Test get_config raises error when non-dict is returned.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 200 + + async def mock_json(): + return ["not", "a", "dict"] + + mock_response.json = mock_json + + @asynccontextmanager + async def mock_make_request(*_args, **_kwargs): + yield mock_response + + with ( + patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ), + pytest.raises( + HomeAssistantAPIError, match="No config received from Home Assistant API" + ), + ): + await coresys.homeassistant.api.get_config() + + +async def test_get_config_api_error(coresys: CoreSys): + """Test get_config propagates API errors from underlying _get_json call.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 500 + + @asynccontextmanager + async def mock_make_request(*_args, **_kwargs): + yield mock_response + + with ( + patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ), + pytest.raises( + HomeAssistantAPIError, match="Home Assistant Core API return 500" + ), + ): + await coresys.homeassistant.api.get_config()