From a8321fac9544b046bb937f441f46545bac13ce4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Jul 2024 09:53:39 -0500 Subject: [PATCH] Add reauth support to doorbird (#121815) --- homeassistant/components/doorbird/__init__.py | 18 +--- .../components/doorbird/config_flow.py | 57 ++++++++++-- .../components/doorbird/strings.json | 10 ++- tests/components/doorbird/test_config_flow.py | 88 +++++++++++++++---- 4 files changed, 135 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 11ca0c5e76a..ca17a5093cc 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -52,28 +52,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) -> device = DoorBird(device_ip, username, password, http_session=session) try: - status = await device.ready() info = await device.info() except ClientResponseError as err: if err.status == HTTPStatus.UNAUTHORIZED: - _LOGGER.error( - "Authorization rejected by DoorBird for %s@%s", username, device_ip - ) - return False + raise ConfigEntryAuthFailed from err raise ConfigEntryNotReady from err except OSError as oserr: - _LOGGER.error("Failed to setup doorbird at %s: %s", device_ip, oserr) raise ConfigEntryNotReady from oserr - if not status[0]: - _LOGGER.error( - "Could not connect to DoorBird as %s@%s: Error %s", - username, - device_ip, - str(status[1]), - ) - raise ConfigEntryNotReady - token: str = door_station_config.get(CONF_TOKEN, config_entry_id) custom_url: str | None = door_station_config.get(CONF_CUSTOM_URL) name: str | None = door_station_config.get(CONF_NAME) diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index f91e498b5e7..31204a6663b 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from http import HTTPStatus import logging from typing import Any @@ -21,6 +22,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import VolDictType from .const import ( CONF_EVENTS, @@ -36,14 +38,20 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_OPTIONS = {CONF_EVENTS: [DEFAULT_DOORBELL_EVENT, DEFAULT_MOTION_EVENT]} +AUTH_VOL_DICT: VolDictType = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, +} +AUTH_SCHEMA = vol.Schema(AUTH_VOL_DICT) + + def _schema_with_defaults( host: str | None = None, name: str | None = None ) -> vol.Schema: return vol.Schema( { vol.Required(CONF_HOST, default=host): str, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, + **AUTH_VOL_DICT, vol.Optional(CONF_NAME, default=name): str, } ) @@ -56,7 +64,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], http_session=session ) try: - status = await device.ready() info = await device.info() except ClientResponseError as err: if err.status == HTTPStatus.UNAUTHORIZED: @@ -65,9 +72,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except OSError as err: raise CannotConnect from err - if not status[0]: - raise CannotConnect - mac_addr = get_mac_address_from_door_station_info(info) # Return info that you want to store in the config entry. @@ -96,6 +100,47 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the DoorBird config flow.""" self.discovery_schema: vol.Schema | None = None + self.reauth_entry: ConfigEntry | None = None + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + entry_id = self.context["entry_id"] + self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth input.""" + errors: dict[str, str] = {} + existing_entry = self.reauth_entry + assert existing_entry + existing_data = existing_entry.data + placeholders: dict[str, str] = { + CONF_NAME: existing_data[CONF_NAME], + CONF_HOST: existing_data[CONF_HOST], + } + self.context["title_placeholders"] = placeholders + if user_input is not None: + new_config = { + **existing_data, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + _, errors = await self._async_validate_or_error(new_config) + if not errors: + return self.async_update_reload_and_abort( + existing_entry, data=new_config + ) + + return self.async_show_form( + description_placeholders=placeholders, + step_id="reauth_confirm", + data_schema=AUTH_SCHEMA, + errors=errors, + ) async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 32a898c0dd9..29c85ec7311 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -23,12 +23,20 @@ "data_description": { "host": "The hostname or IP address of your DoorBird device." } + }, + "reauth_confirm": { + "description": "Re-authenticate DoorBird device {name} at {host}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "link_local_address": "Link local addresses are not supported", - "not_doorbird_device": "This device is not a DoorBird" + "not_doorbird_device": "This device is not a DoorBird", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "flow_title": "{name} ({host})", "error": { diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index 107fd1454d3..17cfa05b49e 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -28,9 +28,8 @@ VALID_CONFIG = { } -def _get_mock_doorbirdapi_return_values(ready=None, info=None): +def _get_mock_doorbirdapi_return_values(info=None): doorbirdapi_mock = MagicMock() - type(doorbirdapi_mock).ready = AsyncMock(return_value=ready) type(doorbirdapi_mock).info = AsyncMock(return_value=info) type(doorbirdapi_mock).doorbell_state = AsyncMock( side_effect=aiohttp.ClientResponseError( @@ -40,9 +39,8 @@ def _get_mock_doorbirdapi_return_values(ready=None, info=None): return doorbirdapi_mock -def _get_mock_doorbirdapi_side_effects(ready=None, info=None): +def _get_mock_doorbirdapi_side_effects(info=None): doorbirdapi_mock = MagicMock() - type(doorbirdapi_mock).ready = AsyncMock(side_effect=ready) type(doorbirdapi_mock).info = AsyncMock(side_effect=info) return doorbirdapi_mock @@ -57,9 +55,7 @@ async def test_user_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - doorbirdapi = _get_mock_doorbirdapi_return_values( - ready=[True], info={"WIFI_MAC_ADDR": "macaddr"} - ) + doorbirdapi = _get_mock_doorbirdapi_return_values(info={"WIFI_MAC_ADDR": "macaddr"}) with ( patch( "homeassistant.components.doorbird.config_flow.DoorBird", @@ -184,9 +180,7 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: async def test_form_zeroconf_correct_oui(hass: HomeAssistant) -> None: """Test we can setup from zeroconf with the correct OUI source.""" - doorbirdapi = _get_mock_doorbirdapi_return_values( - ready=[True], info={"WIFI_MAC_ADDR": "macaddr"} - ) + doorbirdapi = _get_mock_doorbirdapi_return_values(info={"WIFI_MAC_ADDR": "macaddr"}) with patch( "homeassistant.components.doorbird.config_flow.DoorBird", @@ -253,9 +247,7 @@ async def test_form_zeroconf_correct_oui_wrong_device( hass: HomeAssistant, doorbell_state_side_effect ) -> None: """Test we can setup from zeroconf with the correct OUI source but not a doorstation.""" - doorbirdapi = _get_mock_doorbirdapi_return_values( - ready=[True], info={"WIFI_MAC_ADDR": "macaddr"} - ) + doorbirdapi = _get_mock_doorbirdapi_return_values(info={"WIFI_MAC_ADDR": "macaddr"}) type(doorbirdapi).doorbell_state = AsyncMock(side_effect=doorbell_state_side_effect) with patch( @@ -286,7 +278,7 @@ async def test_form_user_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - doorbirdapi = _get_mock_doorbirdapi_side_effects(ready=OSError) + doorbirdapi = _get_mock_doorbirdapi_side_effects(info=OSError) with patch( "homeassistant.components.doorbird.config_flow.DoorBird", return_value=doorbirdapi, @@ -309,7 +301,7 @@ async def test_form_user_invalid_auth(hass: HomeAssistant) -> None: mock_error = aiohttp.ClientResponseError( request_info=Mock(), history=Mock(), status=401 ) - doorbirdapi = _get_mock_doorbirdapi_side_effects(ready=mock_error) + doorbirdapi = _get_mock_doorbirdapi_side_effects(info=mock_error) with patch( "homeassistant.components.doorbird.config_flow.DoorBird", return_value=doorbirdapi, @@ -348,3 +340,69 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options == {CONF_EVENTS: ["eventa", "eventc", "eventq"]} + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_NAME: "DoorBird", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + config_entry.add_to_hass(hass) + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + flow = flows[0] + + mock_error = aiohttp.ClientResponseError( + request_info=Mock(), history=Mock(), status=401 + ) + doorbirdapi = _get_mock_doorbirdapi_side_effects(info=mock_error) + with patch( + "homeassistant.components.doorbird.config_flow.DoorBird", + return_value=doorbirdapi, + ): + result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + doorbirdapi = _get_mock_doorbirdapi_return_values(info={"WIFI_MAC_ADDR": "macaddr"}) + with ( + patch( + "homeassistant.components.doorbird.config_flow.DoorBird", + return_value=doorbirdapi, + ), + patch( + "homeassistant.components.doorbird.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.doorbird.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1