diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 93a927d21f3..90e2838ff36 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -1,11 +1,17 @@ """The Bond integration.""" from asyncio import TimeoutError as AsyncIOTimeoutError +import logging -from aiohttp import ClientError, ClientTimeout +from aiohttp import ClientError, ClientResponseError, ClientTimeout from bond_api import Bond, BPUPSubscriptions, start_bpup from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + EVENT_HOMEASSISTANT_STOP, + HTTP_UNAUTHORIZED, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -19,6 +25,8 @@ PLATFORMS = ["cover", "fan", "light", "switch"] _API_TIMEOUT = SLOW_UPDATE_WARNING - 1 _STOP_CANCEL = "stop_cancel" +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Bond from a config entry.""" @@ -35,6 +43,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hub = BondHub(bond) try: await hub.setup() + except ClientResponseError as ex: + if ex.status == HTTP_UNAUTHORIZED: + _LOGGER.error("Bond token no longer valid: %s", ex) + return False + raise ConfigEntryNotReady from ex except (ClientError, AsyncIOTimeoutError, OSError) as error: raise ConfigEntryNotReady from error diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 9285b580851..da8f51227dd 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -9,6 +9,7 @@ from bond_api import Bond import voluptuous as vol from homeassistant import config_entries, exceptions +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_HOST, @@ -16,7 +17,7 @@ from homeassistant.const import ( HTTP_UNAUTHORIZED, ) from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import DiscoveryInfoType @@ -33,6 +34,16 @@ DISCOVERY_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) TOKEN_SCHEMA = vol.Schema({}) +async def async_get_token(hass: HomeAssistant, host: str) -> str | None: + """Try to fetch the token from the bond device.""" + bond = Bond(host, "", session=async_get_clientsession(hass)) + try: + response: dict[str, str] = await bond.token() + except ClientConnectionError: + return None + return response.get("token") + + async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[str, str]: """Validate the user input allows us to connect.""" @@ -75,16 +86,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): online longer then the allowed setup period, and we will instead ask them to manually enter the token. """ - bond = Bond( - self._discovered[CONF_HOST], "", session=async_get_clientsession(self.hass) - ) - try: - response = await bond.token() - except ClientConnectionError: - return - - token = response.get("token") - if token is None: + host = self._discovered[CONF_HOST] + if not (token := await async_get_token(self.hass, host)): return self._discovered[CONF_ACCESS_TOKEN] = token @@ -99,7 +102,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host: str = discovery_info[CONF_HOST] bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured({CONF_HOST: host}) + for entry in self._async_current_entries(): + if entry.unique_id != bond_id: + continue + updates = {CONF_HOST: host} + if entry.state == ConfigEntryState.SETUP_ERROR and ( + token := await async_get_token(self.hass, host) + ): + updates[CONF_ACCESS_TOKEN] = token + self.hass.config_entries.async_update_entry( + entry, data={**entry.data, **updates} + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + raise AbortFlow("already_configured") self._discovered = {CONF_HOST: host, CONF_NAME: bond_id} await self._async_try_automatic_configure() diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 8dd379ed3a7..db9652bf0be 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -2,12 +2,13 @@ from __future__ import annotations from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from aiohttp import ClientConnectionError, ClientResponseError from homeassistant import config_entries, core from homeassistant.components.bond.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from .common import ( @@ -308,6 +309,49 @@ async def test_zeroconf_already_configured(hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 0 +async def test_zeroconf_already_configured_refresh_token(hass: core.HomeAssistant): + """Test starting a flow from zeroconf when already configured and the token is out of date.""" + entry2 = MockConfigEntry( + domain=DOMAIN, + unique_id="not-the-same-bond-id", + data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "correct-token"}, + ) + entry2.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="already-registered-bond-id", + data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "incorrect-token"}, + ) + entry.add_to_hass(hass) + + with patch_bond_version( + side_effect=ClientResponseError(MagicMock(), MagicMock(), status=401) + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR + + with _patch_async_setup_entry() as mock_setup_entry, patch_bond_token( + return_value={"token": "discovered-token"} + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "name": "already-registered-bond-id.some-other-tail-info", + "host": "updated-host", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data["host"] == "updated-host" + assert entry.data[CONF_ACCESS_TOKEN] == "discovered-token" + # entry2 should not get changed + assert entry2.data[CONF_ACCESS_TOKEN] == "correct-token" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_form_unexpected_error(hass: core.HomeAssistant): """Test we handle unexpected error gracefully.""" await _help_test_form_unexpected_error( diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 4ba105248df..42eca44dfa7 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -1,8 +1,10 @@ """Tests for the Bond module.""" -from unittest.mock import Mock +import asyncio +from unittest.mock import MagicMock, Mock from aiohttp import ClientConnectionError, ClientResponseError from bond_api import DeviceType +import pytest from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -33,7 +35,16 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant): assert result is True -async def test_async_setup_raises_entry_not_ready(hass: HomeAssistant): +@pytest.mark.parametrize( + "exc", + [ + ClientConnectionError, + ClientResponseError(MagicMock(), MagicMock(), status=404), + asyncio.TimeoutError, + OSError, + ], +) +async def test_async_setup_raises_entry_not_ready(hass: HomeAssistant, exc: Exception): """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -41,11 +52,26 @@ async def test_async_setup_raises_entry_not_ready(hass: HomeAssistant): ) config_entry.add_to_hass(hass) - with patch_bond_version(side_effect=ClientConnectionError()): + with patch_bond_version(side_effect=exc): await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_async_setup_raises_fails_if_auth_fails(hass: HomeAssistant): + """Test that setup fails if auth fails during setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + config_entry.add_to_hass(hass) + + with patch_bond_version( + side_effect=ClientResponseError(MagicMock(), MagicMock(), status=401) + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAssistant): """Test that configuring entry sets up cover domain.""" config_entry = MockConfigEntry(