Files
supervisor/tests/api/test_require_running_system.py
T
Mike Degatano 0e7c4175c5 Block start/restart/rebuild/update APIs while Supervisor is not fully running (#7194)
* Block start/restart/rebuild/update APIs while Supervisor is not fully running

Supervisor boots apps and Home Assistant Core in a specific order, and
replays a similarly ordered sequence while restoring a backup (during
which it is in the freeze state). Calling start/restart/rebuild/update
via the API while one of those sequences is still in progress risks
running it out of order, or blocking a scheduled start from that
sequence outright via job concurrency.

Add a require_running_system decorator that rejects these API calls
unless Supervisor is fully started (CoreState.RUNNING), and apply it to:
- App start/restart/rebuild (fixes #7189) and update
- Home Assistant Core start/restart/rebuild/update
- Plugin (audio/dns/multicast) restart/update, and cli/observer update

Also add a translatable APISystemNotReadyError so the API returns a
clean, user-facing message and error_key instead of leaking the
technical JobConditionException message.

* Address review: neutral error wording + require_running_system test coverage

- Reword APISystemNotReadyError so it no longer implies the block is only
  due to startup, since the same guard also applies while frozen for a
  backup restore.
- Add tests/api/test_require_running_system.py covering the rejection path
  (400 + system_not_ready_error, business logic not invoked) for app
  start/restart/rebuild, app update via the store API, Core
  start/restart/rebuild/update, and all plugin update/restart routes,
  parametrized across SETUP/STARTUP/FREEZE.

* Simplify APISystemNotReadyError message wording

* Switch status code to 503
2026-09-15 14:45:26 +02:00

127 lines
4.6 KiB
Python

"""Test the require_running_system API guard.
Covers the start/restart/rebuild/update routes gated by
supervisor.api.utils.require_running_system: they must reject the call with
a 400 and the system_not_ready_error error_key - and must not invoke the
underlying business-logic method - whenever Supervisor isn't in
CoreState.RUNNING (e.g. while still booting, or while frozen for a backup
restore).
"""
from unittest.mock import AsyncMock, patch
from aiohttp.test_utils import TestClient
import pytest
from supervisor.apps.app import App
from supervisor.apps.manager import AppManager
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.plugins.audio import PluginAudio
from supervisor.plugins.cli import PluginCli
from supervisor.plugins.dns import PluginDns
from supervisor.plugins.multicast import PluginMulticast
from supervisor.plugins.observer import PluginObserver
from ..const import TEST_ADDON_SLUG
# States that must be rejected. Not exhaustive (e.g. omits INITIALIZE,
# SHUTDOWN, STOPPING, CLOSE) but covers the two scenarios called out in the
# require_running_system docstring: still booting, and restoring a backup.
BLOCKED_STATES = [CoreState.SETUP, CoreState.STARTUP, CoreState.FREEZE]
async def _assert_blocked(resp, mocked: AsyncMock) -> None:
"""Assert the call was rejected before reaching the business logic."""
assert resp.status == 503
body = await resp.json()
assert body["error_key"] == "system_not_ready_error"
mocked.assert_not_called()
@pytest.mark.parametrize("blocked_state", BLOCKED_STATES)
@pytest.mark.parametrize("method", ["start", "restart", "rebuild"])
async def test_app_lifecycle_blocked_when_not_running(
app_api_client_with_root: tuple[TestClient, str],
install_app_ssh: App,
coresys: CoreSys,
method: str,
blocked_state: CoreState,
):
"""Test app start/restart/rebuild are rejected unless Supervisor is running."""
client, root = app_api_client_with_root
await coresys.core.set_state(blocked_state)
with patch.object(App, method, new=AsyncMock()) as mocked:
resp = await client.post(f"{root}/{TEST_ADDON_SLUG}/{method}")
await _assert_blocked(resp, mocked)
@pytest.mark.parametrize("blocked_state", BLOCKED_STATES)
async def test_app_update_blocked_when_not_running(
store_app_api_client_with_root: tuple[TestClient, str],
install_app_ssh: App,
coresys: CoreSys,
blocked_state: CoreState,
):
"""Test app update (via the store API) is rejected unless Supervisor is running."""
client, root = store_app_api_client_with_root
await coresys.core.set_state(blocked_state)
with patch.object(AppManager, "update", new=AsyncMock()) as mocked:
resp = await client.post(f"/{root}/{TEST_ADDON_SLUG}/update")
await _assert_blocked(resp, mocked)
@pytest.mark.parametrize("blocked_state", BLOCKED_STATES)
@pytest.mark.parametrize("method", ["start", "restart", "rebuild", "update"])
async def test_core_lifecycle_blocked_when_not_running(
core_api_client_with_root: tuple[TestClient, str],
coresys: CoreSys,
method: str,
blocked_state: CoreState,
):
"""Test Core start/restart/rebuild/update are rejected unless Supervisor is running."""
client, root = core_api_client_with_root
await coresys.core.set_state(blocked_state)
with patch.object(HomeAssistantCore, method, new=AsyncMock()) as mocked:
resp = await client.post(f"{root}/{method}")
await _assert_blocked(resp, mocked)
@pytest.mark.parametrize("blocked_state", BLOCKED_STATES)
@pytest.mark.parametrize(
("plugin_path", "plugin_class", "method"),
[
("audio", PluginAudio, "update"),
("audio", PluginAudio, "restart"),
("dns", PluginDns, "update"),
("dns", PluginDns, "restart"),
("multicast", PluginMulticast, "update"),
("multicast", PluginMulticast, "restart"),
("cli", PluginCli, "update"),
("observer", PluginObserver, "update"),
],
)
async def test_plugin_lifecycle_blocked_when_not_running(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
plugin_path: str,
plugin_class: type,
method: str,
blocked_state: CoreState,
):
"""Test plugin update/restart routes are rejected unless Supervisor is running."""
client, prefix = api_client_with_prefix
await coresys.core.set_state(blocked_state)
with patch.object(plugin_class, method, new=AsyncMock()) as mocked:
resp = await client.post(f"{prefix}/{plugin_path}/{method}")
await _assert_blocked(resp, mocked)