diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py index b3d59f50321..f960b1a8b81 100644 --- a/homeassistant/components/oncue/__init__.py +++ b/homeassistant/components/oncue/__init__.py @@ -5,12 +5,12 @@ from __future__ import annotations from datetime import timedelta import logging -from aiooncue import LoginFailedException, Oncue +from aiooncue import LoginFailedException, Oncue, OncueDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -29,17 +29,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await client.async_login() except CONNECTION_EXCEPTIONS as ex: - raise ConfigEntryNotReady(ex) from ex + raise ConfigEntryNotReady from ex except LoginFailedException as ex: - _LOGGER.error("Failed to login to oncue service: %s", ex) - return False + raise ConfigEntryAuthFailed from ex + + async def _async_update() -> dict[str, OncueDevice]: + """Fetch data from Oncue.""" + try: + return await client.async_fetch_all() + except LoginFailedException as ex: + raise ConfigEntryAuthFailed from ex coordinator = DataUpdateCoordinator( hass, _LOGGER, name=f"Oncue {entry.data[CONF_USERNAME]}", update_interval=timedelta(minutes=10), - update_method=client.async_fetch_all, + update_method=_async_update, always_update=False, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/oncue/config_flow.py b/homeassistant/components/oncue/config_flow.py index ba672dcc588..e423ba08105 100644 --- a/homeassistant/components/oncue/config_flow.py +++ b/homeassistant/components/oncue/config_flow.py @@ -2,13 +2,14 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from aiooncue import LoginFailedException, Oncue import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,30 +23,26 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the oncue config flow.""" + self.reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - try: - await Oncue( - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - async_get_clientsession(self.hass), - ).async_login() - except CONNECTION_EXCEPTIONS: - errors["base"] = "cannot_connect" - except LoginFailedException: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not (errors := await self._async_validate_or_error(user_input)): normalized_username = user_input[CONF_USERNAME].lower() await self.async_set_unique_id(normalized_username) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured( + updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) return self.async_create_entry( title=normalized_username, data=user_input ) @@ -60,3 +57,54 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def _async_validate_or_error(self, config: dict[str, Any]) -> dict[str, str]: + """Validate the user input.""" + errors: dict[str, str] = {} + try: + await Oncue( + config[CONF_USERNAME], + config[CONF_PASSWORD], + async_get_clientsession(self.hass), + ).async_login() + except CONNECTION_EXCEPTIONS: + errors["base"] = "cannot_connect" + except LoginFailedException: + errors[CONF_PASSWORD] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + entry_id = self.context["entry_id"] + self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth input.""" + errors: dict[str, str] = {} + existing_entry = self.reauth_entry + assert existing_entry + existing_data = existing_entry.data + description_placeholders: dict[str, str] = { + CONF_USERNAME: existing_data[CONF_USERNAME] + } + if user_input is not None: + new_config = {**existing_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} + if not (errors := await self._async_validate_or_error(new_config)): + return self.async_update_reload_and_abort( + existing_entry, data=new_config + ) + + return self.async_show_form( + description_placeholders=description_placeholders, + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, + ) diff --git a/homeassistant/components/oncue/strings.json b/homeassistant/components/oncue/strings.json index f7a539fe0e6..ce7561962a2 100644 --- a/homeassistant/components/oncue/strings.json +++ b/homeassistant/components/oncue/strings.json @@ -6,6 +6,12 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "description": "Re-authenticate Oncue account {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -14,7 +20,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index df1452b176e..d88774307c0 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from unittest.mock import patch -from aiooncue import OncueDevice, OncueSensor +from aiooncue import LoginFailedException, OncueDevice, OncueSensor MOCK_ASYNC_FETCH_ALL = { "123456": OncueDevice( @@ -861,3 +861,21 @@ def _patch_login_and_data_unavailable_device(): yield return _patcher() + + +def _patch_login_and_data_auth_failure(): + @contextmanager + def _patcher(): + with ( + patch( + "homeassistant.components.oncue.Oncue.async_login", + side_effect=LoginFailedException, + ), + patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + side_effect=LoginFailedException, + ), + ): + yield + + return _patcher() diff --git a/tests/components/oncue/test_config_flow.py b/tests/components/oncue/test_config_flow.py index 2f327dec052..3907242e26c 100644 --- a/tests/components/oncue/test_config_flow.py +++ b/tests/components/oncue/test_config_flow.py @@ -6,6 +6,7 @@ from aiooncue import LoginFailedException from homeassistant import config_entries from homeassistant.components.oncue.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -42,7 +43,7 @@ async def test_form(hass: HomeAssistant) -> None: "username": "TEST-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 + assert mock_setup_entry.call_count == 1 async def test_form_invalid_auth(hass: HomeAssistant) -> None: @@ -64,7 +65,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -138,3 +139,54 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "any", + CONF_PASSWORD: "old", + }, + ) + config_entry.add_to_hass(hass) + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + flow = flows[0] + + with patch( + "homeassistant.components.oncue.config_flow.Oncue.async_login", + side_effect=LoginFailedException, + ): + result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"password": "invalid_auth"} + + with ( + patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), + patch( + "homeassistant.components.oncue.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_PASSWORD] == "test-password" + assert mock_setup_entry.call_count == 1 diff --git a/tests/components/oncue/test_init.py b/tests/components/oncue/test_init.py index 2da3e04e4c3..cf93b51dee1 100644 --- a/tests/components/oncue/test_init.py +++ b/tests/components/oncue/test_init.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import patch from aiooncue import LoginFailedException @@ -12,10 +13,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from . import _patch_login_and_data +from . import _patch_login_and_data, _patch_login_and_data_auth_failure -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_config_entry_reload(hass: HomeAssistant) -> None: @@ -67,3 +69,26 @@ async def test_config_entry_retry_later(hass: HomeAssistant) -> None: await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_late_auth_failure(hass: HomeAssistant) -> None: + """Test auth fails after already setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + with _patch_login_and_data_auth_failure(): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + flow = flows[0] + assert flow["context"]["source"] == "reauth"