mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Fix add-on ingress panels missing after Supervisor timeout or restart (#179025)
This commit is contained in:
@@ -49,7 +49,7 @@ from . import ( # noqa: F401
|
||||
update,
|
||||
)
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState
|
||||
from .addon_panel import async_setup_addon_panel
|
||||
from .addon_panel import async_setup_addon_panel, async_setup_addon_panel_coordinator
|
||||
from .auth import async_setup_auth_view
|
||||
from .config import HassioConfigStore, StoredHassioConfig
|
||||
from .config_entry import async_get_hassio_entry
|
||||
@@ -426,6 +426,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data[MAIN_COORDINATOR] = coordinator
|
||||
entry.async_on_unload(async_setup_addon_panel_coordinator(hass, coordinator))
|
||||
|
||||
jobs_coordinator = SupervisorJobsCoordinator(hass, entry)
|
||||
await jobs_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -9,33 +9,52 @@ from aiohttp import web
|
||||
|
||||
from homeassistant.components import frontend
|
||||
from homeassistant.components.http import HomeAssistantView, require_admin
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
|
||||
from .const import MAIN_COORDINATOR
|
||||
from .coordinator import HassioMainDataUpdateCoordinator
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def async_setup_addon_panel(hass: HomeAssistant) -> None:
|
||||
"""Add-on Ingress Panel setup."""
|
||||
hassio_addon_panel = HassIOAddonPanel(hass)
|
||||
hass.http.register_view(hassio_addon_panel)
|
||||
"""Register the add-on panel push API view."""
|
||||
hass.http.register_view(HassIOAddonPanel(hass))
|
||||
|
||||
# Handle existing panels on startup
|
||||
async def _async_panel_start_handler(event: Event) -> None:
|
||||
"""Process all existing panels on startup."""
|
||||
# Check if there are panels to register
|
||||
if not (panels := await hassio_addon_panel.get_panels()):
|
||||
return
|
||||
|
||||
# Register available panels
|
||||
for addon, data in panels.items():
|
||||
if not data.enable:
|
||||
continue
|
||||
_register_panel(hass, addon, data)
|
||||
@callback
|
||||
def async_setup_addon_panel_coordinator(
|
||||
hass: HomeAssistant, coordinator: HassioMainDataUpdateCoordinator
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Reconcile add-on panels registered with the frontend against coordinator data.
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_panel_start_handler)
|
||||
Registers the panels present after the coordinator's first refresh, then keeps
|
||||
the frontend in sync with coordinator.data.panels on every following update:
|
||||
periodic refreshes, a refresh triggered by a Supervisor restart, and a post/
|
||||
delete pushed by Supervisor and cached via coordinator.async_push_panel /
|
||||
coordinator.async_push_panel_removal.
|
||||
|
||||
Returns a function that unsubscribes from the coordinator.
|
||||
"""
|
||||
registered: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _async_reconcile_panels() -> None:
|
||||
"""Register or remove panels to match the coordinator's cached data."""
|
||||
panels = coordinator.data.panels
|
||||
wanted = {addon for addon, panel in panels.items() if panel.enable}
|
||||
|
||||
for addon in wanted - registered:
|
||||
_register_panel(hass, addon, panels[addon])
|
||||
for addon in registered - wanted:
|
||||
frontend.async_remove_panel(hass, addon, warn_if_unknown=False)
|
||||
|
||||
registered.clear()
|
||||
registered.update(wanted)
|
||||
|
||||
_async_reconcile_panels()
|
||||
return coordinator.async_add_listener(_async_reconcile_panels)
|
||||
|
||||
|
||||
class HassIOAddonPanel(HomeAssistantView):
|
||||
@@ -52,34 +71,46 @@ class HassIOAddonPanel(HomeAssistantView):
|
||||
@require_admin
|
||||
async def post(self, request: web.Request, addon: str) -> web.Response:
|
||||
"""Handle new add-on panel requests."""
|
||||
panels = await self.get_panels()
|
||||
# Supervisor calls this endpoint because an add-on's panel state just
|
||||
# changed, so fetch it fresh instead of relying on the coordinator's
|
||||
# cache, which may still hold the value from before this change.
|
||||
try:
|
||||
panels = await self.client.ingress.panels()
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Can't read panel info: %s", err)
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
# Panel exists for add-on slug
|
||||
if addon not in panels or not panels[addon].enable:
|
||||
_LOGGER.error("Panel is not enabled for %s", addon)
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
# Register panel
|
||||
_register_panel(self.hass, addon, panels[addon])
|
||||
if (coordinator := self.hass.data.get(MAIN_COORDINATOR)) is not None:
|
||||
# Update the cache; the coordinator listener registers it with the frontend.
|
||||
coordinator.async_push_panel(addon, panels[addon])
|
||||
else:
|
||||
_register_panel(self.hass, addon, panels[addon])
|
||||
return web.Response()
|
||||
|
||||
@require_admin
|
||||
async def delete(self, request: web.Request, addon: str) -> web.Response:
|
||||
"""Handle remove add-on panel requests."""
|
||||
frontend.async_remove_panel(self.hass, addon)
|
||||
if (coordinator := self.hass.data.get(MAIN_COORDINATOR)) is not None:
|
||||
# Update the cache; the coordinator listener removes it from the frontend.
|
||||
coordinator.async_push_panel_removal(addon)
|
||||
else:
|
||||
frontend.async_remove_panel(self.hass, addon, warn_if_unknown=False)
|
||||
return web.Response()
|
||||
|
||||
async def get_panels(self) -> dict[str, IngressPanel]:
|
||||
"""Return panels add-on info data."""
|
||||
try:
|
||||
return await self.client.ingress.panels()
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Can't read panel info: %s", err)
|
||||
return {}
|
||||
|
||||
def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel) -> None:
|
||||
"""Helper to register the panel.
|
||||
|
||||
def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel):
|
||||
"""Helper to register the panel."""
|
||||
Uses update=True so this is idempotent: a config entry reload can run this
|
||||
for a panel the frontend still has registered from before the reload, and
|
||||
the push API's early-startup fallback can register one before the
|
||||
coordinator's own reconciliation runs for the first time.
|
||||
"""
|
||||
frontend.async_register_built_in_panel(
|
||||
hass,
|
||||
"app",
|
||||
@@ -88,4 +119,5 @@ def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel):
|
||||
sidebar_icon=data.icon,
|
||||
require_admin=data.admin,
|
||||
config={"addon": addon},
|
||||
update=True,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from aiohasupervisor.models import (
|
||||
HomeAssistantInfo,
|
||||
HomeAssistantStats,
|
||||
HostInfo,
|
||||
IngressPanel,
|
||||
InstalledAddon,
|
||||
InstalledAddonComplete,
|
||||
Issue as SupervisorIssue,
|
||||
@@ -777,6 +778,7 @@ class HassioMainData:
|
||||
host: HostInfo
|
||||
mounts: dict[str, CIFSMountResponse | NFSMountResponse]
|
||||
os: OSInfo | None
|
||||
panels: dict[str, IngressPanel]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the data."""
|
||||
@@ -786,6 +788,7 @@ class HassioMainData:
|
||||
"host": self.host.to_dict(),
|
||||
"mounts": {name: mount.to_dict() for name, mount in self.mounts.items()},
|
||||
"os": self.os.to_dict() if self.os is not None else None,
|
||||
"panels": {slug: panel.to_dict() for slug, panel in self.panels.items()},
|
||||
}
|
||||
|
||||
|
||||
@@ -1526,6 +1529,25 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
):
|
||||
self.config_entry.async_create_task(self.hass, self.async_request_refresh())
|
||||
|
||||
@callback
|
||||
def async_push_panel(self, addon: str, panel: IngressPanel) -> None:
|
||||
"""Apply a Supervisor panel push to cached data without touching refresh state."""
|
||||
self.data = replace(self.data, panels={**self.data.panels, addon: panel})
|
||||
self.async_update_listeners()
|
||||
|
||||
@callback
|
||||
def async_push_panel_removal(self, addon: str) -> None:
|
||||
"""Apply a Supervisor panel removal push to cached data."""
|
||||
if addon not in self.data.panels:
|
||||
return
|
||||
self.data = replace(
|
||||
self.data,
|
||||
panels={
|
||||
slug: panel for slug, panel in self.data.panels.items() if slug != addon
|
||||
},
|
||||
)
|
||||
self.async_update_listeners()
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> HassioMainData:
|
||||
"""Update data via library."""
|
||||
@@ -1535,7 +1557,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
try:
|
||||
# Cast is required here because asyncio.gather only has overloads to
|
||||
# maintain typing for 6 arguments. It falls back to list[<common parent>]
|
||||
# after that which is what mypy sees here since we have 7 API calls.
|
||||
# after that which is what mypy sees here since we have 8 API calls.
|
||||
(
|
||||
info,
|
||||
core_info,
|
||||
@@ -1544,6 +1566,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
host_info,
|
||||
store_info,
|
||||
network_info,
|
||||
panels_info,
|
||||
) = cast(
|
||||
tuple[
|
||||
RootInfo,
|
||||
@@ -1553,6 +1576,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
HostInfo,
|
||||
StoreInfo,
|
||||
NetworkInfo,
|
||||
dict[str, IngressPanel],
|
||||
],
|
||||
await asyncio.gather(
|
||||
client.info(),
|
||||
@@ -1562,6 +1586,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
client.host.info(),
|
||||
client.store.info(),
|
||||
client.network.info(),
|
||||
client.ingress.panels(),
|
||||
),
|
||||
)
|
||||
mounts_info = await client.mounts.info()
|
||||
@@ -1576,6 +1601,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
host=host_info,
|
||||
mounts={mount.name: mount for mount in mounts_info.mounts},
|
||||
os=os_info if self.is_hass_os else None,
|
||||
panels=panels_info,
|
||||
)
|
||||
|
||||
# Update hass.data for legacy accessor functions
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
"""Test add-on panel."""
|
||||
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import IngressPanel
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hassio import DOMAIN
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.components.hassio.const import (
|
||||
MAIN_COORDINATOR,
|
||||
REQUEST_REFRESH_DELAY,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockUser
|
||||
from tests.typing import ClientSessionGenerator
|
||||
from tests.common import MockUser, async_fire_time_changed
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
|
||||
|
||||
@@ -24,10 +31,15 @@ def mock_all(all_setup_requests: None) -> None:
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor_client")
|
||||
async def test_hassio_addon_panel_startup(
|
||||
async def test_hassio_addon_panel_registered_on_setup(
|
||||
hass: HomeAssistant, ingress_panels: AsyncMock
|
||||
) -> None:
|
||||
"""Test startup and panel setup after event."""
|
||||
"""Test enabled panels are registered as part of config entry setup.
|
||||
|
||||
Regression test for https://github.com/home-assistant/supervisor/issues/7015:
|
||||
registration must not depend on the one-shot EVENT_HOMEASSISTANT_START handler
|
||||
that used to swallow Supervisor timeouts and never retry.
|
||||
"""
|
||||
ingress_panels.return_value = {
|
||||
"test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
"test2": IngressPanel(
|
||||
@@ -35,24 +47,145 @@ async def test_hassio_addon_panel_startup(
|
||||
),
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hassio.addon_panel._register_panel"
|
||||
) as mock_panel,
|
||||
patch.dict(os.environ, MOCK_ENVIRON),
|
||||
):
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_panel.assert_called_once_with(
|
||||
hass,
|
||||
"test1",
|
||||
IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor_client")
|
||||
async def test_hassio_addon_panel_registration(
|
||||
hass: HomeAssistant, ingress_panels: AsyncMock
|
||||
) -> None:
|
||||
"""Test panel registration calls frontend.async_register_built_in_panel."""
|
||||
ingress_panels.return_value = {
|
||||
"test_addon": IngressPanel(
|
||||
enable=True, title="Test Addon", icon="mdi:test-tube", admin=True
|
||||
),
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hassio.addon_panel.frontend.async_register_built_in_panel"
|
||||
) as mock_register,
|
||||
patch.dict(os.environ, MOCK_ENVIRON),
|
||||
):
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_register.assert_any_call(
|
||||
hass,
|
||||
"app",
|
||||
frontend_url_path="test_addon",
|
||||
sidebar_title="Test Addon",
|
||||
sidebar_icon="mdi:test-tube",
|
||||
require_admin=True,
|
||||
config={"addon": "test_addon"},
|
||||
update=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_hassio_addon_panel_setup_retries_after_transient_error(
|
||||
hass: HomeAssistant, ingress_panels: AsyncMock
|
||||
) -> None:
|
||||
"""Test a transient Supervisor error fetching panels causes setup to retry.
|
||||
|
||||
Regression test for https://github.com/home-assistant/supervisor/issues/7015:
|
||||
previously a timeout fetching panels at startup was logged and swallowed,
|
||||
leaving panels missing forever with no retry. Panel data is now fetched as
|
||||
part of the main coordinator's first refresh, so a transient failure causes
|
||||
the whole config entry setup to retry until Supervisor is reachable again.
|
||||
"""
|
||||
ingress_panels.side_effect = SupervisorError("Timeout connecting to Supervisor")
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
ingress_panels.side_effect = None
|
||||
ingress_panels.return_value = {
|
||||
"test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_panel._register_panel",
|
||||
"homeassistant.components.hassio.addon_panel._register_panel"
|
||||
) as mock_panel:
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
mock_panel.assert_called_once_with(
|
||||
hass,
|
||||
"test1",
|
||||
IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
)
|
||||
|
||||
|
||||
async def test_hassio_addon_panel_recovers_after_supervisor_restart(
|
||||
hass: HomeAssistant,
|
||||
hass_supervisor_ws_client: WebSocketGenerator,
|
||||
ingress_panels: AsyncMock,
|
||||
) -> None:
|
||||
"""Test panels are refreshed when Supervisor reports it has restarted.
|
||||
|
||||
Regression test for the "Supervisor restarts while Core keeps running"
|
||||
scenario: Supervisor fires a supervisor_update/startup:complete event on
|
||||
every one of its own (re)starts, which the main coordinator already listens
|
||||
for and uses to trigger a refresh.
|
||||
"""
|
||||
ingress_panels.return_value = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hassio.addon_panel._register_panel"
|
||||
) as mock_panel,
|
||||
patch.dict(os.environ, MOCK_ENVIRON),
|
||||
):
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ingress_panels.assert_not_called()
|
||||
mock_panel.assert_not_called()
|
||||
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
ingress_panels.return_value = {
|
||||
"test1": IngressPanel(
|
||||
enable=True, title="Test", icon="mdi:test", admin=False
|
||||
),
|
||||
}
|
||||
|
||||
client = await hass_supervisor_ws_client()
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "supervisor_update",
|
||||
"update_key": "supervisor",
|
||||
"data": {"startup": "complete"},
|
||||
},
|
||||
}
|
||||
)
|
||||
await client.receive_json()
|
||||
|
||||
async_fire_time_changed(
|
||||
hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DELAY + 1)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ingress_panels.assert_called_once()
|
||||
assert mock_panel.called
|
||||
mock_panel.assert_called_with(
|
||||
mock_panel.assert_called_once_with(
|
||||
hass,
|
||||
"test1",
|
||||
IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
@@ -60,10 +193,10 @@ async def test_hassio_addon_panel_startup(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor_client")
|
||||
async def test_hassio_addon_panel_api(
|
||||
async def test_hassio_addon_panel_api_post(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock
|
||||
) -> None:
|
||||
"""Test panel api after event."""
|
||||
"""Test posting a panel push registers it via the coordinator cache."""
|
||||
ingress_panels.return_value = {
|
||||
"test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
"test2": IngressPanel(
|
||||
@@ -75,37 +208,77 @@ async def test_hassio_addon_panel_api(
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
hass_client = await hass_client()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_panel._register_panel",
|
||||
"homeassistant.components.hassio.addon_panel._register_panel"
|
||||
) as mock_panel:
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ingress_panels.assert_called_once()
|
||||
assert mock_panel.called
|
||||
mock_panel.assert_called_with(
|
||||
hass,
|
||||
"test1",
|
||||
IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
)
|
||||
|
||||
hass_client = await hass_client()
|
||||
|
||||
# Panel is not enabled yet according to Supervisor
|
||||
resp = await hass_client.post("/api/hassio_push/panel/test2")
|
||||
assert resp.status == HTTPStatus.BAD_REQUEST
|
||||
mock_panel.assert_not_called()
|
||||
|
||||
# Supervisor enables the panel and pushes the change
|
||||
ingress_panels.return_value["test2"] = IngressPanel(
|
||||
enable=True, title="Test 2", icon="mdi:test2", admin=True
|
||||
)
|
||||
resp = await hass_client.post("/api/hassio_push/panel/test2")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
mock_panel.assert_called_once_with(
|
||||
hass,
|
||||
"test2",
|
||||
IngressPanel(enable=True, title="Test 2", icon="mdi:test2", admin=True),
|
||||
)
|
||||
|
||||
# Posting again for an already-registered, unchanged panel is a no-op
|
||||
mock_panel.reset_mock()
|
||||
resp = await hass_client.post("/api/hassio_push/panel/test1")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert mock_panel.call_count == 2
|
||||
mock_panel.assert_not_called()
|
||||
|
||||
mock_panel.assert_called_with(
|
||||
|
||||
@pytest.mark.usefixtures("supervisor_client")
|
||||
async def test_hassio_addon_panel_api_before_coordinator_ready(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock
|
||||
) -> None:
|
||||
"""Test panel push api falls back to a fresh Supervisor call before setup completes.
|
||||
|
||||
Other callers besides Supervisor may rely on this API before the config
|
||||
entry (and its main coordinator) finishes setting up, so it must keep
|
||||
working via a direct Supervisor call and frontend registration instead of
|
||||
failing with a 503.
|
||||
"""
|
||||
ingress_panels.return_value = {
|
||||
"test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
hass_client = await hass_client()
|
||||
|
||||
# Simulate the main coordinator not being ready yet
|
||||
del hass.data[MAIN_COORDINATOR]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_panel._register_panel"
|
||||
) as mock_panel:
|
||||
resp = await hass_client.post("/api/hassio_push/panel/test1")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
mock_panel.assert_called_once_with(
|
||||
hass,
|
||||
"test1",
|
||||
IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_panel.frontend.async_remove_panel"
|
||||
) as mock_remove:
|
||||
resp = await hass_client.delete("/api/hassio_push/panel/test1")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
mock_remove.assert_called_once_with(hass, "test1", warn_if_unknown=False)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor_client")
|
||||
async def test_hassio_addon_panel_api_non_admin(
|
||||
@@ -123,21 +296,12 @@ async def test_hassio_addon_panel_api_non_admin(
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
hass_admin_user.groups = []
|
||||
hass_client = await hass_client()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_panel._register_panel",
|
||||
"homeassistant.components.hassio.addon_panel._register_panel"
|
||||
) as mock_panel:
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ingress_panels.assert_called_once()
|
||||
mock_panel.assert_called_once()
|
||||
|
||||
mock_panel.reset_mock()
|
||||
hass_admin_user.groups = []
|
||||
hass_client = await hass_client()
|
||||
|
||||
# Both should return unauthorized regardless of enabled as the endpoint requires
|
||||
# admin and the user is not admin
|
||||
resp = await hass_client.post("/api/hassio_push/panel/test2")
|
||||
@@ -149,47 +313,11 @@ async def test_hassio_addon_panel_api_non_admin(
|
||||
mock_panel.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor_client")
|
||||
async def test_hassio_addon_panel_registration(
|
||||
hass: HomeAssistant, ingress_panels: AsyncMock
|
||||
) -> None:
|
||||
"""Test panel registration calls frontend.async_register_built_in_panel."""
|
||||
ingress_panels.return_value = {
|
||||
"test_addon": IngressPanel(
|
||||
enable=True, title="Test Addon", icon="mdi:test-tube", admin=True
|
||||
),
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_panel.frontend.async_register_built_in_panel"
|
||||
) as mock_register:
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify that async_register_built_in_panel was called with correct arguments
|
||||
# for our test addon
|
||||
mock_register.assert_any_call(
|
||||
hass,
|
||||
"app",
|
||||
frontend_url_path="test_addon",
|
||||
sidebar_title="Test Addon",
|
||||
sidebar_icon="mdi:test-tube",
|
||||
require_admin=True,
|
||||
config={"addon": "test_addon"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor_client")
|
||||
async def test_hassio_addon_panel_api_delete(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock
|
||||
) -> None:
|
||||
"""Test panel api delete."""
|
||||
"""Test panel api delete removes it via the coordinator cache."""
|
||||
ingress_panels.return_value = {
|
||||
"test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
|
||||
}
|
||||
@@ -204,7 +332,7 @@ async def test_hassio_addon_panel_api_delete(
|
||||
) as mock_remove:
|
||||
resp = await hass_client.delete("/api/hassio_push/panel/test1")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
mock_remove.assert_called_once_with(hass, "test1")
|
||||
mock_remove.assert_called_once_with(hass, "test1", warn_if_unknown=False)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor_client")
|
||||
|
||||
@@ -174,7 +174,7 @@ async def test_setup_api_ping(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
assert get_core_info(hass)["version_latest"] == "1.0.0"
|
||||
assert is_hassio(hass)
|
||||
|
||||
@@ -310,7 +310,7 @@ async def test_setup_api_push_api_data(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
supervisor_client.homeassistant.set_options.assert_called_once_with(
|
||||
HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY)
|
||||
)
|
||||
@@ -326,7 +326,7 @@ async def test_setup_api_push_api_data_error(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
assert "Failed to update Home Assistant options in Supervisor: boom" in caplog.text
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ async def test_setup_api_push_api_data_server_host(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
supervisor_client.homeassistant.set_options.assert_called_once_with(
|
||||
HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY)
|
||||
)
|
||||
@@ -362,7 +362,7 @@ async def test_setup_api_push_api_data_default(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
supervisor_client.homeassistant.set_options.assert_called_once_with(
|
||||
HomeAssistantOptions(ssl=False, port=80, refresh_token=ANY)
|
||||
)
|
||||
@@ -438,7 +438,7 @@ async def test_setup_api_existing_hassio_user(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
supervisor_client.homeassistant.set_options.assert_called_once_with(
|
||||
HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token)
|
||||
)
|
||||
@@ -483,7 +483,7 @@ async def test_setup_migrates_legacy_hassio_store_to_config_entry(
|
||||
assert entry.options[OPTION_ADD_ON_BACKUP_RETAIN_COPIES] == 2
|
||||
assert entry.options[OPTION_CORE_BACKUP_BEFORE_UPDATE] is True
|
||||
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
supervisor_client.homeassistant.set_options.assert_called_once_with(
|
||||
HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token)
|
||||
)
|
||||
@@ -548,7 +548,7 @@ async def test_setup_core_push_config(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
supervisor_client.supervisor.set_options.assert_called_once_with(
|
||||
SupervisorOptions(timezone="testzone")
|
||||
)
|
||||
@@ -573,7 +573,7 @@ async def test_setup_core_push_config_error(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
assert "Failed to update Supervisor options: boom" in caplog.text
|
||||
|
||||
|
||||
@@ -589,7 +589,7 @@ async def test_setup_hassio_no_additional_data(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
|
||||
|
||||
async def test_fail_setup_without_environ_var(hass: HomeAssistant) -> None:
|
||||
@@ -1320,7 +1320,7 @@ async def test_setup_hardware_integration(
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert result
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
assert len(supervisor_client.mock_calls) == 17
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user