Files
supervisor/tests/api/test_supervisor.py
T
Mike Degatano 090ef31ec0 Refactor v2 API contracts to remove deprecated supervisor/apps/backups fields (#7111)
* tests(api): cover v1/v2 deprecated contract split

* api: drop deprecated fields from v2 supervisor/apps/backups contracts

* Proper fix for review finding

* Fixes from feedback
2026-08-21 11:55:28 +02:00

682 lines
23 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,
HostJournalGatewaydConnectionError,
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_info_v1_includes_deprecated_fields(
api_client: TestClient,
):
"""Test v1 /supervisor/info includes deprecated contract fields."""
resp = await api_client.get("/supervisor/info")
assert resp.status == 200
data = (await resp.json())["data"]
assert "wait_boot" in data
assert "addons" in data
assert "addons_repositories" in data
async def test_api_supervisor_info_v2_excludes_deprecated_fields(
api_client_v2: TestClient,
):
"""Test v2 /supervisor/info excludes deprecated contract fields."""
resp = await api_client_v2.get("/v2/supervisor/info")
assert resp.status == 200
data = (await resp.json())["data"]
assert "wait_boot" not in data
assert "addons" not in data
assert "addons_repositories" not in data
async def test_api_supervisor_options_add_repository(
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
with (
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
):
response = await api_client.post(
"/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: TestClient,
coresys: CoreSys,
test_repository: Repository,
):
"""Test remove a repository via POST /supervisor/options REST API."""
assert test_repository.source in coresys.store.repository_urls
assert test_repository.slug in coresys.store.repositories
response = await api_client.post(
"/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
async def test_api_supervisor_options_v1_accepts_deprecated_fields(
api_client: TestClient,
coresys: CoreSys,
):
"""Test v1 /supervisor/options accepts deprecated request fields."""
with patch.object(coresys.store, "update_repositories", new=AsyncMock()) as update:
response = await api_client.post(
"/supervisor/options",
json={"wait_boot": 42, "addons_repositories": []},
)
assert response.status == 200
assert coresys.config.wait_boot == 42
update.assert_awaited_once_with(set())
async def test_api_supervisor_options_v2_rejects_deprecated_fields(
api_client_v2: TestClient,
):
"""Test v2 /supervisor/options rejects deprecated request fields."""
response = await api_client_v2.post("/v2/supervisor/options", json={"wait_boot": 7})
assert response.status == 400
response = await api_client_v2.post(
"/v2/supervisor/options", json={"addons_repositories": []}
)
assert response.status == 400
@pytest.mark.parametrize("git_error", [None, StoreGitError()])
async def test_api_supervisor_options_repositories_skipped_on_error(
api_client: TestClient, coresys: CoreSys, git_error: StoreGitError
):
"""Test repositories skipped on error via POST /supervisor/options REST API."""
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(
"/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: TestClient, coresys: CoreSys
):
"""Test config change with add repository error via POST /supervisor/options REST API."""
assert not coresys.config.debug
with patch(
"supervisor.store.repository.RepositoryGit.load", side_effect=StoreGitError()
):
response = await api_client.post(
"/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 = HostJournalGatewaydConnectionError(
"Unable to connect to systemd-journal-gatewayd"
)
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,
},
]
@pytest.mark.parametrize("dev", [True, False])
async def test_api_supervisor_update_no_update_available(
api_client_with_prefix: tuple[TestClient, str],
dev: bool,
):
"""Test versionless update is a no-op when no update is available.
Home Assistant Core triggers a versionless Supervisor update during
onboarding. Without an available update (always the case in DEV) this
must report no update rather than installing the latest published version.
"""
api_client, prefix = api_client_with_prefix
with (
patch.object(CoreSys, "dev", new=PropertyMock(return_value=dev)),
patch.object(Supervisor, "need_update", new=PropertyMock(return_value=False)),
patch.object(Supervisor, "update") as update,
):
resp = await api_client.post(f"{prefix}/supervisor/update")
assert resp.status == 400
result = await resp.json()
assert "No supervisor update available" in result["message"]
update.assert_not_called()
async def test_api_supervisor_update_dev_explicit_version(
api_client_with_prefix: tuple[TestClient, str],
):
"""Test an explicit version can be forced in DEV without an update available."""
api_client, prefix = api_client_with_prefix
with (
patch.object(CoreSys, "dev", new=PropertyMock(return_value=True)),
patch.object(Supervisor, "need_update", new=PropertyMock(return_value=False)),
patch.object(Supervisor, "update") as update,
):
resp = await api_client.post(
f"{prefix}/supervisor/update", json={"version": "2025.08.3"}
)
assert resp.status == 200
update.assert_called_once_with(AwesomeVersion("2025.08.3"))
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