diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 13dd977b7dc..05494d14869 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -8,8 +8,8 @@ from hyperion import client, const as hyperion_const from pkg_resources import parse_version from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -85,6 +85,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def _create_reauth_flow( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> None: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_REAUTH}, data=config_entry.data + ) + ) + + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hyperion from a config entry.""" host = config_entry.data[CONF_HOST] @@ -92,8 +103,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b token = config_entry.data.get(CONF_TOKEN) hyperion_client = await async_create_connect_hyperion_client( - host, port, token=token + host, port, token=token, raw_connection=True ) + + # Client won't connect? => Not ready. if not hyperion_client: raise ConfigEntryNotReady version = await hyperion_client.async_sysinfo_version() @@ -110,6 +123,31 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except ValueError: pass + # Client needs authentication, but no token provided? => Reauth. + auth_resp = await hyperion_client.async_is_auth_required() + if ( + auth_resp is not None + and client.ResponseOK(auth_resp) + and auth_resp.get(hyperion_const.KEY_INFO, {}).get( + hyperion_const.KEY_REQUIRED, False + ) + and token is None + ): + await _create_reauth_flow(hass, config_entry) + return False + + # Client login doesn't work? => Reauth. + if not await hyperion_client.async_client_login(): + await _create_reauth_flow(hass, config_entry) + return False + + # Cannot switch instance or cannot load state? => Not ready. + if ( + not await hyperion_client.async_client_switch_instance() + or not client.ServerInfoResponseOK(await hyperion_client.async_get_serverinfo()) + ): + raise ConfigEntryNotReady + hyperion_client.set_callbacks( { f"{hyperion_const.KEY_INSTANCE}-{hyperion_const.KEY_UPDATE}": lambda json: ( @@ -139,17 +177,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ] ) hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].append( - config_entry.add_update_listener(_async_options_updated) + config_entry.add_update_listener(_async_entry_updated) ) hass.async_create_task(setup_then_listen()) return True -async def _async_options_updated( +async def _async_entry_updated( hass: HomeAssistantType, config_entry: ConfigEntry ) -> None: - """Handle options update.""" + """Handle entry updates.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index aef74e530b1..11ab3289d14 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -12,11 +12,19 @@ import voluptuous as vol from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL from homeassistant.config_entries import ( CONN_CLASS_LOCAL_PUSH, + SOURCE_REAUTH, ConfigEntry, ConfigFlow, OptionsFlow, ) -from homeassistant.const import CONF_BASE, CONF_HOST, CONF_ID, CONF_PORT, CONF_TOKEN +from homeassistant.const import ( + CONF_BASE, + CONF_HOST, + CONF_ID, + CONF_PORT, + CONF_SOURCE, + CONF_TOKEN, +) from homeassistant.core import callback from homeassistant.helpers.typing import ConfigType @@ -35,13 +43,13 @@ from .const import ( _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) -# +------------------+ +------------------+ +--------------------+ -# |Step: SSDP | |Step: user | |Step: import | -# | | | | | | -# |Input: | |Input: | |Input: | -# +------------------+ +------------------+ +--------------------+ -# v v v -# +----------------------+-----------------------+ +# +------------------+ +------------------+ +--------------------+ +--------------------+ +# |Step: SSDP | |Step: user | |Step: import | |Step: reauth | +# | | | | | | | | +# |Input: | |Input: | |Input: | |Input: | +# +------------------+ +------------------+ +--------------------+ +--------------------+ +# v v v v +# +-------------------+-----------------------+--------------------+ # Auth not | Auth | # required? | required? | # | v @@ -82,7 +90,7 @@ _LOGGER.setLevel(logging.DEBUG) # | # v # +----------------+ -# | Create! | +# | Create/Update! | # +----------------+ # A note on choice of discovery mechanisms: Hyperion supports both Zeroconf and SSDP out @@ -140,6 +148,17 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") return await self._advance_to_auth_step_if_necessary(hyperion_client) + async def async_step_reauth( + self, + config_data: ConfigType, + ) -> Dict[str, Any]: + """Handle a reauthentication flow.""" + self._data = dict(config_data) + async with self._create_client(raw_connection=True) as hyperion_client: + if not hyperion_client: + return self.async_abort(reason="cannot_connect") + return await self._advance_to_auth_step_if_necessary(hyperion_client) + async def async_step_ssdp( # type: ignore[override] self, discovery_info: Dict[str, Any] ) -> Dict[str, Any]: @@ -401,7 +420,18 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): if not hyperion_id: return self.async_abort(reason="no_id") - await self.async_set_unique_id(hyperion_id, raise_on_progress=False) + entry = await self.async_set_unique_id(hyperion_id, raise_on_progress=False) + + # pylint: disable=no-member + if self.context.get(CONF_SOURCE) == SOURCE_REAUTH and entry is not None: + assert self.hass + self.hass.config_entries.async_update_entry(entry, data=self._data) + # Need to manually reload, as the listener won't have been installed because + # the initial load did not succeed (the reauth flow will not be initiated if + # the load succeeds) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + self._abort_if_unique_id_configured() # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 9875f3bd918..6d61af0b66f 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -16,8 +16,6 @@ CONF_ON_UNLOAD = "ON_UNLOAD" SIGNAL_INSTANCES_UPDATED = f"{DOMAIN}_instances_updated_signal." "{}" SIGNAL_INSTANCE_REMOVED = f"{DOMAIN}_instance_removed_signal." "{}" -SOURCE_IMPORT = "import" - HYPERION_VERSION_WARN_CUTOFF = "2.0.0-alpha.9" HYPERION_RELEASES_URL = "https://github.com/hyperion-project/hyperion.ng/releases" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 5aa087c0515..e2989cb973b 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -21,7 +21,7 @@ from homeassistant.components.light import ( SUPPORT_EFFECT, LightEntity, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TOKEN from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -47,7 +47,6 @@ from .const import ( DOMAIN, SIGNAL_INSTANCE_REMOVED, SIGNAL_INSTANCES_UPDATED, - SOURCE_IMPORT, TYPE_HYPERION_LIGHT, ) diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index 180f266f1af..ca7ed238f0b 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -37,7 +37,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "auth_new_token_not_granted_error": "Newly created token was not approved on Hyperion UI", "auth_new_token_not_work_error": "Failed to authenticate using newly created token", - "no_id": "The Hyperion Ambilight instance did not report its id" + "no_id": "The Hyperion Ambilight instance did not report its id", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py index a2febcca2a5..31a6c49eeb3 100644 --- a/tests/components/hyperion/__init__.py +++ b/tests/components/hyperion/__init__.py @@ -50,6 +50,20 @@ TEST_INSTANCE_3: Dict[str, Any] = { "running": True, } +TEST_AUTH_REQUIRED_RESP: Dict[str, Any] = { + "command": "authorize-tokenRequired", + "info": { + "required": True, + }, + "success": True, + "tan": 1, +} + +TEST_AUTH_NOT_REQUIRED_RESP = { + **TEST_AUTH_REQUIRED_RESP, + "info": {"required": False}, +} + _LOGGER = logging.getLogger(__name__) @@ -78,12 +92,7 @@ def create_mock_client() -> Mock: mock_client.async_client_connect = AsyncMock(return_value=True) mock_client.async_client_disconnect = AsyncMock(return_value=True) mock_client.async_is_auth_required = AsyncMock( - return_value={ - "command": "authorize-tokenRequired", - "info": {"required": False}, - "success": True, - "tan": 1, - } + return_value=TEST_AUTH_NOT_REQUIRED_RESP ) mock_client.async_login = AsyncMock( return_value={"command": "authorize-login", "success": True, "tan": 0} @@ -91,6 +100,17 @@ def create_mock_client() -> Mock: mock_client.async_sysinfo_id = AsyncMock(return_value=TEST_SYSINFO_ID) mock_client.async_sysinfo_version = AsyncMock(return_value=TEST_SYSINFO_ID) + mock_client.async_client_switch_instance = AsyncMock(return_value=True) + mock_client.async_client_login = AsyncMock(return_value=True) + mock_client.async_get_serverinfo = AsyncMock( + return_value={ + "command": "serverinfo", + "success": True, + "tan": 0, + "info": {"fake": "data"}, + } + ) + mock_client.adjustment = None mock_client.effects = None mock_client.instances = [ @@ -100,12 +120,15 @@ def create_mock_client() -> Mock: return mock_client -def add_test_config_entry(hass: HomeAssistantType) -> ConfigEntry: +def add_test_config_entry( + hass: HomeAssistantType, data: Optional[Dict[str, Any]] = None +) -> ConfigEntry: """Add a test config entry.""" config_entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call] entry_id=TEST_CONFIG_ENTRY_ID, domain=DOMAIN, - data={ + data=data + or { CONF_HOST: TEST_HOST, CONF_PORT: TEST_PORT, }, @@ -118,10 +141,12 @@ def add_test_config_entry(hass: HomeAssistantType) -> ConfigEntry: async def setup_test_config_entry( - hass: HomeAssistantType, hyperion_client: Optional[Mock] = None + hass: HomeAssistantType, + config_entry: Optional[ConfigEntry] = None, + hyperion_client: Optional[Mock] = None, ) -> ConfigEntry: """Add a test Hyperion entity to hass.""" - config_entry = add_test_config_entry(hass) + config_entry = config_entry or add_test_config_entry(hass) hyperion_client = hyperion_client or create_mock_client() # pylint: disable=attribute-defined-outside-init diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 807a3829e7b..481b7957849 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -11,10 +11,14 @@ from homeassistant.components.hyperion.const import ( CONF_CREATE_TOKEN, CONF_PRIORITY, DOMAIN, - SOURCE_IMPORT, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_IMPORT, + SOURCE_REAUTH, + SOURCE_SSDP, + SOURCE_USER, +) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, @@ -25,6 +29,7 @@ from homeassistant.const import ( from homeassistant.helpers.typing import HomeAssistantType from . import ( + TEST_AUTH_REQUIRED_RESP, TEST_CONFIG_ENTRY_ID, TEST_ENTITY_ID_1, TEST_HOST, @@ -49,15 +54,6 @@ TEST_HOST_PORT: Dict[str, Any] = { CONF_PORT: TEST_PORT, } -TEST_AUTH_REQUIRED_RESP = { - "command": "authorize-tokenRequired", - "info": { - "required": True, - }, - "success": True, - "tan": 1, -} - TEST_AUTH_ID = "ABCDE" TEST_REQUEST_TOKEN_SUCCESS = { "command": "authorize-requestToken", @@ -694,3 +690,62 @@ async def test_options(hass: HomeAssistantType) -> None: blocking=True, ) assert client.async_send_set_color.call_args[1][CONF_PRIORITY] == new_priority + + +async def test_reauth_success(hass: HomeAssistantType) -> None: + """Check a reauth flow that succeeds.""" + + config_data = { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + } + + config_entry = add_test_config_entry(hass, data=config_data) + client = create_mock_client() + client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP) + + with patch( + "homeassistant.components.hyperion.client.HyperionClient", return_value=client + ), patch("homeassistant.components.hyperion.async_setup", return_value=True), patch( + "homeassistant.components.hyperion.async_setup_entry", return_value=True + ): + result = await _init_flow( + hass, + source=SOURCE_REAUTH, + data=config_data, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await _configure_flow( + hass, result, user_input={CONF_CREATE_TOKEN: False, CONF_TOKEN: TEST_TOKEN} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert CONF_TOKEN in config_entry.data + + +async def test_reauth_cannot_connect(hass: HomeAssistantType) -> None: + """Check a reauth flow that fails to connect.""" + + config_data = { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + } + + add_test_config_entry(hass, data=config_data) + client = create_mock_client() + client.async_client_connect = AsyncMock(return_value=False) + + with patch( + "homeassistant.components.hyperion.client.HyperionClient", return_value=client + ): + result = await _init_flow( + hass, + source=SOURCE_REAUTH, + data=config_data, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 5366f6e14d1..4636a9ad59c 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -17,12 +17,26 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, DOMAIN as LIGHT_DOMAIN, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.config_entries import ( + ENTRY_STATE_SETUP_ERROR, + SOURCE_REAUTH, + ConfigEntry, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_PORT, + CONF_SOURCE, + CONF_TOKEN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.typing import HomeAssistantType from . import ( + TEST_AUTH_NOT_REQUIRED_RESP, + TEST_AUTH_REQUIRED_RESP, TEST_CONFIG_ENTRY_OPTIONS, TEST_ENTITY_ID_1, TEST_ENTITY_ID_2, @@ -206,7 +220,9 @@ async def test_setup_config_entry(hass: HomeAssistantType) -> None: assert hass.states.get(TEST_ENTITY_ID_1) is not None -async def test_setup_config_entry_not_ready(hass: HomeAssistantType) -> None: +async def test_setup_config_entry_not_ready_connect_fail( + hass: HomeAssistantType, +) -> None: """Test the component not being ready.""" client = create_mock_client() client.async_client_connect = AsyncMock(return_value=False) @@ -214,6 +230,32 @@ async def test_setup_config_entry_not_ready(hass: HomeAssistantType) -> None: assert hass.states.get(TEST_ENTITY_ID_1) is None +async def test_setup_config_entry_not_ready_switch_instance_fail( + hass: HomeAssistantType, +) -> None: + """Test the component not being ready.""" + client = create_mock_client() + client.async_client_switch_instance = AsyncMock(return_value=False) + await setup_test_config_entry(hass, hyperion_client=client) + assert hass.states.get(TEST_ENTITY_ID_1) is None + + +async def test_setup_config_entry_not_ready_load_state_fail( + hass: HomeAssistantType, +) -> None: + """Test the component not being ready.""" + client = create_mock_client() + client.async_get_serverinfo = AsyncMock( + return_value={ + "command": "serverinfo", + "success": False, + } + ) + + await setup_test_config_entry(hass, hyperion_client=client) + assert hass.states.get(TEST_ENTITY_ID_1) is None + + async def test_setup_config_entry_dynamic_instances(hass: HomeAssistantType) -> None: """Test dynamic changes in the omstamce configuration.""" config_entry = add_test_config_entry(hass) @@ -724,7 +766,7 @@ async def test_unload_entry(hass: HomeAssistantType) -> None: client = create_mock_client() await setup_test_config_entry(hass, hyperion_client=client) assert hass.states.get(TEST_ENTITY_ID_1) is not None - assert client.async_client_connect.called + assert client.async_client_connect.call_count == 2 assert not client.async_client_disconnect.called entry = _get_config_entry_from_unique_id(hass, TEST_SYSINFO_ID) assert entry @@ -749,3 +791,44 @@ async def test_version_no_log_warning(caplog, hass: HomeAssistantType) -> None: await setup_test_config_entry(hass, hyperion_client=client) assert hass.states.get(TEST_ENTITY_ID_1) is not None assert "Please consider upgrading" not in caplog.text + + +async def test_setup_entry_no_token_reauth(hass: HomeAssistantType) -> None: + """Verify a reauth flow when auth is required but no token provided.""" + client = create_mock_client() + config_entry = add_test_config_entry(hass) + client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP) + + with patch( + "homeassistant.components.hyperion.client.HyperionClient", return_value=client + ), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + mock_flow_init.assert_called_once_with( + DOMAIN, + context={CONF_SOURCE: SOURCE_REAUTH}, + data=config_entry.data, + ) + assert config_entry.state == ENTRY_STATE_SETUP_ERROR + + +async def test_setup_entry_bad_token_reauth(hass: HomeAssistantType) -> None: + """Verify a reauth flow when a bad token is provided.""" + client = create_mock_client() + config_entry = add_test_config_entry( + hass, + data={CONF_HOST: TEST_HOST, CONF_PORT: TEST_PORT, CONF_TOKEN: "expired_token"}, + ) + client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_NOT_REQUIRED_RESP) + + # Fail to log in. + client.async_client_login = AsyncMock(return_value=False) + with patch( + "homeassistant.components.hyperion.client.HyperionClient", return_value=client + ), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + mock_flow_init.assert_called_once_with( + DOMAIN, + context={CONF_SOURCE: SOURCE_REAUTH}, + data=config_entry.data, + ) + assert config_entry.state == ENTRY_STATE_SETUP_ERROR