mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-08-13 08:22:36 +01:00
* Gracefully end log stream when journal gateway connection is lost When systemd-journal-gatewayd is stopped while a client follows logs (e.g. on host reboot with the log viewer open), aiohttp raises ClientPayloadError and advanced_logs_handler converted it to an APIError. For the /supervisor/logs endpoints this got logged as an unexpected error with a full traceback and captured to Sentry on every occurrence (#7103, SUPERVISOR-1FHT). Once the streaming response has started, an error response can no longer be delivered anyway, so treat a lost connection to systemd-journal-gatewayd like a client-side disconnect and end the stream gracefully. The APIError is still raised when the connection is lost before any data was sent to the client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Treat unreachable systemd-journal-gatewayd as a known API error Manual testing of the previous commit (killing systemd-journal-gatewayd while Supervisor is running) showed that every log API request hitting the dead gateway logs an "Unexpected error during API call" traceback and captures HostServiceError to Sentry (SUPERVISOR-K8C), in addition to the ERROR already logged at the raise site in journald_logs(). Make HostServiceError inherit from APIError as well, following the HostContainerLogEpochError precedent, so the api_process decorators return a plain 400 response without the redundant traceback and Sentry capture. Also treat it like HostNotSupportedError in the supervisor logs fallback wrapper: fall back to Docker container logs with a warning instead of an exception log plus Sentry capture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use dedicated exception for journal gateway connection errors HostServiceError is also raised by ServiceManager for systemd units called through the API (e.g. /os/config/sync), where inheriting from APIError would hide genuine service breakage from Sentry. Introduce HostJournalGatewaydConnectionError subclassing HostServiceError and APIError, and raise it for the gatewayd connection failure only, as suggested in the PR review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
633 lines
21 KiB
Python
633 lines
21 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_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 = 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
|