diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 7297f514f966..3c01657da4e2 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -2,9 +2,10 @@ import asyncio import socket -from pyfritzhome import Fritzhome +from pyfritzhome import Fritzhome, LoginError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH from homeassistant.const import ( CONF_DEVICES, CONF_HOST, @@ -62,7 +63,7 @@ async def async_setup(hass, config): for entry_config in config[DOMAIN][CONF_DEVICES]: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": "import"}, data=entry_config + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config ) ) @@ -76,7 +77,18 @@ async def async_setup_entry(hass, entry): user=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) - await hass.async_add_executor_job(fritz.login) + + try: + await hass.async_add_executor_job(fritz.login) + except LoginError: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry, + ) + ) + return False hass.data.setdefault(DOMAIN, {CONF_CONNECTIONS: {}, CONF_DEVICES: set()}) hass.data[DOMAIN][CONF_CONNECTIONS][entry.entry_id] = fritz diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index ee1b3aff241e..f54211aa8a2a 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -47,6 +47,7 @@ class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize flow.""" + self._entry = None self._host = None self._name = None self._password = None @@ -62,6 +63,17 @@ class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) + async def _update_entry(self): + self.hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_HOST: self._host, + CONF_PASSWORD: self._password, + CONF_USERNAME: self._username, + }, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + def _try_connect(self): """Try to connect and check auth.""" fritzbox = Fritzhome( @@ -160,3 +172,41 @@ class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"name": self._name}, errors=errors, ) + + async def async_step_reauth(self, entry): + """Trigger a reauthentication flow.""" + self._entry = entry + self._host = entry.data[CONF_HOST] + self._name = entry.data[CONF_HOST] + self._username = entry.data[CONF_USERNAME] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + self._password = user_input[CONF_PASSWORD] + self._username = user_input[CONF_USERNAME] + + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_SUCCESS: + await self._update_entry() + return self.async_abort(reason="reauth_successful") + if result != RESULT_INVALID_AUTH: + return self.async_abort(reason=result) + errors["base"] = result + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=self._username): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={"name": self._name}, + errors=errors, + ) diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 141348583f4e..6de6b6d9d9ab 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -16,16 +16,24 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "description": "Update your login information for {name}.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "abort": { "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices." + "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } } -} +} \ No newline at end of file diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 31a9f89ce483..f07a78e30de8 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -12,11 +12,19 @@ from homeassistant.components.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, ) +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) from homeassistant.helpers.typing import HomeAssistantType from . import MOCK_CONFIG +from tests.common import MockConfigEntry + MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_SSDP_DATA = { ATTR_SSDP_LOCATION: "https://fake_host:12345/test", @@ -35,15 +43,15 @@ def fritz_fixture() -> Mock: async def test_user(hass: HomeAssistantType, fritz: Mock): """Test starting a flow by user.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "fake_host" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" @@ -56,9 +64,9 @@ async def test_user_auth_failed(hass: HomeAssistantType, fritz: Mock): fritz().login.side_effect = [LoginError("Boom"), mock.DEFAULT] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" @@ -68,33 +76,109 @@ async def test_user_not_successful(hass: HomeAssistantType, fritz: Mock): fritz().login.side_effect = OSError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "no_devices_found" async def test_user_already_configured(hass: HomeAssistantType, fritz: Mock): """Test starting a flow by user when already configured.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert not result["result"].unique_id result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" +async def test_reauth_success(hass: HomeAssistantType, fritz: Mock): + """Test starting a reauthentication flow.""" + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=mock_config + ) + 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={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert mock_config.data[CONF_USERNAME] == "other_fake_user" + assert mock_config.data[CONF_PASSWORD] == "other_fake_password" + + +async def test_reauth_auth_failed(hass: HomeAssistantType, fritz: Mock): + """Test starting a reauthentication flow with authentication failure.""" + fritz().login.side_effect = LoginError("Boom") + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=mock_config + ) + 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={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == "invalid_auth" + + +async def test_reauth_not_successful(hass: HomeAssistantType, fritz: Mock): + """Test starting a reauthentication flow but no connection found.""" + fritz().login.side_effect = OSError("Boom") + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=mock_config + ) + 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={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" + + async def test_import(hass: HomeAssistantType, fritz: Mock): """Test starting a flow by import.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "import"}, data=MOCK_USER_DATA ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "fake_host" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" @@ -105,16 +189,16 @@ async def test_import(hass: HomeAssistantType, fritz: Mock): async def test_ssdp(hass: HomeAssistantType, fritz: Mock): """Test starting a flow from discovery.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "fake_pass", CONF_USERNAME: "fake_user"}, ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "fake_name" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" @@ -127,16 +211,16 @@ async def test_ssdp_no_friendly_name(hass: HomeAssistantType, fritz: Mock): MOCK_NO_NAME = MOCK_SSDP_DATA.copy() del MOCK_NO_NAME[ATTR_UPNP_FRIENDLY_NAME] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_NO_NAME + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_NAME ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "fake_pass", CONF_USERNAME: "fake_user"}, ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "fake_host" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" @@ -149,9 +233,9 @@ async def test_ssdp_auth_failed(hass: HomeAssistantType, fritz: Mock): fritz().login.side_effect = LoginError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" assert result["errors"] == {} @@ -159,7 +243,7 @@ async def test_ssdp_auth_failed(hass: HomeAssistantType, fritz: Mock): result["flow_id"], user_input={CONF_PASSWORD: "whatever", CONF_USERNAME: "whatever"}, ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" assert result["errors"]["base"] == "invalid_auth" @@ -169,16 +253,16 @@ async def test_ssdp_not_successful(hass: HomeAssistantType, fritz: Mock): fritz().login.side_effect = OSError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "whatever", CONF_USERNAME: "whatever"}, ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "no_devices_found" @@ -187,62 +271,62 @@ async def test_ssdp_not_supported(hass: HomeAssistantType, fritz: Mock): fritz().get_device_elements.side_effect = HTTPError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "whatever", CONF_USERNAME: "whatever"}, ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "not_supported" async def test_ssdp_already_in_progress_unique_id(hass: HomeAssistantType, fritz: Mock): """Test starting a flow from discovery twice.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_in_progress" async def test_ssdp_already_in_progress_host(hass: HomeAssistantType, fritz: Mock): """Test starting a flow from discovery twice.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" MOCK_NO_UNIQUE_ID = MOCK_SSDP_DATA.copy() del MOCK_NO_UNIQUE_ID[ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_NO_UNIQUE_ID + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_in_progress" async def test_ssdp_already_configured(hass: HomeAssistantType, fritz: Mock): """Test starting a flow from discovery when already configured.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert not result["result"].unique_id result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result2["type"] == "abort" + assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_configured" assert result["result"].unique_id == "only-a-test"