From 3c73c0f17f2ed755eab8ae7e9e5a74b875149f53 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 Dec 2023 12:12:56 +0100 Subject: [PATCH] Add reauth support to Tailwind (#105959) --- .../components/tailwind/config_flow.py | 54 +++++++++++- .../components/tailwind/coordinator.py | 10 ++- .../components/tailwind/strings.json | 10 +++ tests/components/tailwind/test_config_flow.py | 87 ++++++++++++++++++- tests/components/tailwind/test_init.py | 31 ++++++- 5 files changed, 187 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index 612264907ad..ae63bb1a5e2 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Tailwind integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from gotailwind import ( @@ -14,7 +15,7 @@ from gotailwind import ( import voluptuous as vol from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -38,6 +39,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 host: str + reauth_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -140,6 +142,46 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth(self, _: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with a Tailwind device.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with a Tailwind device.""" + errors = {} + + if user_input is not None and self.reauth_entry: + try: + return await self._async_step_create_entry( + host=self.reauth_entry.data[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + async def _async_step_create_entry(self, *, host: str, token: str) -> FlowResult: """Create entry.""" tailwind = Tailwind( @@ -151,6 +193,16 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): except TailwindUnsupportedFirmwareVersionError: return self.async_abort(reason="unsupported_firmware") + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={CONF_HOST: host, CONF_TOKEN: token}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + await self.async_set_unique_id( format_mac(status.mac_address), raise_on_progress=False ) diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py index 46b3d074045..d918b093605 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -1,11 +1,17 @@ """Data update coordinator for Tailwind.""" from datetime import timedelta -from gotailwind import Tailwind, TailwindDeviceStatus, TailwindError +from gotailwind import ( + Tailwind, + TailwindAuthenticationError, + TailwindDeviceStatus, + TailwindError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -35,5 +41,7 @@ class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]) """Fetch data from the Tailwind device.""" try: return await self.tailwind.status() + except TailwindAuthenticationError as err: + raise ConfigEntryAuthFailed from err except TailwindError as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index 9a11b46a40e..01a254ca0dc 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -1,6 +1,15 @@ { "config": { "step": { + "reauth_confirm": { + "description": "Reauthenticate with your Tailwind garage door opener.\n\nTo do so, you will need to get your new local control key of your Tailwind device. For more details, see the description below the field down below.", + "data": { + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + } + }, "user": { "description": "Set up your Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key and IP address of your Tailwind device. For more details, see the description below the fields down below.", "data": { @@ -31,6 +40,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_device_id": "The discovered Tailwind device did not provide a device ID.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." } }, diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index b9361e5172f..c6afc6e7aec 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -12,7 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import zeroconf from homeassistant.components.tailwind.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -293,3 +293,88 @@ async def test_zeroconf_flow_not_discovered_again( assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_TOKEN] == "123456" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "987654"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {"base": "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_config_entry.add_to_hass(hass) + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" diff --git a/tests/components/tailwind/test_init.py b/tests/components/tailwind/test_init.py index c15646f4459..fb61d155008 100644 --- a/tests/components/tailwind/test_init.py +++ b/tests/components/tailwind/test_init.py @@ -1,10 +1,10 @@ """Integration tests for the Tailwind integration.""" from unittest.mock import MagicMock -from gotailwind import TailwindConnectionError +from gotailwind import TailwindAuthenticationError, TailwindConnectionError from homeassistant.components.tailwind.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -44,3 +44,30 @@ async def test_config_entry_not_ready( assert len(mock_tailwind.status.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_tailwind.status.side_effect = TailwindAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id