Add reauth support to Tailwind (#105959)

This commit is contained in:
Franck Nijhof 2023-12-18 12:12:56 +01:00 committed by GitHub
parent 60fe6ff771
commit 3c73c0f17f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 187 additions and 5 deletions

View file

@ -1,6 +1,7 @@
"""Config flow to configure the Tailwind integration.""" """Config flow to configure the Tailwind integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any from typing import Any
from gotailwind import ( from gotailwind import (
@ -14,7 +15,7 @@ from gotailwind import (
import voluptuous as vol import voluptuous as vol
from homeassistant.components import zeroconf 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.const import CONF_HOST, CONF_TOKEN
from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -38,6 +39,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
host: str host: str
reauth_entry: ConfigEntry | None = None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -140,6 +142,46 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors, 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: async def _async_step_create_entry(self, *, host: str, token: str) -> FlowResult:
"""Create entry.""" """Create entry."""
tailwind = Tailwind( tailwind = Tailwind(
@ -151,6 +193,16 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
except TailwindUnsupportedFirmwareVersionError: except TailwindUnsupportedFirmwareVersionError:
return self.async_abort(reason="unsupported_firmware") 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( await self.async_set_unique_id(
format_mac(status.mac_address), raise_on_progress=False format_mac(status.mac_address), raise_on_progress=False
) )

View file

@ -1,11 +1,17 @@
"""Data update coordinator for Tailwind.""" """Data update coordinator for Tailwind."""
from datetime import timedelta 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.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -35,5 +41,7 @@ class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus])
"""Fetch data from the Tailwind device.""" """Fetch data from the Tailwind device."""
try: try:
return await self.tailwind.status() return await self.tailwind.status()
except TailwindAuthenticationError as err:
raise ConfigEntryAuthFailed from err
except TailwindError as err: except TailwindError as err:
raise UpdateFailed(err) from err raise UpdateFailed(err) from err

View file

@ -1,6 +1,15 @@
{ {
"config": { "config": {
"step": { "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": { "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.", "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": { "data": {
@ -31,6 +40,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_device_id": "The discovered Tailwind device did not provide a device ID.", "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." "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."
} }
}, },

View file

@ -12,7 +12,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.components.tailwind.const import DOMAIN 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.const import CONF_HOST, CONF_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType 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("type") == FlowResultType.ABORT
assert result.get("reason") == "already_configured" assert result.get("reason") == "already_configured"
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" 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"

View file

@ -1,10 +1,10 @@
"""Integration tests for the Tailwind integration.""" """Integration tests for the Tailwind integration."""
from unittest.mock import MagicMock from unittest.mock import MagicMock
from gotailwind import TailwindConnectionError from gotailwind import TailwindAuthenticationError, TailwindConnectionError
from homeassistant.components.tailwind.const import DOMAIN 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 homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry 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 len(mock_tailwind.status.mock_calls) == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY 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