Files
supervisor/tests/api/test_supervisor.py
T
Mike Degatano 2d153ac497 Add one-shot mode and docker-given totals to stats API (#7168)
* Add one-shot mode and docker-given totals to stats API

- Add one-shot query option to container stats to skip the calculated
  CPU percentage window, returning docker-given totals directly.
- Refactor container_stats() with translatable, structured Docker
  errors and timeout handling instead of generic Docker exceptions.
- Translate low-level Docker errors into component-flavored errors
  (NotRunningError/StatsTimeoutError/UnknownError) for Home Assistant,
  Supervisor, and each plugin (Audio, Cli, CoreDNS, Multicast,
  Observer) as well as apps, mirroring the existing App.stats()
  pattern for consistent, informative API responses.
- Add a shared api_return_stats() helper to remove duplicated stats
  handling logic across the 8 stats API endpoints.
- DockerContainerNotFoundError now inherits from DockerNotFound.

* Contain aiodocker _query access and split v1/v2 stats response model

- Contain the reach into aiodocker's protected _query internals (needed
  for one-shot stats until aio-libs/aiodocker#1054 lands) to a single
  DockerAPI._query_one_shot_stats() helper.
- Split the stats API response model between v1 (legacy, windowed CPU
  percentage still available via opt-in one_shot query string) and v2
  (always one-shot, no query string, cpu_percent dropped since it would
  never carry a meaningful value). Both share the same
  api_return_stats(stats, legacy=...) helper so the response model
  stays in one place.
- DockerAPI.container_stats() keeps its existing one_shot keyword only;
  the legacy/v1 vs v2 distinction is handled entirely in the API layer.

* Detect stopped/restarting containers in one-shot stats response

Docker returns a stub response containing only the container's id/name
(no cpu_stats/memory_stats/networks data) for a stopped or restarting
container instead of an error, even in one-shot mode - the not-running
check in daemon.ContainerStats happens before the one-shot branch, so
requesting one-shot stats doesn't change this. Detect the missing
online_cpus field (only ever present while running) and raise
DockerContainerNotRunningError instead of returning a payload that
DockerStats would otherwise turn into a successful all-zero response.

* Add tests for stats error paths introduced in one-shot/totals PR

- Cover DockerAPI.container_stats/_query_one_shot_stats edge cases: empty
  one-shot response, empty windowed response, and the raw _query_one_shot_stats
  helper.
- Add stats() error-translation tests (not-running, timeout, unknown) for the
  audio, cli, multicast and observer plugins, none of which had any coverage.
- Add stats endpoint tests (v1 + v2) for the cli, audio, dns, multicast and
  observer REST APIs, plus a v1 success-path test for the apps stats endpoint.

These close the codecov-flagged gaps for newly introduced code in this PR.

* Unify one-shot and windowed container_stats() code paths

Both modes now share the same error handling and not-running detection:
- The windowed path no longer inspects the container first to check its
  running state. Docker returns the same id/name-only stub response (no
  online_cpus in cpu_stats) for a stopped or restarting container in
  windowed mode as it does in one-shot mode, so the existing online_cpus
  presence check now covers both, closing a race window where a container
  stopping between the inspect and the stats call would previously reach
  the DockerStats parser with an incomplete payload.
- The windowed path builds its container handle via containers.container(),
  which constructs a DockerContainer from the name with no I/O, instead of
  containers.get(), which performs an unnecessary inspect call just to
  fetch the same information the stats call already returns.

* Fix protected access pylint issue
2026-08-31 17:11:38 +02:00

708 lines
24 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.docker.manager import DockerAPI
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
if prefix == "/v2":
# V2 always requests one-shot stats
stats_fixture = load_json_fixture("container_stats.json")
del stats_fixture["precpu_stats"]
with patch.object(
DockerAPI,
"_query_one_shot_stats",
AsyncMock(return_value=stats_fixture),
):
resp = await api_client.get(f"{prefix}/supervisor/stats")
else:
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()
if prefix == "/v2":
assert "cpu_percent" not in result["data"]
else:
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
if prefix == "/v2":
# V2 always requests one-shot stats, which doesn't call containers.container
with patch.object(
DockerAPI,
"_query_one_shot_stats",
AsyncMock(side_effect=aiodocker.DockerError(500, {"message": "fail"})),
):
resp = await api_client.get(f"{prefix}/supervisor/stats")
else:
coresys.docker.containers.container.return_value.stats.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"
if prefix != "/v2":
assert (
"Can't read stats from 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