diff --git a/pyproject.toml b/pyproject.toml index 95a075c8e..c342fdf45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,6 +230,9 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash", "ignore::pytest.PytestUnraisableExceptionWarning", ] +markers = [ + "no_mock_init_websession: disable the autouse mock of init_websession for this test", +] [tool.ruff] lint.select = [ diff --git a/supervisor/api/middleware/security.py b/supervisor/api/middleware/security.py index 647bf355e..6cbb2eecd 100644 --- a/supervisor/api/middleware/security.py +++ b/supervisor/api/middleware/security.py @@ -20,7 +20,7 @@ from ...const import ( ROLE_DEFAULT, ROLE_HOMEASSISTANT, ROLE_MANAGER, - CoreState, + VALID_API_STATES, ) from ...coresys import CoreSys, CoreSysAttributes from ...utils import version_is_new_enough @@ -200,11 +200,7 @@ class SecurityMiddleware(CoreSysAttributes): @middleware async def system_validation(self, request: Request, handler: Callable) -> Response: """Check if core is ready to response.""" - if self.sys_core.state not in ( - CoreState.STARTUP, - CoreState.RUNNING, - CoreState.FREEZE, - ): + if self.sys_core.state not in VALID_API_STATES: return api_return_error( message=f"System is not ready with state: {self.sys_core.state}" ) diff --git a/supervisor/const.py b/supervisor/const.py index 13504d80b..610dc51e4 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -552,3 +552,12 @@ STARTING_STATES = [ CoreState.STARTUP, CoreState.SETUP, ] + +# States in which the API can be used (enforced by system_validation()) +VALID_API_STATES = frozenset( + { + CoreState.STARTUP, + CoreState.RUNNING, + CoreState.FREEZE, + } +) diff --git a/supervisor/core.py b/supervisor/core.py index fdc9952ef..423457ab4 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -124,6 +124,19 @@ class Core(CoreSysAttributes): """Start setting up supervisor orchestration.""" await self.set_state(CoreState.SETUP) + # Initialize websession early. At this point we'll use the Docker DNS proxy + # at 127.0.0.11, which does not have the fallback feature and hence might + # fail in certain environments. But a websession is required to get the + # initial version information after a device wipe or otherwise empty state + # (e.g. CI environment, Supervised). + # + # An OS installation has the plug-in container images pre-installed, so we + # setup can continue even if this early websession fails to connect to the + # internet. We'll reinitialize the websession when the DNS plug-in is up to + # make sure the DNS plug-in along with its fallback capabilities is used + # (see #5857). + await self.coresys.init_websession() + # Check internet on startup await self.sys_supervisor.check_connectivity() diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 46abeec3a..4847a7f1a 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -21,6 +21,7 @@ from .const import ( ENV_SUPERVISOR_MACHINE, MACHINE_ID, SERVER_SOFTWARE, + VALID_API_STATES, ) if TYPE_CHECKING: @@ -68,7 +69,6 @@ class CoreSys: # External objects self._loop: asyncio.BaseEventLoop = asyncio.get_running_loop() - self._websession: aiohttp.ClientSession = aiohttp.ClientSession() # Global objects self._config: CoreConfig = CoreConfig() @@ -100,11 +100,7 @@ class CoreSys: self._security: Security | None = None self._bus: Bus | None = None self._mounts: MountManager | None = None - - # Set default header for aiohttp - self._websession._default_headers = MappingProxyType( - {aiohttp.hdrs.USER_AGENT: SERVER_SOFTWARE} - ) + self._websession: aiohttp.ClientSession | None = None # Task factory attributes self._set_task_context: list[Callable[[Context], Context]] = [] @@ -114,6 +110,33 @@ class CoreSys: await self.config.read_data() return self + async def init_websession(self) -> None: + """Initialize global aiohttp ClientSession.""" + if self.core.state in VALID_API_STATES: + # Make sure we don't reinitialize the session if the API is running (see #5851) + raise RuntimeError( + "Initializing ClientSession is not safe when API is running" + ) + + if self._websession: + await self._websession.close() + + resolver = aiohttp.AsyncResolver() + + # pylint: disable=protected-access + _LOGGER.debug( + "Initializing ClientSession with AsyncResolver. Using nameservers %s", + resolver._resolver.nameservers, + ) + connector = aiohttp.TCPConnector(loop=self.loop, resolver=resolver) + + session = aiohttp.ClientSession( + headers=MappingProxyType({aiohttp.hdrs.USER_AGENT: SERVER_SOFTWARE}), + connector=connector, + ) + + self._websession = session + async def init_machine(self): """Initialize machine information.""" @@ -165,6 +188,8 @@ class CoreSys: @property def websession(self) -> aiohttp.ClientSession: """Return websession object.""" + if self._websession is None: + raise RuntimeError("WebSession not setup yet") return self._websession @property diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index 48d80414e..cbff26b10 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -177,7 +177,13 @@ class PluginDns(PluginBase): # Update supervisor await self._write_resolv(HOST_RESOLV) - await self.sys_supervisor.check_connectivity() + + # Reinitializing aiohttp.ClientSession after DNS setup makes sure that + # aiodns is using the right DNS servers (see #5857). + # At this point it should be fairly safe to replace the session since + # we only use the session synchronously during setup and not thorugh the + # API which previously caused issues (see #5851). + await self.coresys.init_websession() async def install(self) -> None: """Install CoreDNS.""" diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index f622aa4ab..2c026c1f4 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -190,7 +190,7 @@ async def test_addon_shutdown_error( async def test_addon_uninstall_removes_discovery( - coresys: CoreSys, install_addon_ssh: Addon + coresys: CoreSys, install_addon_ssh: Addon, websession: MagicMock ): """Test discovery messages removed when addon uninstalled.""" assert coresys.discovery.list_messages == [] @@ -203,7 +203,6 @@ async def test_addon_uninstall_removes_discovery( assert coresys.discovery.list_messages == [message] coresys.homeassistant.api.ensure_access_token = AsyncMock() - coresys.websession.delete = MagicMock() await coresys.addons.uninstall(TEST_ADDON_SLUG) await asyncio.sleep(0) diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 8b651067e..45a7fbf15 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -1,7 +1,7 @@ """Test auth API.""" from datetime import UTC, datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp.test_utils import TestClient import pytest @@ -9,6 +9,7 @@ import pytest from supervisor.addons.addon import Addon from supervisor.coresys import CoreSys +from tests.common import MockResponse from tests.const import TEST_ADDON_SLUG LIST_USERS_RESPONSE = [ @@ -78,7 +79,10 @@ def fixture_mock_check_login(coresys: CoreSys): async def test_password_reset( - api_client: TestClient, coresys: CoreSys, caplog: pytest.LogCaptureFixture + api_client: TestClient, + coresys: CoreSys, + caplog: pytest.LogCaptureFixture, + websession: MagicMock, ): """Test password reset api.""" coresys.homeassistant.api.access_token = "abc123" @@ -87,15 +91,12 @@ async def test_password_reset( days=1 ) - mock_websession = AsyncMock() - mock_websession.post.return_value.__aenter__.return_value.status = 200 - with patch("supervisor.coresys.aiohttp.ClientSession.post") as post: - post.return_value.__aenter__.return_value.status = 200 - resp = await api_client.post( - "/auth/reset", json={"username": "john", "password": "doe"} - ) - assert resp.status == 200 - assert "Successful password reset for 'john'" in caplog.text + websession.post = MagicMock(return_value=MockResponse(status=200)) + resp = await api_client.post( + "/auth/reset", json={"username": "john", "password": "doe"} + ) + assert resp.status == 200 + assert "Successful password reset for 'john'" in caplog.text async def test_list_users( diff --git a/tests/api/test_backups.py b/tests/api/test_backups.py index 6317f4a22..44232d138 100644 --- a/tests/api/test_backups.py +++ b/tests/api/test_backups.py @@ -276,6 +276,7 @@ async def test_api_backup_restore_background( backup_type: str, options: dict[str, Any], tmp_supervisor_data: Path, + supervisor_internet: AsyncMock, ): """Test background option on backup/restore APIs.""" await coresys.core.set_state(CoreState.RUNNING) @@ -472,6 +473,7 @@ async def test_restore_immediate_errors( api_client: TestClient, coresys: CoreSys, mock_partial_backup: Backup, + supervisor_internet: AsyncMock, ): """Test restore errors that return immediately even in background mode.""" await coresys.core.set_state(CoreState.RUNNING) @@ -1010,6 +1012,7 @@ async def test_restore_backup_from_location( coresys: CoreSys, tmp_supervisor_data: Path, local_location: str | None, + supervisor_internet: AsyncMock, ): """Test restoring a backup from a specific location.""" await coresys.core.set_state(CoreState.RUNNING) @@ -1059,6 +1062,7 @@ async def test_restore_backup_from_location( async def test_restore_backup_unencrypted_after_encrypted( api_client: TestClient, coresys: CoreSys, + supervisor_internet: AsyncMock, ): """Test restoring an unencrypted backup after an encrypted backup and vis-versa.""" enc_tar = copy(get_fixture_path("test_consolidate.tar"), coresys.config.path_backup) @@ -1131,6 +1135,7 @@ async def test_restore_homeassistant_adds_env( docker: DockerAPI, backup_type: str, postbody: dict[str, Any], + supervisor_internet: AsyncMock, ): """Test restoring home assistant from backup adds env to container.""" event = asyncio.Event() @@ -1328,6 +1333,7 @@ async def test_missing_file_removes_location_from_cache( url_path: str, body: dict[str, Any] | None, backup_file: str, + supervisor_internet: AsyncMock, ): """Test finding a missing file removes the location from cache.""" await coresys.core.set_state(CoreState.RUNNING) @@ -1387,6 +1393,7 @@ async def test_missing_file_removes_backup_from_cache( url_path: str, body: dict[str, Any] | None, backup_file: str, + supervisor_internet: AsyncMock, ): """Test finding a missing file removes the backup from cache if its the only one.""" await coresys.core.set_state(CoreState.RUNNING) @@ -1412,7 +1419,9 @@ async def test_missing_file_removes_backup_from_cache( @pytest.mark.usefixtures("tmp_supervisor_data") async def test_immediate_list_after_missing_file_restore( - api_client: TestClient, coresys: CoreSys + api_client: TestClient, + coresys: CoreSys, + supervisor_internet: AsyncMock, ): """Test race with reload for missing file on restore does not error.""" await coresys.core.set_state(CoreState.RUNNING) diff --git a/tests/api/test_discovery.py b/tests/api/test_discovery.py index 2593fb1e2..39efcf36e 100644 --- a/tests/api/test_discovery.py +++ b/tests/api/test_discovery.py @@ -84,12 +84,14 @@ async def test_api_list_discovery( @pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True) async def test_api_send_del_discovery( - api_client: TestClient, coresys: CoreSys, install_addon_ssh: Addon + api_client: TestClient, + coresys: CoreSys, + install_addon_ssh: Addon, + websession: MagicMock, ): """Test adding and removing discovery.""" install_addon_ssh.data["discovery"] = ["test"] coresys.homeassistant.api.ensure_access_token = AsyncMock() - coresys.websession.post = MagicMock() resp = await api_client.post("/discovery", json={"service": "test", "config": {}}) assert resp.status == 200 diff --git a/tests/api/test_security.py b/tests/api/test_security.py index e0bf9344f..2ef5d5313 100644 --- a/tests/api/test_security.py +++ b/tests/api/test_security.py @@ -1,5 +1,7 @@ """Test Supervisor API.""" +from unittest.mock import AsyncMock + import pytest from supervisor.coresys import CoreSys @@ -36,7 +38,9 @@ async def test_api_security_options_pwned(api_client, coresys: CoreSys): @pytest.mark.asyncio -async def test_api_integrity_check(api_client, coresys: CoreSys): +async def test_api_integrity_check( + api_client, coresys: CoreSys, supervisor_internet: AsyncMock +): """Test security integrity check.""" coresys.security.content_trust = False diff --git a/tests/api/test_store.py b/tests/api/test_store.py index 403dcfab2..df327d44b 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from aiohttp import ClientResponse from aiohttp.test_utils import TestClient @@ -92,7 +92,9 @@ async def test_api_store_repositories_repository( assert result["data"]["slug"] == repository.slug -async def test_api_store_add_repository(api_client: TestClient, coresys: CoreSys): +async def test_api_store_add_repository( + api_client: TestClient, coresys: CoreSys, supervisor_internet: AsyncMock +) -> None: """Test POST /store/repositories REST API.""" with ( patch("supervisor.store.repository.Repository.load", return_value=None), diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index 064c2c658..251ff4eef 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access import time -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp.test_utils import TestClient from blockbuster import BlockingError @@ -34,7 +34,7 @@ async def test_api_supervisor_options_debug(api_client: TestClient, coresys: Cor async def test_api_supervisor_options_add_repository( - api_client: TestClient, coresys: CoreSys + api_client: TestClient, coresys: CoreSys, supervisor_internet: AsyncMock ): """Test add a repository via POST /supervisor/options REST API.""" assert REPO_URL not in coresys.store.repository_urls @@ -231,7 +231,9 @@ async def test_api_supervisor_fallback_log_capture( capture_exception.assert_called_once() -async def test_api_supervisor_reload(api_client: TestClient): +async def test_api_supervisor_reload( + api_client: TestClient, supervisor_internet: AsyncMock, websession: MagicMock +): """Test supervisor reload.""" resp = await api_client.post("/supervisor/reload") assert resp.status == 200 diff --git a/tests/common.py b/tests/common.py index 79feb4645..5ce105193 100644 --- a/tests/common.py +++ b/tests/common.py @@ -103,3 +103,31 @@ def get_job_decorator(func) -> Job: def reset_last_call(func, group: str | None = None) -> None: """Reset last call for a function using the Job decorator.""" get_job_decorator(func).set_last_call(datetime.min, group) + + +class MockResponse: + """Mock response for aiohttp requests.""" + + def __init__(self, *, status=200, text=""): + """Initialize mock response.""" + self.status = status + self._text = text + + def update_text(self, text: str): + """Update the text of the response.""" + self._text = text + + async def read(self): + """Read the response body.""" + return self._text.encode("utf-8") + + async def text(self) -> str: + """Return the response body as text.""" + return self._text + + async def __aenter__(self): + """Enter the context manager.""" + return self + + async def __aexit__(self, exc_type, exc, tb): + """Exit the context manager.""" diff --git a/tests/conftest.py b/tests/conftest.py index 3f9a13010..d954398e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import subprocess from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch from uuid import uuid4 -from aiohttp import web +from aiohttp import ClientSession, web from aiohttp.test_utils import TestClient from awesomeversion import AwesomeVersion from blockbuster import BlockBuster, blockbuster_ctx @@ -53,7 +53,13 @@ from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository from supervisor.utils.dt import utcnow -from .common import load_binary_fixture, load_json_fixture, mock_dbus_services +from .common import ( + MockResponse, + load_binary_fixture, + load_fixture, + load_json_fixture, + mock_dbus_services, +) from .const import TEST_ADDON_SLUG from .dbus_service_mocks.base import DBusServiceMock from .dbus_service_mocks.network_connection_settings import ( @@ -329,6 +335,7 @@ async def coresys( aiohttp_client, run_supervisor_state, supervisor_name, + request: pytest.FixtureRequest, ) -> CoreSys: """Create a CoreSys Mock.""" with ( @@ -397,12 +404,14 @@ async def coresys( ha_version=AwesomeVersion("2021.2.4") ) + if not request.node.get_closest_marker("no_mock_init_websession"): + coresys_obj.init_websession = AsyncMock() + # Don't remove files/folders related to addons and stores with patch("supervisor.store.git.GitRepo._remove"): yield coresys_obj await coresys_obj.dbus.unload() - await coresys_obj.websession.close() @pytest.fixture @@ -512,6 +521,31 @@ async def api_client( yield await aiohttp_client(api.webapp) +@pytest.fixture +def supervisor_internet(coresys: CoreSys) -> Generator[AsyncMock]: + """Fixture which simluate Supervsior internet connection.""" + connectivity_check = AsyncMock(return_value=True) + coresys.supervisor.check_connectivity = connectivity_check + yield connectivity_check + + +@pytest.fixture +def websession(coresys: CoreSys) -> Generator[MagicMock]: + """Fixture for global aiohttp SessionClient.""" + coresys._websession = MagicMock(spec_set=ClientSession) + yield coresys._websession + + +@pytest.fixture +def mock_update_data(websession: MagicMock) -> Generator[MockResponse]: + """Mock updater JSON data.""" + version_data = load_fixture("version_stable.json") + client_response = MockResponse(text=version_data) + client_response.status = 200 + websession.get = MagicMock(return_value=client_response) + yield client_response + + @pytest.fixture def store_manager(coresys: CoreSys): """Fixture for the store manager.""" diff --git a/tests/misc/test_tasks.py b/tests/misc/test_tasks.py index 84ae37f37..01d189ed8 100644 --- a/tests/misc/test_tasks.py +++ b/tests/misc/test_tasks.py @@ -1,7 +1,6 @@ """Test scheduled tasks.""" from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager from pathlib import Path from shutil import copy from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch @@ -18,7 +17,7 @@ from supervisor.homeassistant.core import HomeAssistantCore from supervisor.misc.tasks import Tasks from supervisor.supervisor import Supervisor -from tests.common import get_fixture_path, load_fixture +from tests.common import MockResponse, get_fixture_path # pylint: disable=protected-access @@ -173,25 +172,17 @@ async def test_watchdog_homeassistant_api_reanimation_limit( @pytest.mark.usefixtures("no_job_throttle") async def test_reload_updater_triggers_supervisor_update( - tasks: Tasks, coresys: CoreSys + tasks: Tasks, + coresys: CoreSys, + mock_update_data: MockResponse, + supervisor_internet: AsyncMock, ): """Test an updater reload triggers a supervisor update if there is one.""" coresys.hardware.disk.get_disk_free_space = lambda x: 5000 await coresys.core.set_state(CoreState.RUNNING) coresys.security.content_trust = False - version_data = load_fixture("version_stable.json") - version_resp = AsyncMock() - version_resp.status = 200 - version_resp.read.return_value = version_data - - @asynccontextmanager - async def mock_get_for_version(*args, **kwargs) -> AsyncGenerator[AsyncMock]: - """Mock get call for version information.""" - yield version_resp - with ( - patch("supervisor.coresys.aiohttp.ClientSession.get", new=mock_get_for_version), patch.object( Supervisor, "version", @@ -208,7 +199,8 @@ async def test_reload_updater_triggers_supervisor_update( update.assert_not_called() # Version change causes an update - version_resp.read.return_value = version_data.replace("2024.10.0", "2024.10.1") + version_data = await mock_update_data.text() + mock_update_data.update_text(version_data.replace("2024.10.0", "2024.10.1")) await tasks._reload_updater() update.assert_called_once() diff --git a/tests/os/test_manager.py b/tests/os/test_manager.py index a5db35c2e..70370064a 100644 --- a/tests/os/test_manager.py +++ b/tests/os/test_manager.py @@ -1,6 +1,6 @@ """Test Home Assistant OS functionality.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch from awesomeversion import AwesomeVersion from dbus_fast import Variant @@ -10,6 +10,7 @@ from supervisor.const import CoreState from supervisor.coresys import CoreSys from supervisor.exceptions import HassOSJobError +from tests.common import MockResponse from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.rauc import Rauc as RaucService @@ -17,7 +18,9 @@ from tests.dbus_service_mocks.rauc import Rauc as RaucService @pytest.mark.usefixtures("no_job_throttle") -async def test_ota_url_generic_x86_64_rename(coresys: CoreSys) -> None: +async def test_ota_url_generic_x86_64_rename( + coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock +) -> None: """Test download URL generated.""" coresys.os._board = "intel-nuc" coresys.os._version = AwesomeVersion("5.13") @@ -65,7 +68,9 @@ def test_ota_url_os_name_rel_5_downgrade(coresys: CoreSys) -> None: assert url == url_formatted -async def test_update_fails_if_out_of_date(coresys: CoreSys) -> None: +async def test_update_fails_if_out_of_date( + coresys: CoreSys, supervisor_internet: AsyncMock +) -> None: """Test update of OS fails if Supervisor is out of date.""" await coresys.core.set_state(CoreState.RUNNING) with ( diff --git a/tests/plugins/test_plugin_manager.py b/tests/plugins/test_plugin_manager.py index fcd05ef6f..8cbee0c1c 100644 --- a/tests/plugins/test_plugin_manager.py +++ b/tests/plugins/test_plugin_manager.py @@ -1,6 +1,6 @@ """Test plugin manager.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch from awesomeversion import AwesomeVersion import pytest @@ -10,6 +10,8 @@ from supervisor.docker.interface import DockerInterface from supervisor.plugins.base import PluginBase from supervisor.supervisor import Supervisor +from tests.common import MockResponse + def mock_awaitable_bool(value: bool): """Return a mock of an awaitable bool.""" @@ -37,7 +39,9 @@ async def test_repair(coresys: CoreSys): @pytest.mark.usefixtures("no_job_throttle") -async def test_load(coresys: CoreSys): +async def test_load( + coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock +): """Test plugin manager load.""" coresys.hardware.disk.get_disk_free_space = lambda x: 5000 await coresys.updater.load() diff --git a/tests/resolution/check/test_check_docker_config.py b/tests/resolution/check/test_check_docker_config.py index 54e0f6be8..91efa3e0c 100644 --- a/tests/resolution/check/test_check_docker_config.py +++ b/tests/resolution/check/test_check_docker_config.py @@ -52,13 +52,14 @@ async def test_check( docker.containers.get = _make_mock_container_get( ["homeassistant", "hassio_audio", "addon_local_ssh"], folder ) + # Use state used in setup() + await coresys.core.set_state(CoreState.SETUP) with patch.object(DockerInterface, "is_running", return_value=True): await coresys.plugins.load() await coresys.homeassistant.load() await coresys.addons.load() docker_config = CheckDockerConfig(coresys) - await coresys.core.set_state(CoreState.RUNNING) assert not coresys.resolution.issues assert not coresys.resolution.suggestions diff --git a/tests/resolution/fixup/test_system_execute_integrity.py b/tests/resolution/fixup/test_system_execute_integrity.py index d90a94a15..766345c6c 100644 --- a/tests/resolution/fixup/test_system_execute_integrity.py +++ b/tests/resolution/fixup/test_system_execute_integrity.py @@ -16,7 +16,7 @@ from supervisor.security.const import ContentTrustResult, IntegrityResult from supervisor.utils.dt import utcnow -async def test_fixup(coresys: CoreSys): +async def test_fixup(coresys: CoreSys, supervisor_internet: AsyncMock): """Test fixup.""" system_execute_integrity = FixupSystemExecuteIntegrity(coresys) @@ -42,7 +42,7 @@ async def test_fixup(coresys: CoreSys): assert len(coresys.resolution.issues) == 0 -async def test_fixup_error(coresys: CoreSys): +async def test_fixup_error(coresys: CoreSys, supervisor_internet: AsyncMock): """Test fixup.""" system_execute_integrity = FixupSystemExecuteIntegrity(coresys) diff --git a/tests/test_core.py b/tests/test_core.py index 8b0cd3a06..bde0ce105 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -34,7 +34,7 @@ async def test_write_state(run_supervisor_state: MagicMock, coresys: CoreSys): ) -async def test_adjust_system_datetime(coresys: CoreSys): +async def test_adjust_system_datetime(coresys: CoreSys, websession: MagicMock): """Test _adjust_system_datetime method with successful retrieve_whoami.""" utc_ts = datetime.datetime.now().replace(tzinfo=datetime.UTC) with patch( @@ -52,7 +52,9 @@ async def test_adjust_system_datetime(coresys: CoreSys): mock_retrieve_whoami.assert_not_called() -async def test_adjust_system_datetime_without_ssl(coresys: CoreSys): +async def test_adjust_system_datetime_without_ssl( + coresys: CoreSys, websession: MagicMock +): """Test _adjust_system_datetime method when retrieve_whoami raises WhoamiSSLError.""" utc_ts = datetime.datetime.now().replace(tzinfo=datetime.UTC) with patch( @@ -67,7 +69,9 @@ async def test_adjust_system_datetime_without_ssl(coresys: CoreSys): assert coresys.core.sys_config.timezone == "Europe/Zurich" -async def test_adjust_system_datetime_if_time_behind(coresys: CoreSys): +async def test_adjust_system_datetime_if_time_behind( + coresys: CoreSys, websession: MagicMock +): """Test _adjust_system_datetime method when current time is ahead more than 3 days.""" utc_ts = datetime.datetime.now().replace(tzinfo=datetime.UTC) + datetime.timedelta( days=4 diff --git a/tests/test_coresys.py b/tests/test_coresys.py index a5f34e2e0..9afbc66b5 100644 --- a/tests/test_coresys.py +++ b/tests/test_coresys.py @@ -1,9 +1,12 @@ """Testing handling with CoreState.""" from datetime import timedelta +from unittest.mock import MagicMock, patch from aiohttp.hdrs import USER_AGENT +import pytest +from supervisor.const import CoreState from supervisor.coresys import CoreSys from supervisor.dbus.timedate import TimeDate from supervisor.utils.dt import utcnow @@ -36,9 +39,25 @@ async def test_now(coresys: CoreSys): assert zurich - utc <= timedelta(hours=2) -def test_custom_user_agent(coresys: CoreSys): +@pytest.mark.no_mock_init_websession +async def test_custom_user_agent(coresys: CoreSys): """Test custom useragent.""" - assert ( - "HomeAssistantSupervisor/9999.09.9.dev9999" - in coresys.websession._default_headers[USER_AGENT] # pylint: disable=protected-access - ) + with patch( + "supervisor.coresys.aiohttp.ClientSession", return_value=MagicMock() + ) as mock_session: + await coresys.init_websession() + assert ( + "HomeAssistantSupervisor/9999.09.9.dev9999" + in mock_session.call_args_list[0][1]["headers"][USER_AGENT] + ) + + +@pytest.mark.no_mock_init_websession +async def test_no_init_when_api_running(coresys: CoreSys): + """Test ClientSession reinitialization is refused when API is running.""" + with patch("supervisor.coresys.aiohttp.ClientSession"): + await coresys.init_websession() + await coresys.core.set_state(CoreState.RUNNING) + # Reinitialize websession should not be possible while running + with pytest.raises(RuntimeError): + await coresys.init_websession() diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 15172d448..7328c4739 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta import errno -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiohttp import ClientTimeout from aiohttp.client_exceptions import ClientError @@ -23,17 +23,7 @@ from supervisor.resolution.const import ContextType, IssueType from supervisor.resolution.data import Issue from supervisor.supervisor import Supervisor -from tests.common import reset_last_call - - -@pytest.fixture(name="websession", scope="function") -async def fixture_webession(coresys: CoreSys) -> AsyncMock: - """Mock of websession.""" - mock_websession = AsyncMock() - with patch.object( - type(coresys), "websession", new=PropertyMock(return_value=mock_websession) - ): - yield mock_websession +from tests.common import MockResponse, reset_last_call @pytest.mark.parametrize( @@ -42,14 +32,14 @@ async def fixture_webession(coresys: CoreSys) -> AsyncMock: @pytest.mark.usefixtures("no_job_throttle") async def test_connectivity_check( coresys: CoreSys, - websession: AsyncMock, + websession: MagicMock, side_effect: Exception | None, connectivity: bool, ): """Test connectivity check.""" assert coresys.supervisor.connectivity is True - websession.head.side_effect = side_effect + websession.head = AsyncMock(side_effect=side_effect) await coresys.supervisor.check_connectivity() assert coresys.supervisor.connectivity is connectivity @@ -66,14 +56,14 @@ async def test_connectivity_check( ) async def test_connectivity_check_throttling( coresys: CoreSys, - websession: AsyncMock, + websession: MagicMock, side_effect: Exception | None, call_interval: timedelta, throttled: bool, ): """Test connectivity check throttled when checks succeed.""" coresys.supervisor.connectivity = None - websession.head.side_effect = side_effect + websession.head = AsyncMock(side_effect=side_effect) reset_last_call(Supervisor.check_connectivity) with travel(datetime.now(), tick=False) as traveller: @@ -105,35 +95,32 @@ async def test_update_failed(coresys: CoreSys, capture_exception: Mock): "channel", [UpdateChannel.STABLE, UpdateChannel.BETA, UpdateChannel.DEV] ) async def test_update_apparmor( - coresys: CoreSys, channel: UpdateChannel, tmp_supervisor_data + coresys: CoreSys, channel: UpdateChannel, websession: MagicMock, tmp_supervisor_data ): """Test updating apparmor.""" + websession.get = Mock(return_value=MockResponse()) coresys.updater.channel = channel with ( - patch("supervisor.coresys.aiohttp.ClientSession.get") as get, patch.object(AppArmorControl, "load_profile") as load_profile, ): - get.return_value.__aenter__.return_value.status = 200 - get.return_value.__aenter__.return_value.text = AsyncMock(return_value="") await coresys.supervisor.update_apparmor() - get.assert_called_once_with( + websession.get.assert_called_once_with( f"https://version.home-assistant.io/apparmor_{channel}.txt", timeout=ClientTimeout(total=10), ) load_profile.assert_called_once() -async def test_update_apparmor_error(coresys: CoreSys, tmp_supervisor_data): +async def test_update_apparmor_error( + coresys: CoreSys, websession: MagicMock, tmp_supervisor_data +): """Test error updating apparmor profile.""" + websession.get = Mock(return_value=MockResponse()) with ( - patch("supervisor.coresys.aiohttp.ClientSession.get") as get, patch.object(AppArmorControl, "load_profile"), patch("supervisor.supervisor.Path.write_text", side_effect=(err := OSError())), ): - get.return_value.__aenter__.return_value.status = 200 - get.return_value.__aenter__.return_value.text = AsyncMock(return_value="") - err.errno = errno.EBUSY with pytest.raises(SupervisorAppArmorError): await coresys.supervisor.update_apparmor() diff --git a/tests/test_updater.py b/tests/test_updater.py index 7f14e726a..750a12847 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -1,6 +1,7 @@ """Test updater files.""" import asyncio +import json from unittest.mock import AsyncMock, MagicMock, patch from awesomeversion import AwesomeVersion @@ -11,7 +12,7 @@ from supervisor.coresys import CoreSys from supervisor.dbus.const import ConnectivityState from supervisor.jobs import SupervisorJob -from tests.common import load_binary_fixture +from tests.common import MockResponse, load_binary_fixture from tests.dbus_service_mocks.network_manager import ( NetworkManager as NetworkManagerService, ) @@ -20,15 +21,15 @@ URL_TEST = "https://version.home-assistant.io/stable.json" @pytest.mark.usefixtures("no_job_throttle") -async def test_fetch_versions(coresys: CoreSys) -> None: +async def test_fetch_versions( + coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock +) -> None: """Test download and sync version.""" coresys.security.force = True await coresys.updater.fetch_data() - async with coresys.websession.get(URL_TEST) as request: - data = await request.json() - + data = json.loads(await mock_update_data.text()) assert coresys.updater.version_supervisor == data["supervisor"] assert coresys.updater.version_homeassistant == data["homeassistant"]["default"] @@ -73,7 +74,13 @@ async def test_fetch_versions(coresys: CoreSys) -> None: ("4.20", "5.13"), ], ) -async def test_os_update_path(coresys: CoreSys, version: str, expected: str): +async def test_os_update_path( + coresys: CoreSys, + version: str, + expected: str, + mock_update_data: AsyncMock, + supervisor_internet: AsyncMock, +): """Test OS upgrade path across major versions.""" coresys.os._board = "rpi4" # pylint: disable=protected-access coresys.os._version = AwesomeVersion(version) # pylint: disable=protected-access @@ -85,7 +92,9 @@ async def test_os_update_path(coresys: CoreSys, version: str, expected: str): @pytest.mark.usefixtures("no_job_throttle") async def test_delayed_fetch_for_connectivity( - coresys: CoreSys, network_manager_service: NetworkManagerService + coresys: CoreSys, + network_manager_service: NetworkManagerService, + websession: MagicMock, ): """Test initial version fetch waits for connectivity on load.""" coresys.websession.get = MagicMock()