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_supervisor.py
T
Mike Degatano bc24fb5449 Refactor API registration to support v1/v2 via shared methods (#6769)
* Refactor API registration to support v1/v2 via shared methods

- Add AppVersion StrEnum (V1, V2) to supervisor/api/const.py
- Replace self.v2_app with self._v2_app and expose a versions property
  (dict[AppVersion, web.Application]) computed dynamically so that test
  fixtures reassigning self.webapp are automatically reflected in V1
- All _register_* methods now accept a required app: web.Application
  parameter; version-specific routes are gated with
  "if app is self.versions[AppVersion.V1/V2]:"
- load() loops over enabled_versions (V1 always, V2 when feature-flagged)
  and calls each registration method once per version, no duplication
- Static resources are registered before webapp.add_subapp() to avoid
  registering into a frozen router
- add_subapp uses self.webapp directly for readability
- Fold _register_v2_apps/_register_v2_backups/_register_v2_store into
  their respective unified methods; remove the now-defunct _register_v2_*
  helpers and the _api_apps/_api_backups/_api_store instance vars
- _register_proxy and _register_ingress updated to accept app; legacy
  /homeassistant/* proxy routes gated behind V1 conditional

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add dual v1/v2 parametrization to API tests

All 163 tests across 17 API modules that register identically on both
v1 and v2 now run against both versions via api_client_with_prefix.

- tests/api/conftest.py: advanced_logs_tester switched to
  api_client_with_prefix so log-endpoint tests are auto-parametrized;
  accepts optional v2_path_prefix kwarg for paths that differ by version
- tests/api/test_{auth,discovery,dns,docker,hardware,host,ingress,
  jobs,mounts,network,os,resolution,security,services,supervisor}.py:
  api_client -> api_client_with_prefix with path prefix unpacking
- supervisor/api/__init__.py: _register_panel() moved outside the
  version loop -- frontend static assets are V1-only
- tests/api/test_panel.py: kept on plain api_client (V1-only)

Tests intentionally kept V1-only:
- auth/discovery: use indirect api_client parametrize for addon context
- homeassistant: all tests call legacy /homeassistant/* paths (V1-only)
- jobs (4 tests): inner @Job-decorated classes register names into a
  module-level set; re-running the same test raises RuntimeError

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Extend dual v1/v2 parametrization to homeassistant and jobs tests

tests/api/conftest.py:
- Add core_api_client_with_root fixture parametrized over three paths:
    v1-core:   /core/...          (canonical v1 path)
    v1-legacy: /homeassistant/... (legacy v1 alias, same handlers)
    v2-core:   /v2/core/...       (canonical v2 path)

tests/api/test_homeassistant.py:
- Switch all 17 api_client tests to core_api_client_with_root so each
  test runs against all three access paths (v1 canonical, v1 legacy
  alias, v2 canonical), exercising every registered route

tests/api/test_jobs.py:
- Promote four inner TestClass definitions to module-level helpers
  (_JobsTreeTestHelper, _JobManualCleanupTestHelper,
  _JobsSortedTestHelper, _JobWithErrorTestHelper) so that @Job name
  registration into the global _JOB_NAMES set only happens once at
  import time rather than on each parametrized test run
- Replace closure references to outer-scope coresys with self.coresys
- Use api_client_with_prefix for dual-version coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix typo

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-27 23:39:47 +02:00

573 lines
19 KiB
Python

"""Test Supervisor API."""
# pylint: disable=protected-access
import time
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import aiodocker
from aiodocker.containers import DockerContainer
from aiohttp.test_utils import TestClient
from awesomeversion import AwesomeVersion
from blockbuster import BlockingError
import pytest
from supervisor.const import CoreState, FeatureFlag
from supervisor.core import Core
from supervisor.coresys import CoreSys
from supervisor.exceptions import HassioError, HostNotSupportedError, StoreGitError
from supervisor.homeassistant.const import WSEvent
from supervisor.store.repository import Repository
from supervisor.supervisor import Supervisor
from supervisor.updater import Updater
from tests.common import AsyncIterator, load_json_fixture
from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
async def test_api_supervisor_options_debug(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test security options force security."""
api_client, prefix = api_client_with_prefix
assert not coresys.config.debug
await api_client.post(f"{prefix}/supervisor/options", json={"debug": True})
assert coresys.config.debug
async def test_api_supervisor_options_add_repository(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
supervisor_internet: AsyncMock,
):
"""Test add a repository via POST /supervisor/options REST API."""
api_client, prefix = api_client_with_prefix
assert REPO_URL not in coresys.store.repository_urls
with (
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
):
response = await api_client.post(
f"{prefix}/supervisor/options", json={"addons_repositories": [REPO_URL]}
)
assert response.status == 200
assert REPO_URL in coresys.store.repository_urls
async def test_api_supervisor_options_remove_repository(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
test_repository: Repository,
):
"""Test remove a repository via POST /supervisor/options REST API."""
api_client, prefix = api_client_with_prefix
assert test_repository.source in coresys.store.repository_urls
assert test_repository.slug in coresys.store.repositories
response = await api_client.post(
f"{prefix}/supervisor/options", json={"addons_repositories": []}
)
assert response.status == 200
assert test_repository.source not in coresys.store.repository_urls
assert test_repository.slug not in coresys.store.repositories
@pytest.mark.parametrize("git_error", [None, StoreGitError()])
async def test_api_supervisor_options_repositories_skipped_on_error(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
git_error: StoreGitError,
):
"""Test repositories skipped on error via POST /supervisor/options REST API."""
api_client, prefix = api_client_with_prefix
with (
patch("supervisor.store.repository.RepositoryGit.load", side_effect=git_error),
patch("supervisor.store.repository.RepositoryGit.validate", return_value=False),
patch("supervisor.store.repository.RepositoryCustom.remove"),
):
response = await api_client.post(
f"{prefix}/supervisor/options", json={"addons_repositories": [REPO_URL]}
)
assert response.status == 400
assert len(coresys.resolution.suggestions) == 0
assert REPO_URL not in coresys.store.repository_urls
async def test_api_supervisor_options_repo_error_with_config_change(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test config change with add repository error via POST /supervisor/options REST API."""
api_client, prefix = api_client_with_prefix
assert not coresys.config.debug
with patch(
"supervisor.store.repository.RepositoryGit.load", side_effect=StoreGitError()
):
response = await api_client.post(
f"{prefix}/supervisor/options",
json={"debug": True, "addons_repositories": [REPO_URL]},
)
assert response.status == 400
assert REPO_URL not in coresys.store.repository_urls
assert coresys.config.debug
coresys.updater.save_data.assert_called_once()
coresys.config.save_data.assert_called_once()
async def test_api_supervisor_options_auto_update(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test disabling auto update via api."""
api_client, prefix = api_client_with_prefix
assert coresys.updater.auto_update is True
response = await api_client.post(
f"{prefix}/supervisor/options", json={"auto_update": False}
)
assert response.status == 200
assert coresys.updater.auto_update is False
async def test_api_supervisor_options_diagnostics(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
os_agent_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
):
"""Test changing diagnostics."""
api_client, prefix = api_client_with_prefix
os_agent_service: OSAgentService = os_agent_services["os_agent"]
os_agent_service.Diagnostics = False
await os_agent_service.ping()
assert coresys.dbus.agent.diagnostics is False
with patch("supervisor.utils.sentry.sentry_sdk.init") as sentry_init:
response = await api_client.post(
f"{prefix}/supervisor/options", json={"diagnostics": True}
)
assert response.status == 200
sentry_init.assert_called_once()
await os_agent_service.ping()
assert coresys.dbus.agent.diagnostics is True
with patch("supervisor.api.supervisor.close_sentry") as close_sentry:
response = await api_client.post(
f"{prefix}/supervisor/options", json={"diagnostics": False}
)
assert response.status == 200
close_sentry.assert_called_once()
await os_agent_service.ping()
assert coresys.dbus.agent.diagnostics is False
async def test_api_supervisor_logs(advanced_logs_tester):
"""Test supervisor logs."""
await advanced_logs_tester("/supervisor", "hassio_supervisor")
async def test_api_supervisor_fallback(
api_client_with_prefix: tuple[TestClient, str],
journald_logs: MagicMock,
docker_logs: MagicMock,
):
"""Check that supervisor logs read from container logs if reading from journald gateway fails badly."""
api_client, prefix = api_client_with_prefix
journald_logs.side_effect = HassioError("Something bad happened!")
with patch("supervisor.api._LOGGER.exception") as logger:
resp = await api_client.get(f"{prefix}/supervisor/logs")
logger.assert_called_once_with(
"Failed to get supervisor logs using advanced_logs API"
)
assert resp.status == 200
assert resp.content_type == "text/plain"
content = await resp.read()
assert content.split(b"\n")[0:2] == [
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
]
# check fallback also works for the /follow endpoint (no mock reset needed)
with patch("supervisor.api._LOGGER.exception") as logger:
resp = await api_client.get(f"{prefix}/supervisor/logs/follow")
logger.assert_called_once_with(
"Failed to get supervisor logs using advanced_logs API"
)
assert resp.status == 200
assert resp.content_type == "text/plain"
# check the /latest endpoint as well
with patch("supervisor.api._LOGGER.exception") as logger:
resp = await api_client.get(f"{prefix}/supervisor/logs/latest")
logger.assert_called_once_with(
"Failed to get supervisor logs using advanced_logs API"
)
assert resp.status == 200
assert resp.content_type == "text/plain"
# also check generic Python error
journald_logs.side_effect = OSError("Something bad happened!")
with patch("supervisor.api._LOGGER.exception") as logger:
resp = await api_client.get(f"{prefix}/supervisor/logs")
logger.assert_called_once_with(
"Failed to get supervisor logs using advanced_logs API"
)
assert resp.status == 200
assert resp.content_type == "text/plain"
async def test_api_supervisor_fallback_log_capture(
api_client_with_prefix: tuple[TestClient, str],
journald_logs: MagicMock,
docker_logs: MagicMock,
):
"""Check that Sentry log capture is executed only for unexpected errors."""
api_client, prefix = api_client_with_prefix
journald_logs.side_effect = HostNotSupportedError(
"No systemd-journal-gatewayd Unix socket available!"
)
with patch("supervisor.api.async_capture_exception") as capture_exception:
await api_client.get(f"{prefix}/supervisor/logs")
capture_exception.assert_not_called()
journald_logs.reset_mock()
journald_logs.side_effect = HassioError("Something bad happened!")
with patch("supervisor.api.async_capture_exception") as capture_exception:
await api_client.get(f"{prefix}/supervisor/logs")
capture_exception.assert_called_once()
async def test_api_supervisor_reload(
api_client_with_prefix: tuple[TestClient, str],
supervisor_internet: AsyncMock,
websession: MagicMock,
):
"""Test supervisor reload."""
api_client, prefix = api_client_with_prefix
resp = await api_client.post(f"{prefix}/supervisor/reload")
assert resp.status == 200
assert await resp.json() == {"result": "ok", "data": {}}
async def test_api_supervisor_options_timezone(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test setting supervisor timezone via API."""
api_client, prefix = api_client_with_prefix
assert coresys.timezone == "Etc/UTC"
resp = await api_client.post(
f"{prefix}/supervisor/options", json={"timezone": "Europe/Zurich"}
)
assert resp.status == 200
assert coresys.timezone == "Europe/Zurich"
async def test_api_supervisor_options_country(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test setting supervisor country via API."""
api_client, prefix = api_client_with_prefix
assert coresys.config.country is None
resp = await api_client.post(f"{prefix}/supervisor/options", json={"country": "CH"})
assert resp.status == 200
assert coresys.config.country == "CH"
resp = await api_client.get(f"{prefix}/supervisor/info")
assert resp.status == 200
body = await resp.json()
assert body["data"]["country"] == "CH"
@pytest.mark.parametrize(
("blockbuster", "option_value", "config_value"),
[("no_blockbuster", "on", False), ("no_blockbuster", "on-at-startup", True)],
indirect=["blockbuster"],
)
async def test_api_supervisor_options_blocking_io(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
option_value: str,
config_value: bool,
):
"""Test setting supervisor detect blocking io option."""
api_client, prefix = api_client_with_prefix
# This should not fail with a blocking error yet
time.sleep(0)
resp = await api_client.post(
f"{prefix}/supervisor/options", json={"detect_blocking_io": option_value}
)
assert resp.status == 200
resp = await api_client.get(f"{prefix}/supervisor/info")
assert resp.status == 200
body = await resp.json()
assert body["data"]["detect_blocking_io"] is True
# This remains false because we only turned it on for current run of supervisor, not permanently
assert coresys.config.detect_blocking_io is config_value
with pytest.raises(BlockingError):
time.sleep(0)
resp = await api_client.post(
f"{prefix}/supervisor/options", json={"detect_blocking_io": "off"}
)
assert resp.status == 200
resp = await api_client.get(f"{prefix}/supervisor/info")
assert resp.status == 200
body = await resp.json()
assert body["data"]["detect_blocking_io"] is False
assert coresys.config.detect_blocking_io is False
# This should not raise blocking error anymore
time.sleep(0)
@pytest.mark.usefixtures("tmp_supervisor_data")
async def test_api_progress_updates_supervisor_update(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
ha_ws_client: AsyncMock,
):
"""Test progress updates sent to Home Assistant for updates."""
api_client, prefix = api_client_with_prefix
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.core.set_state(CoreState.RUNNING)
logs = load_json_fixture("docker_pull_image_log.json")
coresys.docker.images.pull.return_value = AsyncIterator(logs)
with (
patch.object(
Supervisor,
"version",
new=PropertyMock(return_value=AwesomeVersion("2025.08.0")),
),
patch.object(
Updater,
"version_supervisor",
new=PropertyMock(return_value=AwesomeVersion("2025.08.3")),
),
patch.object(
Updater, "image_supervisor", new=PropertyMock(return_value="supervisor")
),
patch.object(Supervisor, "update_apparmor"),
patch.object(Core, "stop"),
):
resp = await api_client.post(f"{prefix}/supervisor/update")
assert resp.status == 200
events = [
{
"stage": evt.args[0]["data"]["data"]["stage"],
"progress": evt.args[0]["data"]["data"]["progress"],
"done": evt.args[0]["data"]["data"]["done"],
}
for evt in ha_ws_client.async_send_command.call_args_list
if "data" in evt.args[0]
and evt.args[0]["data"]["event"] == WSEvent.JOB
and evt.args[0]["data"]["data"]["name"] == "supervisor_update"
]
# Count-based progress: 2 layers need pulling (each worth 50%)
# Layers that already exist are excluded from progress calculation
assert events[:4] == [
{
"stage": None,
"progress": 0,
"done": False,
},
{
"stage": None,
"progress": 9.2,
"done": False,
},
{
"stage": None,
"progress": 25.6,
"done": False,
},
{
"stage": None,
"progress": 35.4,
"done": False,
},
]
assert events[-5:] == [
{
"stage": None,
"progress": 95.5,
"done": False,
},
{
"stage": None,
"progress": 96.9,
"done": False,
},
{
"stage": None,
"progress": 98.2,
"done": False,
},
{
"stage": None,
"progress": 100,
"done": False,
},
{
"stage": None,
"progress": 100,
"done": True,
},
]
async def test_api_supervisor_stats(
api_client_with_prefix: tuple[TestClient, str], container: DockerContainer
):
"""Test supervisor stats."""
api_client, prefix = api_client_with_prefix
container.show.return_value["State"]["Status"] = "running"
container.show.return_value["State"]["Running"] = True
container.stats = AsyncMock(
return_value=[load_json_fixture("container_stats.json")]
)
resp = await api_client.get(f"{prefix}/supervisor/stats")
assert resp.status == 200
result = await resp.json()
assert result["data"]["cpu_percent"] == 90.0
assert result["data"]["memory_usage"] == 59700000
assert result["data"]["memory_limit"] == 4000000000
assert result["data"]["memory_percent"] == 1.49
async def test_supervisor_api_stats_failure(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
caplog: pytest.LogCaptureFixture,
):
"""Test supervisor stats failure."""
api_client, prefix = api_client_with_prefix
coresys.docker.containers.get.side_effect = aiodocker.DockerError(
500, {"message": "fail"}
)
resp = await api_client.get(f"{prefix}/supervisor/stats")
assert resp.status == 500
body = await resp.json()
assert (
body["message"]
== "An unknown error occurred with Supervisor. Check Supervisor logs for details"
)
assert body["error_key"] == "supervisor_unknown_error"
assert "extra_fields" not in body
assert (
"Could not inspect container 'hassio_supervisor': [500] {'message': 'fail'}"
in caplog.text
)
async def test_api_supervisor_info_feature_flags(
api_client: TestClient, coresys: CoreSys
):
"""Test that supervisor info returns all feature flags with default False."""
resp = await api_client.get("/supervisor/info")
assert resp.status == 200
result = await resp.json()
assert "feature_flags" in result["data"]
feature_flags = result["data"]["feature_flags"]
# All known feature flags should be present and default to False
for feature in FeatureFlag:
assert feature.value in feature_flags
assert feature_flags[feature.value] is False
async def test_api_supervisor_options_feature_flags_enable(
api_client: TestClient, coresys: CoreSys
):
"""Test enabling a feature flag via supervisor options."""
assert not coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API)
response = await api_client.post(
"/supervisor/options",
json={"feature_flags": {"supervisor_v2_api": True}},
)
assert response.status == 200
assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True
async def test_api_supervisor_options_feature_flags_disable(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test disabling a feature flag via supervisor options."""
api_client, prefix = api_client_with_prefix
coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_V2_API, True)
assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True
response = await api_client.post(
f"{prefix}/supervisor/options",
json={"feature_flags": {"supervisor_v2_api": False}},
)
assert response.status == 200
assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is False
async def test_api_supervisor_options_feature_flags_partial_update(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test that omitting a feature flag in options leaves its state unchanged."""
api_client, prefix = api_client_with_prefix
coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_V2_API, True)
# Post options without mentioning feature_flags at all
response = await api_client.post(
f"{prefix}/supervisor/options", json={"debug": False}
)
assert response.status == 200
# The feature flag should remain True
assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True
async def test_api_supervisor_options_feature_flags_unknown_flag(
api_client_with_prefix: tuple[TestClient, str],
):
"""Test that an unknown feature flag name is rejected."""
api_client, prefix = api_client_with_prefix
response = await api_client.post(
f"{prefix}/supervisor/options",
json={"feature_flags": {"unknown_feature": True}},
)
assert response.status == 400