mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-07-02 19:35:42 +01:00
ed91b18c4b
* tests: enable flake8-pytest-style (PT) ruff rules Enable the `PT` ruff rule set and fix the resulting violations across the test suite: - PT006: pass parametrize argument names as tuples instead of a single comma-separated string. - PT022: switch fixtures that have no teardown from `yield` to `return` so the lack of cleanup is obvious at a glance. - PT011: add `match=` to broad `pytest.raises(ValueError)` blocks so the expected error is anchored to a specific message. - PT012: hoist setup (patches, branching) out of `pytest.raises()` blocks so only the call that is expected to raise remains inside. - PT013: replace `from pytest import X` with `import pytest` and access attributes via the module. - PT015: replace `try/except` + `assert False` patterns with `pytest.raises(...)`. - PT017: replace `assert` on exceptions inside `except` blocks with `pytest.raises(...) as exc_info` and assert on `exc_info.value`. No behavioral changes to the tests; the full suite still passes. * tests: address review feedback on PT ruff rule enablement - Fix fixture return-type annotations after switching `yield` to `return` in tests/conftest.py: drop the `Generator[...]`/`AsyncGenerator[...]` wrapper for `dns_manager_service`, `supervisor_internet`, `websession`, and `mock_update_data` so the annotation matches what the fixture actually returns. - Correct the return-type annotation of `fixture_ip6config_service` from `IP4ConfigService` to `IP6ConfigService`. - Fix recurring "excepiton" typo in tests/utils/test_exception_helper.py. * tests: verify backup cleanup on permission error After `test_new_backup_permission_error` raises `BackupPermissionError`, assert that no tarfile was left behind and `tmp_path` is empty. The previous version only checked that the exception was raised, which missed any regression where a partial tarfile would survive the failed create. * tests: rename DNS_GOOD_V6 to DNS_V6_UNSUPPORTED The constant was named "good" but its tests assert that the URLs are rejected by the DNS validator. The IPv6 URLs are well-formed but currently rejected because IPv6 doesn't work with the Docker network (see `dns_url` in supervisor/validate.py). Rename the constant and the related test to make the intent obvious.
269 lines
9.0 KiB
Python
269 lines
9.0 KiB
Python
"""Test supervisor object."""
|
|
|
|
import asyncio
|
|
import errno
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
|
|
from aiohttp import ClientTimeout
|
|
from aiohttp.client_exceptions import ClientError
|
|
from awesomeversion import AwesomeVersion
|
|
import pytest
|
|
|
|
from supervisor.const import BusEvent, UpdateChannel
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.docker.supervisor import DockerSupervisor
|
|
from supervisor.exceptions import (
|
|
DockerError,
|
|
SupervisorAppArmorError,
|
|
SupervisorUpdateError,
|
|
)
|
|
from supervisor.host.apparmor import AppArmorControl
|
|
from supervisor.resolution.const import ContextType, IssueType
|
|
from supervisor.resolution.data import Issue
|
|
|
|
from tests.common import MockResponse
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("side_effect", "connectivity"), [(ClientError(), False), (None, True)]
|
|
)
|
|
async def test_connectivity_check(
|
|
coresys: CoreSys,
|
|
websession: MagicMock,
|
|
side_effect: Exception | None,
|
|
connectivity: bool,
|
|
):
|
|
"""Test connectivity check updates state based on probe outcome."""
|
|
assert coresys.supervisor.connectivity is True
|
|
|
|
websession.head = AsyncMock(side_effect=side_effect)
|
|
await coresys.supervisor.check_and_update_connectivity(force=True)
|
|
|
|
assert coresys.supervisor.connectivity is connectivity
|
|
|
|
|
|
async def test_connectivity_check_min_interval_when_connected(
|
|
coresys: CoreSys, websession: MagicMock
|
|
):
|
|
"""Non-forced checks within the min-interval use the cached state."""
|
|
websession.head = AsyncMock()
|
|
|
|
# First call runs the probe.
|
|
await coresys.supervisor.check_and_update_connectivity()
|
|
assert websession.head.call_count == 1
|
|
|
|
# Second call within the (10 min) window should not hit the network.
|
|
await coresys.supervisor.check_and_update_connectivity()
|
|
assert websession.head.call_count == 1
|
|
|
|
|
|
async def test_connectivity_check_force_bypasses_min_interval(
|
|
coresys: CoreSys, websession: MagicMock
|
|
):
|
|
"""force=True skips the min-interval short-circuit."""
|
|
websession.head = AsyncMock()
|
|
|
|
await coresys.supervisor.check_and_update_connectivity()
|
|
assert websession.head.call_count == 1
|
|
|
|
await coresys.supervisor.check_and_update_connectivity(force=True)
|
|
assert websession.head.call_count == 2
|
|
|
|
|
|
async def test_connectivity_check_coalesces_concurrent_callers(
|
|
coresys: CoreSys, websession: MagicMock
|
|
):
|
|
"""Concurrent callers await the same in-flight probe instead of each firing one."""
|
|
probe_started = asyncio.Event()
|
|
probe_release = asyncio.Event()
|
|
|
|
async def slow_head(*args, **kwargs):
|
|
probe_started.set()
|
|
await probe_release.wait()
|
|
|
|
websession.head = AsyncMock(side_effect=slow_head)
|
|
|
|
first = asyncio.create_task(
|
|
coresys.supervisor.check_and_update_connectivity(force=True)
|
|
)
|
|
await probe_started.wait()
|
|
|
|
# Kick off a pile of additional callers while the first probe is in flight.
|
|
concurrent = [
|
|
asyncio.create_task(coresys.supervisor.check_and_update_connectivity())
|
|
for _ in range(5)
|
|
]
|
|
# Let them all reach the in-flight await.
|
|
await asyncio.sleep(0)
|
|
|
|
probe_release.set()
|
|
await asyncio.gather(first, *concurrent)
|
|
|
|
assert websession.head.call_count == 1
|
|
|
|
|
|
async def test_connectivity_check_force_during_in_flight_triggers_rerun(
|
|
coresys: CoreSys, websession: MagicMock
|
|
):
|
|
"""A force signal arriving while a probe is in flight queues exactly one rerun."""
|
|
probe_started = asyncio.Event()
|
|
probe_release = asyncio.Event()
|
|
|
|
async def first_then_fast(*args, **kwargs):
|
|
if websession.head.call_count == 1:
|
|
probe_started.set()
|
|
await probe_release.wait()
|
|
|
|
websession.head = AsyncMock(side_effect=first_then_fast)
|
|
|
|
first = asyncio.create_task(
|
|
coresys.supervisor.check_and_update_connectivity(force=True)
|
|
)
|
|
await probe_started.wait()
|
|
|
|
# Forced call while a probe is in flight should set the rerun flag.
|
|
forced = asyncio.create_task(
|
|
coresys.supervisor.check_and_update_connectivity(force=True)
|
|
)
|
|
# Non-forced calls must NOT queue a rerun.
|
|
cheap = asyncio.create_task(coresys.supervisor.check_and_update_connectivity())
|
|
await asyncio.sleep(0)
|
|
|
|
probe_release.set()
|
|
await asyncio.gather(first, forced, cheap)
|
|
|
|
assert websession.head.call_count == 2
|
|
|
|
|
|
async def test_connectivity_check_owner_cancellation_cancels_probe(
|
|
coresys: CoreSys, websession: MagicMock
|
|
):
|
|
"""Owner cancellation propagates to the probe and skips updating last-check."""
|
|
probe_started = asyncio.Event()
|
|
probe_release = asyncio.Event()
|
|
|
|
async def slow_head(*args, **kwargs):
|
|
probe_started.set()
|
|
await probe_release.wait()
|
|
|
|
websession.head = AsyncMock(side_effect=slow_head)
|
|
last_check_before = coresys.supervisor._connectivity_last_check # pylint: disable=protected-access
|
|
|
|
owner = asyncio.create_task(
|
|
coresys.supervisor.check_and_update_connectivity(force=True)
|
|
)
|
|
await probe_started.wait()
|
|
|
|
owner.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await owner
|
|
|
|
# Owner cancellation must cancel the spawned probe, not orphan it,
|
|
# and the cached last-check timestamp must NOT advance.
|
|
assert coresys.supervisor._connectivity_check is None # pylint: disable=protected-access
|
|
assert coresys.supervisor._connectivity_last_check == last_check_before # pylint: disable=protected-access
|
|
|
|
# A subsequent non-forced call must therefore still run a probe.
|
|
websession.head = AsyncMock()
|
|
await coresys.supervisor.check_and_update_connectivity()
|
|
assert websession.head.call_count == 1
|
|
|
|
|
|
async def test_update_connectivity_fires_event_on_change(coresys: CoreSys):
|
|
"""SUPERVISOR_CONNECTIVITY_CHANGE fires only when the cached value changes."""
|
|
events: list[bool] = []
|
|
|
|
async def listener(state: bool) -> None:
|
|
events.append(state)
|
|
|
|
coresys.bus.register_event(BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, listener)
|
|
|
|
# Same value: no event.
|
|
coresys.supervisor._update_connectivity(True) # pylint: disable=protected-access
|
|
# Change to False: one event.
|
|
coresys.supervisor._update_connectivity(False) # pylint: disable=protected-access
|
|
# Change back to True: another event.
|
|
coresys.supervisor._update_connectivity(True) # pylint: disable=protected-access
|
|
await asyncio.sleep(0)
|
|
|
|
assert events == [False, True]
|
|
|
|
|
|
async def test_request_connectivity_check_is_fire_and_forget(
|
|
coresys: CoreSys, websession: MagicMock
|
|
):
|
|
"""request_connectivity_check schedules a check that runs asynchronously."""
|
|
websession.head = AsyncMock()
|
|
|
|
# Synchronous call must return without awaiting the HTTP probe.
|
|
result = coresys.supervisor.request_connectivity_check(force=True)
|
|
assert result is None
|
|
|
|
# Yield until the scheduled task has had a chance to complete.
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
|
|
assert websession.head.call_count == 1
|
|
|
|
|
|
async def test_update_failed(coresys: CoreSys, capture_exception: Mock):
|
|
"""Test update failure."""
|
|
# pylint: disable-next=protected-access
|
|
coresys.updater._data.setdefault("image", {})["supervisor"] = (
|
|
"ghcr.io/home-assistant/aarch64-hassio-supervisor"
|
|
)
|
|
err = DockerError()
|
|
with (
|
|
patch.object(DockerSupervisor, "install", side_effect=err),
|
|
patch.object(type(coresys.supervisor), "update_apparmor"),
|
|
pytest.raises(SupervisorUpdateError),
|
|
):
|
|
await coresys.supervisor.update(AwesomeVersion("1.0"))
|
|
|
|
capture_exception.assert_called_once_with(err)
|
|
assert (
|
|
Issue(IssueType.UPDATE_FAILED, ContextType.SUPERVISOR)
|
|
in coresys.resolution.issues
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"channel", [UpdateChannel.STABLE, UpdateChannel.BETA, UpdateChannel.DEV]
|
|
)
|
|
async def test_update_apparmor(
|
|
coresys: CoreSys, channel: UpdateChannel, websession: MagicMock, tmp_supervisor_data
|
|
):
|
|
"""Test updating apparmor."""
|
|
websession.get = Mock(return_value=MockResponse())
|
|
coresys.updater.channel = channel
|
|
with (
|
|
patch.object(AppArmorControl, "load_profile") as load_profile,
|
|
):
|
|
await coresys.supervisor.update_apparmor()
|
|
|
|
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, websession: MagicMock, tmp_supervisor_data
|
|
):
|
|
"""Test error updating apparmor profile."""
|
|
websession.get = Mock(return_value=MockResponse())
|
|
with (
|
|
patch.object(AppArmorControl, "load_profile"),
|
|
patch("supervisor.supervisor.Path.write_text", side_effect=(err := OSError())),
|
|
):
|
|
err.errno = errno.EBUSY
|
|
with pytest.raises(SupervisorAppArmorError):
|
|
await coresys.supervisor.update_apparmor()
|
|
assert coresys.core.healthy is True
|
|
|
|
err.errno = errno.EBADMSG
|
|
with pytest.raises(SupervisorAppArmorError):
|
|
await coresys.supervisor.update_apparmor()
|
|
assert coresys.core.healthy is False
|