diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 601509aa575..8cb64bb527a 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -1,16 +1,19 @@ """The Sonarr component.""" import asyncio from datetime import timedelta +import logging from typing import Any, Dict -from sonarr import Sonarr, SonarrError +from sonarr import Sonarr, SonarrAccessRestricted, SonarrError -from homeassistant.config_entries import ConfigEntry +from homeassistant.components import persistent_notification +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import ( ATTR_NAME, CONF_API_KEY, CONF_HOST, CONF_PORT, + CONF_SOURCE, CONF_SSL, CONF_VERIFY_SSL, ) @@ -36,6 +39,7 @@ from .const import ( PLATFORMS = ["sensor"] SCAN_INTERVAL = timedelta(seconds=30) +_LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: @@ -69,6 +73,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool try: await sonarr.update() + except SonarrAccessRestricted: + _async_start_reauth(hass, entry) + return False except SonarrError as err: raise ConfigEntryNotReady from err @@ -106,6 +113,24 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> boo return unload_ok +def _async_start_reauth(hass: HomeAssistantType, entry: ConfigEntry): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_REAUTH}, + data={"config_entry_id": entry.entry_id, **entry.data}, + ) + ) + _LOGGER.error("API Key is no longer valid. Please reauthenticate") + + persistent_notification.async_create( + hass, + f"Sonarr integration for the Sonarr API hosted at {entry.entry_data[CONF_HOST]} needs to be re-authenticated. Please go to the integrations page to re-configure it.", + "Sonarr re-authentication", + "sonarr_reauth", + ) + + async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None: """Handle options update.""" async_dispatcher_send( diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index ec1a29c660b..753fb829268 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Optional from sonarr import Sonarr, SonarrAccessRestricted, SonarrError import voluptuous as vol +from homeassistant.components import persistent_notification from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_API_KEY, @@ -61,6 +62,12 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + def __init__(self): + """Initialize the flow.""" + self._reauth = False + self._entry_id = None + self._entry_data = {} + @staticmethod @callback def async_get_options_flow(config_entry): @@ -73,30 +80,87 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initiated by configuration file.""" return await self.async_step_user(user_input) + async def async_step_reauth( + self, data: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle configuration by re-auth.""" + self._reauth = True + self._entry_data = dict(data) + self._entry_id = self._entry_data.pop("config_entry_id") + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"host": self._entry_data[CONF_HOST]}, + data_schema=vol.Schema({}), + errors={}, + ) + + assert self.hass + persistent_notification.async_dismiss(self.hass, "sonarr_reauth") + + return await self.async_step_user() + async def async_step_user( self, user_input: Optional[ConfigType] = None ) -> Dict[str, Any]: """Handle a flow initiated by the user.""" - if user_input is None: - return self._show_setup_form() + errors = {} - if CONF_VERIFY_SSL not in user_input: - user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL + if user_input is not None: + if self._reauth: + user_input = {**self._entry_data, **user_input} - try: - await validate_input(self.hass, user_input) - except SonarrAccessRestricted: - return self._show_setup_form({"base": "invalid_auth"}) - except SonarrError: - return self._show_setup_form({"base": "cannot_connect"}) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - return self.async_abort(reason="unknown") + if CONF_VERIFY_SSL not in user_input: + user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL - return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + try: + await validate_input(self.hass, user_input) + except SonarrAccessRestricted: + errors = {"base": "invalid_auth"} + except SonarrError: + errors = {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + if self._reauth: + return await self._async_reauth_update_entry( + self._entry_id, user_input + ) + + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + data_schema = self._get_user_data_schema() + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(data_schema), + errors=errors, + ) + + async def _async_reauth_update_entry( + self, entry_id: str, data: dict + ) -> Dict[str, Any]: + """Update existing config entry.""" + entry = self.hass.config_entries.async_get_entry(entry_id) + self.hass.config_entries.async_update_entry(entry, data=data) + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + def _get_user_data_schema(self) -> Dict[str, Any]: + """Get the data schema to display user form.""" + if self._reauth: + return {vol.Required(CONF_API_KEY): str} - def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: - """Show the setup form to the user.""" data_schema = { vol.Required(CONF_HOST): str, vol.Required(CONF_API_KEY): str, @@ -110,11 +174,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL) ] = bool - return self.async_show_form( - step_id="user", - data_schema=vol.Schema(data_schema), - errors=errors or {}, - ) + return data_schema class SonarrOptionsFlowHandler(OptionsFlow): diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index 481a3d381f0..7a50879195d 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -13,6 +13,10 @@ "ssl": "Sonarr uses a SSL certificate", "verify_ssl": "Sonarr uses a proper certificate" } + }, + "reauth_confirm": { + "title": "Re-authenticate with Sonarr", + "description": "The Sonarr integration needs to be manually re-authenticated with the Sonarr API hosted at: {host}" } }, "error": { @@ -21,6 +25,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "Successfully re-authenticated", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/tests/components/sonarr/__init__.py b/tests/components/sonarr/__init__.py index 0ce08e0c868..a66184887d5 100644 --- a/tests/components/sonarr/__init__.py +++ b/tests/components/sonarr/__init__.py @@ -35,6 +35,8 @@ MOCK_SENSOR_CONFIG = { "days": 3, } +MOCK_REAUTH_INPUT = {CONF_API_KEY: "test-api-key-reauth"} + MOCK_USER_INPUT = { CONF_HOST: HOST, CONF_PORT: PORT, diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py index 15bd13b580d..2c39e4384e5 100644 --- a/tests/components/sonarr/test_config_flow.py +++ b/tests/components/sonarr/test_config_flow.py @@ -6,8 +6,8 @@ from homeassistant.components.sonarr.const import ( DEFAULT_WANTED_MAX_ITEMS, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_SOURCE, CONF_VERIFY_SSL +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_SOURCE, CONF_VERIFY_SSL from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY, @@ -18,6 +18,7 @@ from homeassistant.helpers.typing import HomeAssistantType from tests.async_mock import patch from tests.components.sonarr import ( HOST, + MOCK_REAUTH_INPUT, MOCK_USER_INPUT, _patch_async_setup, _patch_async_setup_entry, @@ -98,7 +99,7 @@ async def test_unknown_error( async def test_full_import_flow_implementation( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: - """Test the full manual user flow from start to finish.""" + """Test the full manual import flow from start to finish.""" mock_connection(aioclient_mock) user_input = MOCK_USER_INPUT.copy() @@ -117,6 +118,44 @@ async def test_full_import_flow_implementation( assert result["data"][CONF_HOST] == HOST +async def test_full_reauth_flow_implementation( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the manual reauth flow from start to finish.""" + entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True) + assert entry + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_REAUTH}, + data={"config_entry_id": entry.entry_id, **entry.data}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + user_input = MOCK_REAUTH_INPUT.copy() + with _patch_async_setup(), _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert entry.data[CONF_API_KEY] == "test-api-key-reauth" + + mock_setup_entry.assert_called_once() + + async def test_full_user_flow_implementation( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: @@ -180,7 +219,9 @@ async def test_full_user_flow_advanced_options( async def test_options_flow(hass, aioclient_mock: AiohttpClientMocker): """Test updating options.""" - entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True) + with patch("homeassistant.components.sonarr.PLATFORMS", []): + entry = await setup_integration(hass, aioclient_mock) + assert entry.options[CONF_UPCOMING_DAYS] == DEFAULT_UPCOMING_DAYS assert entry.options[CONF_WANTED_MAX_ITEMS] == DEFAULT_WANTED_MAX_ITEMS @@ -194,6 +235,7 @@ async def test_options_flow(hass, aioclient_mock: AiohttpClientMocker): result["flow_id"], user_input={CONF_UPCOMING_DAYS: 2, CONF_WANTED_MAX_ITEMS: 100}, ) + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["data"][CONF_UPCOMING_DAYS] == 2 diff --git a/tests/components/sonarr/test_init.py b/tests/components/sonarr/test_init.py index e9f01290461..258be0203bb 100644 --- a/tests/components/sonarr/test_init.py +++ b/tests/components/sonarr/test_init.py @@ -3,8 +3,11 @@ from homeassistant.components.sonarr.const import DOMAIN from homeassistant.config_entries import ( ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_ERROR, ENTRY_STATE_SETUP_RETRY, + SOURCE_REAUTH, ) +from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from tests.async_mock import patch @@ -20,6 +23,22 @@ async def test_config_entry_not_ready( assert entry.state == ENTRY_STATE_SETUP_RETRY +async def test_config_entry_reauth( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the configuration entry needing to be re-authenticated.""" + with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: + entry = await setup_integration(hass, aioclient_mock, invalid_auth=True) + + assert entry.state == ENTRY_STATE_SETUP_ERROR + + mock_flow_init.assert_called_once_with( + DOMAIN, + context={CONF_SOURCE: SOURCE_REAUTH}, + data={"config_entry_id": entry.entry_id, **entry.data}, + ) + + async def test_unload_config_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: