From f128bc9ef8cd991a19e5e0f823c7dc6971b27677 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 21 Jul 2021 18:16:27 +0200 Subject: [PATCH] Add reauth flow to Synology DSM (#53204) --- .../components/synology_dsm/__init__.py | 35 +++++++++++- .../components/synology_dsm/config_flow.py | 55 +++++++++++++++++-- .../components/synology_dsm/const.py | 2 + .../components/synology_dsm/strings.json | 11 +++- .../synology_dsm/translations/de.json | 11 +++- .../synology_dsm/translations/en.json | 11 +++- .../synology_dsm/test_config_flow.py | 52 +++++++++++++++++- tests/components/synology_dsm/test_init.py | 28 ++++++++++ 8 files changed, 195 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 0bbf5febbc5..9ec56b898ca 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -18,11 +18,15 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, SynologyDSMLoginFailedException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, SynologyDSMRequestException, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_HOST, @@ -64,6 +68,8 @@ from .const import ( ENTITY_ICON, ENTITY_NAME, ENTITY_UNIT, + EXCEPTION_DETAILS, + EXCEPTION_UNKNOWN, PLATFORMS, SERVICE_REBOOT, SERVICE_SHUTDOWN, @@ -181,6 +187,33 @@ async def async_setup_entry( # noqa: C901 api = SynoApi(hass, entry) try: await api.async_setup() + except ( + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, + ) as err: + if err.args[0] and isinstance(err.args[0], dict): + # pylint: disable=no-member + details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) + else: + details = EXCEPTION_UNKNOWN + _LOGGER.debug( + "Reauthentication for DSM '%s' needed - reason: %s", + entry.unique_id, + details, + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": {**entry.data}, + EXCEPTION_DETAILS: details, + }, + ) + ) + return False except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: _LOGGER.debug( "Unable to connect to DSM '%s' during setup: %s", entry.unique_id, err diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 5f11f158cec..97f9e4343fa 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -46,6 +46,7 @@ from .const import ( DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, + EXCEPTION_DETAILS, ) _LOGGER = logging.getLogger(__name__) @@ -57,6 +58,15 @@ def _discovery_schema_with_defaults(discovery_info: DiscoveryInfoType) -> vol.Sc return vol.Schema(_ordered_shared_schema(discovery_info)) +def _reauth_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: + return vol.Schema( + { + vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str, + vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")): str, + } + ) + + def _user_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: user_schema = { vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, @@ -100,6 +110,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize the synology_dsm config flow.""" self.saved_user_input: dict[str, Any] = {} self.discovered_conf: dict[str, Any] = {} + self.reauth_conf: dict[str, Any] = {} + self.reauth_reason: str | None = None async def _show_setup_form( self, @@ -110,10 +122,18 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if not user_input: user_input = {} + description_placeholders = {} + if self.discovered_conf: user_input.update(self.discovered_conf) step_id = "link" data_schema = _discovery_schema_with_defaults(user_input) + description_placeholders = self.discovered_conf + elif self.reauth_conf: + user_input.update(self.reauth_conf) + step_id = "reauth" + data_schema = _reauth_schema_with_defaults(user_input) + description_placeholders = {EXCEPTION_DETAILS: self.reauth_reason} else: step_id = "user" data_schema = _user_schema_with_defaults(user_input) @@ -122,7 +142,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): step_id=step_id, data_schema=data_schema, errors=errors or {}, - description_placeholders=self.discovered_conf or {}, + description_placeholders=description_placeholders, ) async def async_step_user( @@ -137,6 +157,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if self.discovered_conf: user_input.update(self.discovered_conf) + if self.reauth_conf: + self.reauth_conf.update( + { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) + user_input.update(self.reauth_conf) + host = user_input[CONF_HOST] port = user_input.get(CONF_PORT) username = user_input[CONF_USERNAME] @@ -181,10 +210,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self._show_setup_form(user_input, errors) # unique_id should be serial for services purpose - await self.async_set_unique_id(serial, raise_on_progress=False) - - # Check if already configured - self._abort_if_unique_id_configured() + existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) config_data = { CONF_HOST: host, @@ -202,6 +228,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input.get(CONF_VOLUMES): config_data[CONF_VOLUMES] = user_input[CONF_VOLUMES] + if existing_entry and self.reauth_conf: + self.hass.config_entries.async_update_entry( + existing_entry, data=config_data + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + if existing_entry: + return self.async_abort(reason="already_configured") + return self.async_create_entry(title=host, data=config_data) async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: @@ -227,6 +262,16 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_conf = self.context.get("data", {}) + self.reauth_reason = self.context.get(EXCEPTION_DETAILS) + if user_input is None: + return await self.async_step_user() + return await self.async_step_user(user_input) + async def async_step_link(self, user_input: dict[str, Any]) -> FlowResult: """Link a config entry from discovery.""" return await self.async_step_user(user_input) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 334832ddf2b..e8b919f09d5 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -37,6 +37,8 @@ COORDINATOR_CAMERAS = "coordinator_cameras" COORDINATOR_CENTRAL = "coordinator_central" COORDINATOR_SWITCHES = "coordinator_switches" SYSTEM_LOADED = "system_loaded" +EXCEPTION_DETAILS = "details" +EXCEPTION_UNKNOWN = "unknown" # Entry keys SYNO_API = "syno_api" diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 1464b8a6a06..6baaaaef9f6 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -29,6 +29,14 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } + }, + "reauth": { + "title": "Synology DSM [%key:common::config_flow::title::reauth%]", + "description": "Reason: {details}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -39,7 +47,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index 74867aa9044..5a6c52872db 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/translations/de.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -29,6 +30,14 @@ "description": "M\u00f6chtest du {name} ({host}) einrichten?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Ursache: {details}", + "title": "Synology DSM erneute Authentifizierung notwendig" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 397bad8b14e..0231f8ddb3c 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -29,6 +30,14 @@ "description": "Do you want to setup {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Reason: {details}", + "title": "Synology DSM Reauthenticate Integration" + }, "user": { "data": { "host": "Host", diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 0eb9cb66852..cf043c2ce5f 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -23,7 +23,7 @@ from homeassistant.components.synology_dsm.const import ( DEFAULT_VERIFY_SSL, DOMAIN, ) -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( CONF_DISKS, CONF_HOST, @@ -255,6 +255,56 @@ async def test_user_vdsm(hass: HomeAssistant, service_vdsm: MagicMock): assert result["data"].get(CONF_VOLUMES) is None +async def test_reauth(hass: HomeAssistant, service: MagicMock): + """Test reauthentication.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: f"{PASSWORD}_invalid", + }, + unique_id=SERIAL, + ).add_to_hass(hass) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=True, + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + async def test_abort_if_already_setup(hass: HomeAssistant, service: MagicMock): """Test we abort if the account is already setup.""" MockConfigEntry( diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 891296d97ea..4d6708a2e79 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -2,7 +2,9 @@ from unittest.mock import patch import pytest +from synology_dsm.exceptions import SynologyDSMLoginInvalidException +from homeassistant import data_entry_flow from homeassistant.components.synology_dsm.const import DOMAIN, SERVICES from homeassistant.const import ( CONF_HOST, @@ -40,3 +42,29 @@ async def test_services_registered(hass: HomeAssistant): assert await hass.config_entries.async_setup(entry.entry_id) for service in SERVICES: assert hass.services.has_service(DOMAIN, service) + + +@pytest.mark.no_bypass_setup +async def test_reauth_triggered(hass: HomeAssistant): + """Test if reauthentication flow is triggered.""" + with patch( + "homeassistant.components.synology_dsm.SynoApi.async_setup", + side_effect=SynologyDSMLoginInvalidException(USERNAME), + ), patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", + return_value={"type": data_entry_flow.RESULT_TYPE_FORM}, + ) as mock_async_step_reauth: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + mock_async_step_reauth.assert_called_once()